From b6349077baf81e5ce6deb7bee05689fbe27f0c32 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 8 Sep 2026 21:40:56 -0400 Subject: [PATCH] worktrees --- README.md | 217 ++++++- docs/architecture.md | 8 + packages/cli/skill-data/core/SKILL.md | 28 +- packages/cli/skill-data/git-sync/SKILL.md | 92 ++- packages/cli/skill-data/transform/SKILL.md | 10 +- packages/cli/src/cli.ts | 12 +- packages/cli/src/commands/alert/archive.ts | 1 + packages/cli/src/commands/alert/create.ts | 1 + packages/cli/src/commands/alert/get.ts | 1 + packages/cli/src/commands/alert/list.ts | 1 + packages/cli/src/commands/alert/send.ts | 1 + packages/cli/src/commands/alert/update.ts | 1 + packages/cli/src/commands/auth/list.test.ts | 1 + packages/cli/src/commands/auth/list.ts | 14 +- packages/cli/src/commands/auth/login.ts | 1 + packages/cli/src/commands/auth/logout.ts | 1 + packages/cli/src/commands/auth/render.ts | 9 + packages/cli/src/commands/auth/status.test.ts | 3 + packages/cli/src/commands/auth/status.ts | 7 + packages/cli/src/commands/card/alerts.ts | 1 + packages/cli/src/commands/card/archive.ts | 1 + packages/cli/src/commands/card/create.ts | 1 + packages/cli/src/commands/card/get.ts | 1 + packages/cli/src/commands/card/list.ts | 16 +- packages/cli/src/commands/card/query.ts | 1 + packages/cli/src/commands/card/update.ts | 1 + .../cli/src/commands/collection/archive.ts | 13 +- .../cli/src/commands/collection/create.ts | 14 +- packages/cli/src/commands/collection/get.ts | 18 +- packages/cli/src/commands/collection/items.ts | 27 +- packages/cli/src/commands/collection/list.ts | 12 +- .../cli/src/commands/collection/parse-ref.ts | 11 +- packages/cli/src/commands/collection/tree.ts | 18 +- .../commands/content-translation/download.ts | 1 + .../commands/content-translation/upload.ts | 1 + packages/cli/src/commands/context.test.ts | 3 + packages/cli/src/commands/context.ts | 7 +- .../cli/src/commands/dashboard/archive.ts | 1 + packages/cli/src/commands/dashboard/cards.ts | 1 + packages/cli/src/commands/dashboard/create.ts | 1 + packages/cli/src/commands/dashboard/get.ts | 1 + packages/cli/src/commands/dashboard/list.ts | 1 + .../commands/dashboard/parameter-values.ts | 1 + .../src/commands/dashboard/subscriptions.ts | 1 + .../src/commands/dashboard/update-dashcard.ts | 1 + packages/cli/src/commands/dashboard/update.ts | 1 + packages/cli/src/commands/db/get.ts | 1 + packages/cli/src/commands/db/list.ts | 1 + packages/cli/src/commands/db/rescan-values.ts | 1 + packages/cli/src/commands/db/schema-tables.ts | 1 + packages/cli/src/commands/db/schemas.ts | 1 + packages/cli/src/commands/db/sync-schema.ts | 1 + .../cli/src/commands/delete-runtime.test.ts | 1 + packages/cli/src/commands/document/archive.ts | 1 + packages/cli/src/commands/document/create.ts | 1 + packages/cli/src/commands/document/get.ts | 1 + packages/cli/src/commands/document/list.ts | 1 + packages/cli/src/commands/document/update.ts | 1 + packages/cli/src/commands/eid.ts | 1 + packages/cli/src/commands/field/get.ts | 1 + packages/cli/src/commands/field/summary.ts | 1 + packages/cli/src/commands/field/update.ts | 1 + packages/cli/src/commands/field/values.ts | 1 + packages/cli/src/commands/flags.ts | 8 + .../src/commands/git-sync/add-collection.ts | 1 + .../cli/src/commands/git-sync/branch.test.ts | 51 ++ packages/cli/src/commands/git-sync/branch.ts | 47 ++ .../cli/src/commands/git-sync/branches.ts | 1 + .../cli/src/commands/git-sync/cancel-task.ts | 14 +- .../src/commands/git-sync/create-branch.ts | 22 +- .../cli/src/commands/git-sync/current-task.ts | 12 +- packages/cli/src/commands/git-sync/dirty.ts | 18 +- .../src/commands/git-sync/export-preflight.ts | 89 +++ packages/cli/src/commands/git-sync/export.ts | 37 +- .../commands/git-sync/has-remote-changes.ts | 14 +- packages/cli/src/commands/git-sync/import.ts | 31 +- packages/cli/src/commands/git-sync/index.ts | 1 + .../cli/src/commands/git-sync/is-dirty.ts | 18 +- .../commands/git-sync/remove-collection.ts | 1 + packages/cli/src/commands/git-sync/stash.ts | 1 + packages/cli/src/commands/git-sync/status.ts | 53 +- packages/cli/src/commands/git-sync/wait.ts | 11 +- packages/cli/src/commands/global-flags.ts | 3 +- packages/cli/src/commands/group.ts | 1 + packages/cli/src/commands/library/create.ts | 1 + packages/cli/src/commands/library/get.ts | 19 +- packages/cli/src/commands/library/publish.ts | 1 + .../cli/src/commands/library/unpublish.ts | 1 + packages/cli/src/commands/measure/archive.ts | 1 + packages/cli/src/commands/measure/create.ts | 1 + packages/cli/src/commands/measure/get.ts | 1 + packages/cli/src/commands/measure/list.ts | 1 + packages/cli/src/commands/measure/update.ts | 1 + packages/cli/src/commands/query.ts | 1 + packages/cli/src/commands/runtime.test.ts | 181 +++++- packages/cli/src/commands/runtime.ts | 86 ++- packages/cli/src/commands/search.ts | 19 +- packages/cli/src/commands/segment/archive.ts | 1 + packages/cli/src/commands/segment/create.ts | 1 + packages/cli/src/commands/segment/get.ts | 1 + packages/cli/src/commands/segment/list.ts | 1 + packages/cli/src/commands/segment/update.ts | 1 + packages/cli/src/commands/setting/get.ts | 1 + packages/cli/src/commands/setting/list.ts | 1 + packages/cli/src/commands/setting/set.ts | 1 + packages/cli/src/commands/setup.ts | 1 + packages/cli/src/commands/skills/get.ts | 1 + packages/cli/src/commands/skills/list.ts | 1 + packages/cli/src/commands/skills/path.ts | 1 + packages/cli/src/commands/snippet/archive.ts | 13 +- packages/cli/src/commands/snippet/create.ts | 11 +- packages/cli/src/commands/snippet/get.ts | 16 +- packages/cli/src/commands/snippet/list.ts | 21 +- packages/cli/src/commands/snippet/update.ts | 13 +- .../cli/src/commands/subscription/archive.ts | 1 + .../cli/src/commands/subscription/create.ts | 1 + packages/cli/src/commands/subscription/get.ts | 1 + .../cli/src/commands/subscription/list.ts | 1 + .../cli/src/commands/subscription/update.ts | 1 + packages/cli/src/commands/table/fields.ts | 1 + packages/cli/src/commands/table/get.ts | 1 + packages/cli/src/commands/table/list.ts | 1 + packages/cli/src/commands/table/update.ts | 1 + .../src/commands/timeline-event/archive.ts | 1 + .../cli/src/commands/timeline-event/create.ts | 1 + .../cli/src/commands/timeline-event/delete.ts | 1 + .../cli/src/commands/timeline-event/get.ts | 1 + .../cli/src/commands/timeline-event/update.ts | 1 + packages/cli/src/commands/timeline/archive.ts | 1 + packages/cli/src/commands/timeline/create.ts | 1 + packages/cli/src/commands/timeline/delete.ts | 1 + packages/cli/src/commands/timeline/events.ts | 1 + packages/cli/src/commands/timeline/get.ts | 1 + packages/cli/src/commands/timeline/list.ts | 1 + packages/cli/src/commands/timeline/update.ts | 1 + .../cli/src/commands/transform-job/create.ts | 1 + .../cli/src/commands/transform-job/delete.ts | 1 + .../cli/src/commands/transform-job/get.ts | 1 + .../cli/src/commands/transform-job/list.ts | 1 + .../cli/src/commands/transform-job/run.ts | 1 + .../src/commands/transform-job/set-active.ts | 1 + .../src/commands/transform-job/transforms.ts | 1 + .../cli/src/commands/transform-job/update.ts | 1 + .../cli/src/commands/transform-tag/create.ts | 19 +- .../cli/src/commands/transform-tag/delete.ts | 11 +- .../cli/src/commands/transform-tag/list.ts | 18 +- .../cli/src/commands/transform-tag/scope.ts | 20 + .../cli/src/commands/transform-tag/update.ts | 11 +- packages/cli/src/commands/transform/cancel.ts | 1 + packages/cli/src/commands/transform/create.ts | 20 +- .../src/commands/transform/delete-table.ts | 13 +- packages/cli/src/commands/transform/delete.ts | 13 +- .../src/commands/transform/dependencies.ts | 15 +- .../cli/src/commands/transform/get-run.ts | 1 + packages/cli/src/commands/transform/get.ts | 16 +- packages/cli/src/commands/transform/list.ts | 18 +- packages/cli/src/commands/transform/run.ts | 1 + packages/cli/src/commands/transform/runs.ts | 1 + packages/cli/src/commands/transform/update.ts | 16 +- packages/cli/src/commands/upgrade.ts | 1 + packages/cli/src/commands/upload/append.ts | 1 + packages/cli/src/commands/upload/csv.ts | 1 + packages/cli/src/commands/upload/replace.ts | 1 + packages/cli/src/commands/uuid.ts | 1 + .../cli/src/commands/worktree-scope.test.ts | 82 +++ packages/cli/src/commands/worktree-scope.ts | 84 +++ packages/cli/src/commands/worktree/create.ts | 170 ++++++ packages/cli/src/commands/worktree/delete.ts | 92 +++ packages/cli/src/commands/worktree/get.ts | 27 + packages/cli/src/commands/worktree/index.ts | 15 + packages/cli/src/commands/worktree/list.ts | 24 + packages/cli/src/commands/worktree/pin.ts | 65 ++ packages/cli/src/commands/worktree/unpin.ts | 44 ++ .../cli/src/core/auth/profile-record.test.ts | 29 +- packages/cli/src/core/auth/profile-record.ts | 10 + packages/cli/src/core/auth/storage.test.ts | 36 ++ packages/cli/src/core/auth/storage.ts | 21 +- packages/cli/src/core/env.ts | 1 + packages/cli/src/core/worktree-scope.test.ts | 155 +++++ packages/cli/src/core/worktree-scope.ts | 133 ++++ packages/cli/src/main.ts | 2 + packages/cli/src/output/help.test.ts | 96 +-- packages/cli/src/output/help.ts | 25 +- packages/cli/src/output/views/git-sync.ts | 13 + packages/cli/src/output/views/worktree.ts | 13 + .../cli/src/runtime/command-augment.test.ts | 7 + packages/cli/src/runtime/command-augment.ts | 3 + packages/cli/src/runtime/command-help.test.ts | 45 ++ packages/cli/src/runtime/command-help.ts | 4 + packages/cli/src/runtime/verb-chain.ts | 12 + packages/client/README.md | 10 +- packages/client/src/domain/card.ts | 1 + packages/client/src/domain/collection.ts | 3 + packages/client/src/domain/git-sync.ts | 29 + packages/client/src/domain/snippet.ts | 3 + packages/client/src/domain/transform-tag.ts | 11 +- packages/client/src/domain/transform.ts | 3 + packages/client/src/domain/worktree.ts | 36 ++ packages/client/src/index.test.ts | 2 + packages/client/src/index.ts | 4 + packages/client/src/resources/card.test.ts | 15 + packages/client/src/resources/card.ts | 8 +- .../client/src/resources/collection.test.ts | 46 ++ packages/client/src/resources/collection.ts | 29 +- .../client/src/resources/git-sync.test.ts | 328 +++++++++- packages/client/src/resources/git-sync.ts | 214 ++++++- packages/client/src/resources/library.test.ts | 14 + packages/client/src/resources/library.ts | 19 +- .../client/src/resources/optional-parsed.ts | 8 +- packages/client/src/resources/search.test.ts | 15 + packages/client/src/resources/search.ts | 7 +- packages/client/src/resources/snippet.test.ts | 15 + packages/client/src/resources/snippet.ts | 8 +- .../src/resources/transform-tag.test.ts | 15 + .../client/src/resources/transform-tag.ts | 15 +- .../client/src/resources/transform.test.ts | 15 + packages/client/src/resources/transform.ts | 19 +- tests/e2e/git-sync.e2e.test.ts | 59 +- tests/e2e/help-json.e2e.test.ts | 16 + tests/e2e/run-cli.e2e.test.ts | 1 + tests/e2e/worktree.e2e.test.ts | 576 ++++++++++++++++++ 221 files changed, 4092 insertions(+), 304 deletions(-) create mode 100644 packages/cli/src/commands/git-sync/branch.test.ts create mode 100644 packages/cli/src/commands/git-sync/branch.ts create mode 100644 packages/cli/src/commands/git-sync/export-preflight.ts create mode 100644 packages/cli/src/commands/transform-tag/scope.ts create mode 100644 packages/cli/src/commands/worktree-scope.test.ts create mode 100644 packages/cli/src/commands/worktree-scope.ts create mode 100644 packages/cli/src/commands/worktree/create.ts create mode 100644 packages/cli/src/commands/worktree/delete.ts create mode 100644 packages/cli/src/commands/worktree/get.ts create mode 100644 packages/cli/src/commands/worktree/index.ts create mode 100644 packages/cli/src/commands/worktree/list.ts create mode 100644 packages/cli/src/commands/worktree/pin.ts create mode 100644 packages/cli/src/commands/worktree/unpin.ts create mode 100644 packages/cli/src/core/worktree-scope.test.ts create mode 100644 packages/cli/src/core/worktree-scope.ts create mode 100644 packages/cli/src/output/views/worktree.ts create mode 100644 packages/cli/src/runtime/verb-chain.ts create mode 100644 packages/client/src/domain/worktree.ts create mode 100644 tests/e2e/worktree.e2e.test.ts diff --git a/README.md b/README.md index 04aba583..98e8cc5f 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,15 @@ mb auth status Every `list` and `get` verb takes the same output flags. The per-command flag tables below list only what is specific to that command. -| Flag | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `--json` | Emit JSON. Auto-enabled on non-TTY. Shorthand for `--format json`. | -| `--format ` | `auto` \| `json` \| `text` (default `auto`). | -| `--full` | Return every field. The default is a compact projection. | -| `--fields ` | Project comma-separated dot-paths. Mutually exclusive with `--full`. On list verbs the paths are relative to each `data[]` item. | -| `--max-bytes ` | Output size cap, default `24576`; `0` disables. On a list, trailing items are dropped and `truncated` is set. | -| `-p, --profile ` | Named profile (default `default`). | +| Flag | Description | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `--json` | Emit JSON. Auto-enabled on non-TTY. Shorthand for `--format json`. | +| `--format ` | `auto` \| `json` \| `text` (default `auto`). | +| `--full` | Return every field. The default is a compact projection. | +| `--fields ` | Project comma-separated dot-paths. Mutually exclusive with `--full`. On list verbs the paths are relative to each `data[]` item. | +| `--max-bytes ` | Output size cap, default `24576`; `0` disables. On a list, trailing items are dropped and `truncated` is set. | +| `-p, --profile ` | Named profile (default `default`). | +| `--worktree ` | Run against a git-sync worktree (id or branch) instead of the main app. Only on worktree-scoped commands — see [Worktrees](#worktrees). | Every `list` verb additionally takes a window: @@ -1590,10 +1591,14 @@ mb search products --archived Drive Metabase Enterprise Remote Sync (`/api/ee/remote-sync`) — import / export Metabase content against a configured git remote, inspect dirty state, and manage branches. All git-sync commands require Metabase v60 or newer, the `remote_sync` premium feature on an active EE token, and superuser credentials. +`status`, `is-dirty`, `dirty`, `current-task`, `cancel-task`, `wait`, `has-remote-changes`, `import`, `export`, and `export-preflight` take `--worktree ` and then read and push that worktree's checkout instead of the main app's; the worktree path needs a v64 server. `stash`, `create-branch`, `add-collection`, and `remove-collection` change main-app state and refuse to run while a worktree scope is in force. See [Worktrees](#worktrees). + ### `mb git-sync status` Roll up the current sync state in one call: configured branch, dirty flag, the most recent sync task (or `null` if none has ever run), and the collections marked for sync. +The `worktree` field carries `{ id, branch }` when a worktree scope is in force and `null` in the main app. Under a scope `branch` is the worktree's own branch, `is_dirty` and `current_task` are that worktree's, `synced_collections` is empty (the sync scope is a main-app setting), and the summary line opens with `Worktree () —`. + ```sh mb git-sync status mb git-sync status --json @@ -1674,10 +1679,13 @@ mb git-sync import --force --no-wait | ----------------------- | --------------------------------------------------------------------- | | `--branch `, `-b` | Branch to import from (defaults to the `remote-sync-branch` setting). | | `--force` | Discard local Metabase-side dirty changes before importing (LOSSY). | +| `--merge` | Three-way merge remote changes instead of failing on divergence. | | `--wait` / `--no-wait` | Poll until the task reaches a terminal status (default: wait). | | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | | `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | +In the main app the `remote-sync-branch` setting is always read and sent as `expected_branch` — the branch the server verifies it is still on — while `--branch` chooses where the content moves. `--branch` is not a substitute for the setting: with it unset the command exits `2` with `remote-sync-branch is not set; configure git-sync first`, before any request. Under a worktree scope the branch is the worktree's own, and `--branch` exits `2` with `a worktree is pinned to its branch; drop --branch`. + ### `mb git-sync export` Export Metabase changes back to the configured git remote (Metabase → repo). Auto-polls by default. @@ -1693,10 +1701,30 @@ mb git-sync export --no-wait | `--branch `, `-b` | Branch to export to (defaults to the `remote-sync-branch` setting). | | `--message `, `-m` | Commit message for the export. | | `--force` | Force-push / overwrite the remote branch. | +| `--merge` | Three-way merge remote changes instead of failing on divergence. | | `--wait` / `--no-wait` | Poll until the task reaches a terminal status (default: wait). | | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | | `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | +Branch resolution matches `import`: the `remote-sync-branch` setting is the expected branch and `--branch` the destination, an unset setting exits `2` with `remote-sync-branch is not set; configure git-sync first`, and under a worktree scope `--branch` exits `2` with `a worktree is pinned to its branch; drop --branch`. + +### `mb git-sync export-preflight` + +Ask the server what an export would do, without doing it. Requires Metabase v63 or newer. + +```sh +mb git-sync export-preflight +mb git-sync export-preflight --json +mb git-sync export-preflight --worktree feat/order-metrics --json +``` + +| Flag | Description | +| ----------------------- | ----------------------------------------------------------------------- | +| `--branch `, `-b` | Branch to check against (defaults to the `remote-sync-branch` setting). | +| `--worktree ` | Check that worktree's checkout, against its own branch. | + +The answer is `{ has_changes, clean, conflicts, summary: { added, updated, removed }, force_push_casualties: { deleted, overwritten }, reason }`. `clean: true` with an empty `conflicts` means the export applies without a force-push; `conflicts` names the paths that diverged, `force_push_casualties` names what a `--force` export would delete or overwrite on the remote, and `reason` carries the server's explanation when the export cannot proceed cleanly (`null` otherwise). + ### `mb git-sync stash` Export the current Metabase state to a NEW branch on the remote and switch sync to it. Requires `remote-sync-type` to be `read-write`. @@ -1729,8 +1757,13 @@ Create a new branch on the git remote (from the last imported version) and switc ```sh mb git-sync create-branch feat/dashboards mb git-sync create-branch feat/x --json +mb git-sync create-branch feat/x --no-checkout ``` +| Flag | Description | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| `--checkout` / `--no-checkout` | Switch git-sync to the branch it creates (default: checkout). `--no-checkout` mints the branch and leaves the tracked branch alone. | + ### `mb git-sync add-collection ` Mark a collection as git-synced. The toggle cascades to every descendant by `location` prefix, so flagging a parent flags the whole subtree. Returns `{ success, task_id? }`; `task_id` only appears when the toggle triggers a follow-up task (e.g. a finalization import after switching to read-only mode). @@ -1751,6 +1784,173 @@ mb git-sync remove-collection 12 mb git-sync remove-collection 12 --json --profile prod ``` +## Worktrees + +A worktree is a self-contained checkout of one branch's content inside the same Metabase instance. Every checked-out row carries the worktree's id; the main app is everything that carries none. Transforms, transform tags, transform↔tag links, collections in the `transforms` namespace, the Library, snippets, the Python library, cards, dashboards, documents, measures, segments, and timelines are checked out into a worktree; tables and fields are shared with the main app and are never part of one. Worktrees are **admin-only** and require Metabase v64 or newer plus the `remote_sync` premium feature on an active EE token. + +The point is isolation: an agent (or a person) edits a chain of transforms in a worktree, exports the branch, a human reviews and merges the PR, and the main app pulls the result. Production transforms and their tables are never touched by worktree edits. + +Two limits are worth stating up front: + +- **A worktree is bound to its branch for life.** There is no branch switching inside one; a different branch means a different worktree. +- **A worktree's transforms cannot be run.** The server rejects the run, and `mb transform run` refuses before it sends anything while a scope is in force. Edit and export in the worktree; run the transforms in the main app once the branch is merged and imported. + +### Scoping a command to a worktree + +A command runs either against the main app or inside exactly one worktree. The scope comes from, in precedence order: + +1. `--worktree `, +2. the `MB_WORKTREE` environment variable, +3. the profile's pin, written by `mb worktree pin`. + +A pin is a lock, not a default: while it stands, `--worktree` and `MB_WORKTREE` may only re-state it, and naming a different worktree fails with exit `2`: + +``` +profile "agent" is pinned to worktree 3 (feat/order-metrics); refusing --worktree 5 +``` + +Every command declares how it relates to the split, and `mb --help --json` reports it as `worktree`: + +| Policy | Meaning | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scoped` | Takes `--worktree` and honours a resolved scope: lists are filtered to the worktree, creates are tagged with it, and a `get` / `update` / `delete` of a row outside it is refused. | +| `any` | Indifferent to a scope — reads of resources that are never checked out, and local-only commands. Runs unchanged either way; `--worktree` is an unknown flag on one. | +| `main-only` | Changes or runs main-app state, so it refuses to execute while a scope is in force. The refusal lands before any request, on the profile pin and the environment alone. | + +The scoped commands are `transform list|get|create|update|delete|delete-table|dependencies`, `transform-tag list|create|update|delete`, `snippet list|get|create|update|archive`, `collection list|tree|get|items|create|archive`, `card list`, `search`, `library get`, and the git-sync verbs `status|is-dirty|dirty|current-task|cancel-task|wait|import|export|has-remote-changes|export-preflight`. + +A `main-only` command under a scope exits `2` with the reason and both ways out: + +``` +transform run is not available inside a worktree (scope: worktree 3 (feat/order-metrics) from the profile pin); it changes main-app content. Unpin the profile (`mb worktree unpin`) or drop MB_WORKTREE to run it against the main app. +``` + +A scoped command that fetches a row by id checks the row's own tag before touching it — the server answers by id for an admin whatever scope the row belongs to, so membership is the CLI's to enforce: + +``` +transform 12 is not in worktree 3 (feat/order-metrics); refusing to touch main-app content +``` + +### `mb worktree list` + +List the worktrees on the instance (compact list envelope; columns `ID | Branch | Creator | Created`). + +```sh +mb worktree list +mb worktree list --json +``` + +### `mb worktree get ` + +Get one worktree by id or by branch name. + +```sh +mb worktree get 3 +mb worktree get feat/order-metrics --json +``` + +### `mb worktree create ` + +Create a worktree for a branch and pull the branch into it, so the checkout starts in sync. The branch is created on the remote first when it is missing (`create-branch --no-checkout`, which leaves the main app's tracked branch alone). + +```sh +mb worktree create feat/order-metrics +mb worktree create feat/order-metrics --pin --json +mb worktree create feat/order-metrics --no-create-branch --no-pull +``` + +| Flag | Description | +| ---------------------- | -------------------------------------------------------------------------------------------------- | +| `--no-pull` | Skip the initial import. The worktree stays empty until something is imported into it. | +| `--no-create-branch` | Assume the branch already exists on the remote; skip the branch listing and creation. | +| `--pin` | Pin the profile to the created worktree, so every later command under that profile runs inside it. | +| `--wait` / `--no-wait` | Poll the initial import until the task reaches a terminal status (default: wait). | +| `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | +| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | + +The result is `{ worktree, branch_created, pull }` — `branch_created` says whether the remote branch was minted here, and `pull` is the import result (`null` under `--no-pull`). Creating a worktree for a branch other than the pinned one is refused, since a second worktree from a pinned session would escape the pin. + +### `mb worktree delete ` + +Delete a worktree and every row checked out into it. A worktree holding unpushed changes is refused unless `--force`: + +``` +worktree 3 (feat/order-metrics) has unpushed changes; push them with `mb git-sync export` or pass --force to discard +``` + +```sh +mb worktree delete 3 +mb worktree delete feat/order-metrics --force --json +``` + +| Flag | Description | +| --------- | ------------------------------------------------------------- | +| `--force` | Delete even when the worktree holds unpushed changes (LOSSY). | + +The result is `{ id, branch, deleted, unpinned }`. When the profile was pinned to the deleted worktree the pin is cleared and `unpinned` is `true`. Under a scope, deleting any worktree other than the one in scope is refused. + +### `mb worktree pin ` + +Confine every command run under a profile to one worktree. The pin lives on the profile record and shows up in `mb auth status` / `mb auth list`. + +```sh +mb worktree pin 3 +mb worktree pin feat/order-metrics --profile agent +``` + +### `mb worktree unpin` + +Release a profile's pin. Idempotent, and the one worktree verb that never contacts the server. + +```sh +mb worktree unpin +mb worktree unpin --profile agent --json +``` + +### Working in a worktree + +```sh +# 1. Create the worktree: mints the branch on the remote if it is missing, pulls it in, pins the profile to it. +mb worktree create feat/order-metrics --pin --profile agent + +# 2. Edit transforms. Every scoped command runs inside the worktree with no extra flag. +mb transform create --file ./.scratch/transform.json --profile agent --json +mb transform list --profile agent --json # only this worktree's transforms +mb transform update 42 --file ./.scratch/patch.json --profile agent --json + +# 3. Check what the worktree holds before pushing. +mb git-sync status --profile agent --json # branch, dirty flag and task for the worktree +mb git-sync dirty --profile agent --json # the objects that will be committed + +# 4. Dry-run the push, then push. +mb git-sync export-preflight --profile agent --json +mb git-sync export -m "add order metrics transforms" --profile agent + +# 5. Open a PR from feat/order-metrics and have it reviewed and merged (plain git / gh against the repo). + +# 6. The main app pulls the merged branch, from an unpinned profile, and runs the transforms. +mb git-sync import --profile prod +mb transform list --profile prod --json # the imported rows carry main-app ids +mb transform run --wait --profile prod --json + +# 7. Retire the worktree. This also clears the pin it was holding. +mb worktree delete feat/order-metrics --profile agent +``` + +Step 6 is a different profile on purpose: `git-sync import` into the main app and `transform run` are `main-only`, so a pinned profile refuses both. + +### Isolating an agent + +The server has no per-worktree credential, so the CLI's isolation boundary is the credential set the process can reach. A harness confines an agent to one worktree by giving it a config home of its own that holds a single pinned profile, and no `MB_URL` / `MB_API_KEY` in the environment: + +```sh +export XDG_CONFIG_HOME=/run/agent/config # a profiles.json with exactly one profile +unset MB_URL MB_API_KEY # nothing the agent can point elsewhere +mb worktree pin feat/order-metrics # once, at setup +``` + +Every command that process can run is then confined to that worktree: main-app writes and runs refuse, scoped reads and writes carry the scope, and `--worktree` / `MB_WORKTREE` can only re-state the pin. Dropping the pin is a deliberate act (`mb worktree unpin`), not a flag the agent can pass in passing. + ## Instance setup Bootstrapping a fresh, not-yet-configured Metabase instance. @@ -1956,6 +2156,7 @@ Exit codes: `0` success, `2` `ConfigError` (missing name, unknown name, `MB_SKIL | `MB_URL` | Default URL for `auth login` and config resolution. | | `MB_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). | | `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | +| `MB_WORKTREE` | Worktree to scope commands to, as an id or a branch name. `--worktree` overrides it; a profile pinned to a different worktree refuses it. | | `MB_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | | `MB_CLI_SKIP_PREFLIGHT` | When set to `1`, bypasses the per-command server version / token-feature preflight check. Escape hatch for patched Metabase builds; can mask real compatibility problems. | | `MB_CLI_DISABLE_KEYRING` | When set to `1`, skips the OS keychain and stores credentials as plaintext in the profiles file. | diff --git a/docs/architecture.md b/docs/architecture.md index 1bb65f1d..0ac57528 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,6 +60,14 @@ The server version and token features are probed once on `auth login` / `auth li To find the right `minVersion` for a new endpoint, validate against `../metabase` at `origin/release-x.58.x` — route files `src/metabase/api_routes/routes.clj` and `enterprise/backend/src/metabase_enterprise/api_routes/routes.clj`. Token-feature keys are the underscored map keys in `src/metabase/premium_features/settings.clj`. +## Worktree scope and pinning + +A git-sync worktree is a self-contained checkout of one branch's content in the same app DB, tagged with `worktree_id`; the main app is `worktree_id = null`. Every command therefore runs either against the main app or inside exactly one worktree, and `defineMetabaseCommand` makes each one say which it tolerates: `worktree: "scoped"` honours a resolved scope, `"any"` is indifferent to one, and `"main-only"` changes or runs main-app state and refuses to execute while a scope is in force. The field is required, so a new command cannot silently inherit a classification. + +A scope comes from `--worktree `, then `MB_WORKTREE`, then the profile's pin (`worktree: { id, branch }` on the profile record). A pin is a lock rather than a default: while it stands, the flag and the env var may only re-state it, and naming a different worktree is a `ConfigError`. The server has no per-worktree credential, so the CLI's isolation boundary is the credential set the process can reach: a harness gives an agent an `XDG_CONFIG_HOME` holding one pinned profile and no `MB_URL`/`MB_API_KEY`, and every command that process can run is then confined to that worktree — main-app writes refuse, and reads and writes of scoped resources carry the scope. + +Both the lock and the main-only refusal are enforced in `getClient()`, before the transport is used, and they need only the profile record and the environment to decide. Resolving a scope _ref_ into an id/branch pair may cost a request, so `ctx.getWorktree()` is memoised and returns the pin's own pair without asking the server. + ## The e2e stack The suite drives the built binary against a real Metabase in docker compose, with no mocks. `scripts/e2e-matrix.ts` namespaces the compose project and its volumes per stack, so each matrix entry gets its own server, app-db volume, bootstrap artifact and snapshot. `bun run e2e:up` sets no project name and therefore always targets the default stack's containers, whatever `METABASE_CLI_E2E_STACK` says — reach for `e2e:matrix --stack=` when you need a genuinely separate server. diff --git a/packages/cli/skill-data/core/SKILL.md b/packages/cli/skill-data/core/SKILL.md index 49e0ebff..92084752 100644 --- a/packages/cli/skill-data/core/SKILL.md +++ b/packages/cli/skill-data/core/SKILL.md @@ -13,7 +13,7 @@ Top-level command groups (run `mb --help` to discover verbs): ``` auth | db | table | field | upload | content-translation | query | card | dashboard | snippet | segment | measure | collection | library document | timeline | timeline-event | transform | transform-job | transform-tag | alert | subscription | setting -search | git-sync | setup | eid | uuid | upgrade | skills +search | git-sync | worktree | setup | eid | uuid | upgrade | skills ``` The conventions below — auth, flags, output, body input — hold across **every** group. Per-command flags and examples live in each command's `--help`; add `--json` for the machine-readable form with the output JSON Schema. A few flows have their own skills (see "Specialized skills"). When a card needs a query, prefer MBQL over native SQL (portable, pre-flight-validated — load `mbql`); fall back to native SQL when MBQL can't express it. @@ -47,6 +47,8 @@ Once a name is established, pass `--profile ` to **every** subsequent comm **`--wait` for async operations.** `transform run`, `git-sync import`, and similar verbs return immediately by default. Pass `--wait` whenever the next step depends on completion — without it you race the operation and see "not ready" / transient connection refusals. +**`--worktree ` scopes a command to a git-sync worktree** — an isolated checkout of one branch's content in the same instance (admin-only, v64+). Precedence: flag, then `MB_WORKTREE`, then the profile's pin (`mb worktree pin ` / `unpin`). **The pin is a lock, not a default:** flag and env may only re-state it, a different worktree exits 2 — that is how a harness confines an agent to one worktree. Each command's `worktree` policy shows in `--help --json`: `scoped` honors the scope, `any` ignores it and takes no `--worktree`, `main-only` (`transform run`, main-app writes) refuses under one — report that refusal rather than unpinning. Workflow: the `git-sync` skill. + **Some "lookup" verbs return JSON envelopes, not bare values.** `mb setting get ` returns `{"key": "...", "value": ...}`. Extract before reusing: ```bash @@ -69,7 +71,7 @@ Every list verb takes `--limit ` (items this call returns) and `--offset ` - **`has_more` decides whether to keep going — never compare counts.** `total` is the server's count on endpoints that report one and `null` on those that don't, so arithmetic over it is not a termination condition. - **To continue, pass `next_offset` back as `--offset`.** When `has_more` is true `next_offset` is past the offset you sent, so the loop advances; when false the walk is over and `next_offset` is `null`. -- **`truncated` means the byte cap cut the output, not that the data ran out.** `has_more`/`next_offset` are recomputed to the cut point, so a capped list resumes like any window. Its `bytes` is what the untruncated answer would have measured, so it sizes the work left rather than the reply you hold. Narrow rows with `--fields` rather than raising `--max-bytes` — a bigger cap spends context on fields you didn't ask for, and the cap counts only what you asked for, so `--fields` buys rows directly. A capped list always returns at least one row; when not even one fits it exits 2 with "the smallest response this list can produce is N bytes, over the M-byte --max-bytes cap; …". +- **`truncated` means the byte cap cut the output, not that the data ran out.** `has_more`/`next_offset` are recomputed to the cut point, so a capped list resumes like any window. Its `bytes` is what the untruncated answer would have measured, so it sizes the work left rather than the reply you hold. Narrow rows with `--fields` rather than raising `--max-bytes` — the cap counts only what you asked for, so `--fields` buys rows directly. A capped list always returns at least one row; when not even one fits it exits 2 with "the smallest response this list can produce is N bytes, over the M-byte --max-bytes cap; …". - `limit` is echoed only when you passed `--limit` — except `mb search`, which defaults to `--limit 20` (an unbounded search is expensive server-side) and so always reports one. On nouns the server doesn't page, one large `--limit` with narrow `--fields` is a single request; many small `--offset` hops are one request each. The whole walk, literally: @@ -104,7 +106,7 @@ mb create --file ./.scratch/body.json --profile --json Single-quoted `'EOF'` stops the shell interpolating `$vars` inside the JSON. -Write working files to **`./.scratch`** in the current directory (`mkdir -p ./.scratch` first), never `/tmp` — better permissions, they persist across the session, and the user can review them. +Write working files to **`./.scratch`** in the current directory (`mkdir -p ./.scratch` first), never `/tmp` — they persist across the session and the user can review them. ## Discovering commands and schemas @@ -123,27 +125,27 @@ mb transform --help --json | jq -r '.commands[].command' # verbs under "transfo ## Resource quirks worth memorizing -Routine verb shapes (list / get / create / update), every flag, and output schemas live in each command's `--help` (add `--json` for output schemas). Below is only what help does _not_ tell you: footguns and non-obvious behaviors. +Routine verb shapes (list / get / create / update), every flag, and output schemas live in each command's `--help` (add `--json`). Below is only what help does _not_ tell you: footguns and non-obvious behaviors. -- **db traversal: the hydration ladder.** Start with `database get --include tables` — the compact table map (id, name, schema, description per table), one call that fits most databases. Pick the relevant tables, then `table fields ` per table (bounded: fields are per-table). `--include tables.fields` is the full rollup — small databases only. Hundreds of tables? Traverse by schema (`database schemas ` → `database schema-tables `) or look tables up by name (`search --models table --db-id --limit 10`). `sync-schema` / `rescan-values` queue async work and return `{status:"ok"}` immediately; `sync-schema --wait` blocks until `initial_sync_status: complete`. -- **table fields.** `table get` never returns fields on its own — pass `--include fields` (compact; the underlying query_metadata response also carries FK targets and dimensions, visible under `--full`) or use `table fields ` (list envelope). `table update` patches table-level metadata only; physical columns aren't editable. +- **db traversal: the hydration ladder.** Start with `database get --include tables` — the compact table map (id, name, schema, description per table), one call that fits most databases. Then `table fields ` per relevant table (bounded: fields are per-table). `--include tables.fields` is the full rollup — small databases only. Hundreds of tables? Traverse by schema (`database schemas ` → `database schema-tables `) or look tables up by name (`search --models table --db-id --limit 10`). `sync-schema` / `rescan-values` queue async work and return `{status:"ok"}` immediately; `sync-schema --wait` blocks until `initial_sync_status: complete`. +- **table fields.** `table get` never returns fields on its own — pass `--include fields` (compact; `--full` also carries FK targets and dimensions) or use `table fields ` (list envelope). `table update` patches table-level metadata only; physical columns aren't editable. - **field has no `list`.** Fields are per-table — get them via `table get --include fields`. Never enumerate fields across a whole db (context blow-up). `field summary` is live cardinality `{field_id, count, distincts}`; `field values` is the cached distinct set (`has_more_values: true` ⇒ truncated cache). `field update` patches metadata only (`base_type` isn't editable) — this is where you set a column's `semantic_type` or foreign-key target. - **upload (CSV → tables).** `upload csv --file ` creates a new table + model (prints `{model_id, table_id}`); `upload append ` / `upload replace --file ` add to / overwrite a table **previously created by upload** (columns must match). The destination db+schema is admin-configured, not per-call — check with `mb setting get uploads-settings --json` (`db_id: null` ⇒ uploads off/unconfigured; needs admin to read). `--collection ` only sets the model's collection. Max 50 MB. Errors: **"The uploads database is not configured."** = no db has uploads enabled; **"Uploads are not enabled."** = the append/replace target isn't an uploaded table. -- **content-translation.** EE-only (`content_translation` premium feature), admin-only, and separate from Remote Sync. `content-translation download > translations.csv` streams the complete active dictionary; `content-translation upload --file translations.csv` replaces every active translation with the file's contents. Always upload the canonical complete CSV, never a partial patch. An empty dictionary downloads as Metabase's four-row sample dictionary — don't re-upload it as real translations. Metabase limits dictionaries to 1.5 MiB. +- **content-translation.** EE-only (`content_translation` premium feature), admin-only, separate from Remote Sync. `content-translation download > translations.csv` streams the complete active dictionary; `content-translation upload --file translations.csv` replaces every active translation with the file. Always upload the canonical complete CSV, never a partial patch. An empty dictionary downloads as Metabase's four-row sample — don't re-upload it as real translations. Dictionaries are limited to 1.5 MiB. - **card.** `dataset_query` is the **flat** `mbql/query` value, not a legacy `{type:"query",query:…}` envelope (→ `mbql`). `--export-format csv|xlsx` streams the raw export (pipe to a file), bypassing the JSON envelope. `archive` is the only delete; unarchive with `update --body '{"archived":false}'`. `visualization_settings` keys are scoped by `display` and aren't pre-flighted — see `visualization`. -- **dashboard.** Dashcards round-trip through `PUT /api/dashboard/:id` (no per-dashcard endpoint): `update-dashcard ` patches one safely; `update --body '{"dashcards":[…]}'` replaces the whole set (omitted ids are deleted server-side; negative ids for new cards). Every dashcard must include `card_id`, including existing rows; use `card_id:null` plus a `visualization_settings.virtual_card` block (`{display:"text"|"heading"|"link"|…}`) for non-question cards. `create` accepts the **same** `dashcards` array in its initial body, so lay out the whole dashboard in one call. `create`/`update` pre-flight every positive `card_id` and exit **2** with `{ok:false,errors:[…]}` on a bad ref (non-bypassable). `dashboard get ` (or `--full`) hydrates dashcards/tabs; `list` omits them. **The grid is 24 columns wide:** each dashcard's `{col, row, size_x, size_y}` is in grid units — **full-width is `size_x: 24`** (`size_x: 12` is half a row, the usual cause of a card filling only half the width). Keep `col + size_x ≤ 24`, start a full-width stack's `col` at 0, and don't overlap (the server stores collisions as sent — no auto-fix). Layout patterns and per-chart default sizes → the `dashboard` skill; load it before composing any `dashcards` array. -- **dashboard parameters (filters).** A dashboard's `parameters` array holds its filter widgets; they're part of the dashboard record, so read them with `dashboard get --fields parameters --json` (no separate verb). **Editing replaces the _whole_ array** (like dashcards), so it's a read-modify-write loop and omitting a parameter deletes it. A parameter only filters a card once it is **mapped** onto that dashcard's `parameter_mappings` — an unmapped parameter is an inert widget. `type` is a **closed enum**; an unlisted value is a hard parse error that echoes the full allowed set back to you. `dashboard parameter-values [--query ]` fetches a widget's selectable values (`{values, has_more_values}`; `--query` is a case-insensitive substring search). Parameter types, ids, mapping targets, and value sources → the `dashboard` skill; load it before authoring a `parameters` array. +- **dashboard.** Dashcards round-trip through `PUT /api/dashboard/:id` (no per-dashcard endpoint): `update-dashcard ` patches one safely; `update --body '{"dashcards":[…]}'` replaces the whole set (omitted ids are deleted server-side; negative ids for new cards). Every dashcard must include `card_id`, including existing rows; use `card_id:null` plus a `visualization_settings.virtual_card` block (`{display:"text"|"heading"|"link"|…}`) for non-question cards. `create` accepts the **same** `dashcards` array in its initial body, so lay out the whole dashboard in one call. `create`/`update` pre-flight every positive `card_id` and exit **2** with `{ok:false,errors:[…]}` on a bad ref (non-bypassable). `dashboard get ` (or `--full`) hydrates dashcards/tabs; `list` omits them. **The grid is 24 columns wide:** each dashcard's `{col, row, size_x, size_y}` is in grid units — **full-width is `size_x: 24`** (`size_x: 12` is half a row, the usual cause of a half-width card). Keep `col + size_x ≤ 24`, start a full-width stack's `col` at 0, and don't overlap (the server stores collisions as sent — no auto-fix). Layout patterns and per-chart default sizes → the `dashboard` skill; load it before composing any `dashcards` array. +- **dashboard parameters (filters).** A dashboard's `parameters` array holds its filter widgets; they're part of the dashboard record, so read them with `dashboard get --fields parameters --json` (no separate verb). **Editing replaces the _whole_ array** (like dashcards), so it's a read-modify-write loop and omitting a parameter deletes it. A parameter only filters a card once it is **mapped** onto that dashcard's `parameter_mappings` — an unmapped parameter is an inert widget. `type` is a **closed enum**; an unlisted value is a hard parse error that echoes the allowed set back. `dashboard parameter-values [--query ]` fetches a widget's selectable values (`{values, has_more_values}`; `--query` is a case-insensitive substring match). Parameter types, ids, mapping targets, and value sources → the `dashboard` skill; load it before authoring a `parameters` array. - **alert / subscription are two unrelated systems.** `alert` watches one **card** and fires on a send condition (`/api/notification`: a cron string, `channel/email`-prefixed handlers, typed recipients); `subscription` delivers one **dashboard** on a schedule (`/api/pulse`: structured `schedule_type` + hour/day/frame, bare `email` channels, `{id}|{email}` recipients). The bodies are not interchangeable. Both silently deliver nowhere if the server has no SMTP / Slack app — check `mb setting get 'email-configured?'` (quote it; the `?` is a shell glob) before creating either. Their list-valued fields (`handlers`/`subscriptions`, `channels`/`cards`) **replace wholesale** on update, so adding one recipient is a read-modify-write. `mb card alerts ` and `mb dashboard subscriptions ` list what's already attached to a card/dashboard; `archive` deactivates rather than deletes. Load the `notification` skill before authoring either body. - **snippet `--archived` is a swap, not a union** — list returns _either_ active _or_ archived rows, never both. (Same for `--filter archived` on dashboard/collection.) - **segment / measure.** `update` and `archive` require a non-blank `revision_message` (audit-logged); the CLI does not synthesize it on `update`. `archive` defaults to `"Archived via mb CLI"` — override with `--revision-message`. `definition` is a flat MBQL clause (→ `mbql`): segment = a filter, measure = exactly one aggregation. -- **timeline / timeline-event.** Timelines are collection-scoped event annotations for time-series charts: a timeline's events render only on time-series questions saved in the **same collection** (`collection_id`; null = root) — sub-collections do **not** inherit, and events never draw on dashboard cards, only in the question (and collection) view. To annotate a question's chart, create the timeline in that question's collection, then add events; an event only draws when its `timestamp` falls inside the chart's displayed time range. Event `create` requires `timestamp` (ISO 8601), `timezone` (IANA name), `time_matters` (true = the time of day is significant, false = date-only), and `timeline_id` — the API never auto-creates a default timeline (that's UI-only). There is no `timeline-event list`; enumerate with `timeline events ` (`--archived` to include archived). Archiving a timeline cascades `archived` to its events; `delete` is a **hard** delete of the timeline and all its events — prefer `archive`. +- **timeline / timeline-event.** Timelines are collection-scoped event annotations for time-series charts: a timeline's events render only on time-series questions saved in the **same collection** (`collection_id`; null = root) — sub-collections do **not** inherit, and events never draw on dashboard cards, only in the question (and collection) view. An event draws only when its `timestamp` falls inside the chart's displayed time range. Event `create` requires `timestamp` (ISO 8601), `timezone` (IANA name), `time_matters` (true = the time of day is significant, false = date-only), and `timeline_id` — the API never auto-creates a default timeline (that's UI-only). There is no `timeline-event list`; enumerate with `timeline events ` (`--archived` to include archived). Archiving a timeline cascades `archived` to its events; `delete` is a **hard** delete of the timeline and all its events — prefer `archive`. - **collection ``** accepts four forms only — positive int, `root`, `trash`, or a 21-char entity_id; anything else is a client-side `ConfigError`. `collection items` pages the server endpoint, pulling only as far as the output cap can show — read `has_more`/`next_offset` to continue. `collection tree` is **JSON-only** (`--format text` is rejected). A transform collection needs `collection create --namespace transforms`. - **setting set** parses the value as **strict JSON**: a string is `'"value"'` (inner quotes), booleans `true`/`false`, numbers bare. Wrong quoting silently errors — confirm with `setting get ` after. `setting get --json` works on every value type (wrapping bare-text responses into `{key, value}`). - **search vs. list.** For plain enumeration of cards/dashboards/collections use the dedicated `… list` verbs; reach for `search --models ` only for ranking against a query string or a cross-resource lookup. - **transform.** Iterate with `transform update `, never `delete` + `create` (keeps the row, `entity_id`, materialized table, and YAML filename — avoids `_2` suffixes and noisy git history). `transform run` needs `--wait` (or `--sync`, which also waits for the output table to register and returns `target_table_id`) or you get only `{run_id, final:null}`. (→ `transform`.) - **setup is one-shot.** `mb setup` walks `/api/setup` for a **fresh** instance only — errors against an already-configured one. Mostly for bootstrapping local / e2e instances. - **eid** translates a string entity id → numeric id: `mb eid --model --json`. Entity ids are NanoIDs that can start with `-`, which the positional form misreads as a flag (shell quotes don't help) — for those, use `--body '{"entity_ids":{"card":["-…"]}}'` (the id is a JSON string value, immune to flag parsing). -- **library.** EE-only (`library` premium feature, v59+). The Library is a curated subtree (`library-data` "Data" + `library-metrics` "Metrics" under a `library` root): tables published to **Data** appear first in data pickers and rank up in search; metrics saved to **Metrics** are prioritized in nav, search, and the query builder — it's how you tell people (and agents) "start from these, they're trusted." `library get` shows the Library and its Data/Metrics collection ids; `library create` provisions it (idempotent). `library publish --table-ids/--db-ids/--schemas` publishes tables into Data — it **resolves the Data collection itself and creates the Library if absent** (no collection id to find); each `--schemas` entry is `:` (e.g. `1:public`), not a bare name. `publish` cascades to upstream FK dependencies, `unpublish` to downstream dependents; both need **admin or data-analyst** (Curate alone won't publish) and exit **403** without write **and** query permission on every affected table. Publish status shows on the table: `table get`/`table list` carry `is_published` (`collection_id` under `--full`). Good candidates are finished, analysis-ready tables — clean/combine via transforms first, then publish the polished result. Publishing does not put the Data collection in the git-sync scope: on a remote-sync instance, `mb git-sync add-collection ` is what makes exports carry the published tables' metadata (see the `git-sync` skill). +- **library.** EE-only (`library` premium feature, v59+). The Library is a curated subtree (`library-data` "Data" + `library-metrics` "Metrics" under a `library` root): tables published to **Data** appear first in data pickers and rank up in search; metrics saved to **Metrics** are prioritized in nav, search, and the query builder — it's how you tell people (and agents) which to start from. `library get` shows the Library and its Data/Metrics collection ids; `library create` provisions it (idempotent). `library publish --table-ids/--db-ids/--schemas` publishes tables into Data — it **resolves the Data collection itself and creates the Library if absent**; each `--schemas` entry is `:` (e.g. `1:public`), not a bare name. `publish` cascades to upstream FK dependencies, `unpublish` to downstream dependents; both need **admin or data-analyst** (Curate alone won't publish) and exit **403** without write **and** query permission on every affected table. `table get`/`table list` carry `is_published` (`collection_id` under `--full`). Good candidates are finished, analysis-ready tables — clean up via transforms first. Publishing does not put the Data collection in the git-sync scope: `mb git-sync add-collection ` is what makes exports carry the published tables' metadata (see the `git-sync` skill). - **query / uuid.** `mb query` is the ad-hoc MBQL surface (`--print-schema` → `--dry-run` → run); `mb uuid --count ` mints the `lib/uuid` values MBQL clauses need. Both live in `mbql`. ## Specialized skills (load on demand) @@ -158,8 +160,8 @@ This file is enough for any single-command task. For anything deeper, load the r - **`notification`** — scheduled delivery: question alerts (`mb alert`) and dashboard subscriptions (`mb subscription`). Choosing between them, the two schedule/recipient contracts, channel prerequisites, testing a send. - **`transform`** — transform body JSON, create + run-with-wait, run inspection, tags, jobs. - **`document`** — Metabase documents (TipTap body, embedding cards). -- **`git-sync`** — round-tripping content to/from a git remote. -- **`data-workflow`** — the guided, end-to-end data workflow: investigate raw data, build clean analysis-ready tables, define reusable segments/measures/metrics, answer questions, build dashboards. **Start here when the user states a goal rather than a single verb** — "make sense of my data", "build a data model", "go from raw data to a dashboard", "be my data analyst", "set up analytics for X". It detects where the data is and routes to the right stage. +- **`git-sync`** — round-tripping content to/from a git remote, and worktrees. +- **`data-workflow`** — the guided, end-to-end data workflow: investigate raw data, build clean analysis-ready tables, define reusable segments/measures/metrics, answer questions, build dashboards. **Start here when the user states a goal rather than a single verb** — "make sense of my data", "build a data model", "go from raw data to a dashboard", "set up analytics for X". It detects where the data is and routes to the right stage. If a task spans more than one, load each. `mb skills list` enumerates everything on the installed version. diff --git a/packages/cli/skill-data/git-sync/SKILL.md b/packages/cli/skill-data/git-sync/SKILL.md index 2aa5927c..78c35ba7 100644 --- a/packages/cli/skill-data/git-sync/SKILL.md +++ b/packages/cli/skill-data/git-sync/SKILL.md @@ -1,6 +1,6 @@ --- name: git-sync -description: Round-trip Metabase content (cards, dashboards, transforms, snippets, collections, Library-published table/field metadata) between an instance and a git remote via `mb git-sync …` — status, dirty / has-remote-changes checks, import, export (with branch guard), branches, stash, add/remove a collection from sync. Load when the user wants to "import the latest changes", "export to git", "push my changes to the repo", "open a PR with my Metabase changes", "git sync", "dirty check", "stash before pulling", "add a collection to sync", or anything `mb git-sync …`. +description: Round-trip Metabase content (cards, dashboards, transforms, snippets, collections, Library-published table/field metadata) between an instance and a git remote via `mb git-sync …` — status, dirty / has-remote-changes checks, import, export (with branch guard), branches, stash, add/remove a collection from sync. Load when the user wants to "import the latest changes", "export to git", "push my changes to the repo", "open a PR with my Metabase changes", "git sync", "dirty check", "stash before pulling", "add a collection to sync", or anything `mb git-sync …` / `mb worktree …`. Also covers worktrees — isolated per-branch checkouts of transforms and other content — for "work on this branch in isolation", "create a worktree", "pin the profile to a worktree", "export the worktree and open a PR". allowed-tools: Read, Write, Edit, Bash, AskUserQuestion --- @@ -40,6 +40,7 @@ Pulls the configured branch and applies it to the instance. Polls until the task | `--branch ` | Defaults to the `remote-sync-branch` setting; override per-call. | | `--no-wait` | Return as soon as the task is queued; combine with `mb git-sync wait` later. | | `--force` | **Discards local Metabase-side dirty changes** (lossy). Confirm with the user first. | +| `--merge` | Three-way merge remote changes instead of failing on divergence. Confirm first. | | `--timeout ` | Polling deadline. Default 600 000. | | `--interval ` | Polling cadence. Default 2 000. | @@ -62,6 +63,7 @@ Pushes Metabase-side changes back to the configured remote. `-m` is the commit m | `--branch ` | Push to a specific branch instead of the configured one. | | `-m, --message ` | Commit message. | | `--force` | Force-push / overwrite remote. Confirm with the user. | +| `--merge` | Three-way merge instead of failing on divergence. | | `--no-wait` | Don't poll. | Workflow: @@ -96,6 +98,94 @@ mb git-sync stash --profile # export current state `stash` is the safe move when the instance has team work you don't want to lose, but you need to pivot to a different branch (`import` would discard, `export --force` would overwrite). It exports current state to a fresh branch first. +## Worktrees (isolated branch checkouts) + +A **worktree** is a self-contained checkout of one branch's content inside the same instance. Transforms, transform tags, snippets, `transforms`-namespace collections, the Library, cards, dashboards, and documents are tagged with the worktree's id; tables and fields stay shared with the main app. It is how a chain of transforms gets built, exported, and reviewed as a PR without touching production content. Admin-only, and needs Metabase v64+ with the `remote_sync` feature. + +Two hard limits, up front: + +- **A worktree's transforms cannot be run.** `mb transform run` refuses while a worktree scope is in force, and the server rejects the run anyway. There is no workaround to find — run the transform in the main app after the branch is merged and imported, and say so rather than trying to validate the SQL by running it in the worktree. +- **A worktree is bound to its branch for life.** No branch switching inside one; a different branch means a different worktree. + +### Where am I? Check before touching import/export + +```bash +mb auth status --profile --json | jq '.worktree' # {id, branch} when the profile is pinned, else null +mb git-sync status --profile --json | jq '.worktree' # the scope this command ran under +mb worktree list --profile --json # every worktree on the instance +``` + +A pinned profile pushes to the worktree's branch; an unpinned one pushes to whatever `remote-sync-branch` points at. Confusing the two is how worktree work lands on the team's tracked branch, so read the pin before the first `export`. + +### Preconditions before creating one + +1. **The user named the branch.** Create a worktree only for a branch the user asked you to work on. +2. **A branch holds at most one worktree.** Check `mb worktree list --json` first; a duplicate fails with the server's `A worktree for branch '' already exists.` — reuse it with `mb worktree pin ` instead. +3. **A pinned profile is already confined.** `mb worktree create` for any other branch is refused, because a second worktree from a pinned session would escape the pin. + +### The workflow + +```bash +# 1. Create the worktree and pin the profile to it. Mints the branch on the remote when missing +# (--no-checkout, so the main app's tracked branch is untouched), then pulls the branch in. +mb worktree create feat/order-metrics --pin --profile --json + +# 2. Edit transforms. Scoped commands need no extra flag once the profile is pinned. +mb transform create --file ./.scratch/transform.json --profile --json +mb transform list --profile --json # only this worktree's transforms +mb transform update --file ./.scratch/patch.json --profile --json + +# 3. Read state before pushing — same rule as the main app. +mb git-sync status --profile --json # branch, dirty flag, current task, all scoped +mb git-sync dirty --profile --json # exactly what will be committed + +# 4. Dry-run the push, read the answer, then push. +mb git-sync export-preflight --profile --json +mb git-sync export -m "add order metrics transforms" --profile + +# 5. Open the PR from the branch with plain git / gh, and let a human review and merge it. + +# 6. Retire the worktree once the PR is merged. This clears the pin it was holding. +mb worktree delete feat/order-metrics --profile --json +``` + +`export-preflight` answers `{has_changes, clean, conflicts, summary: {added, updated, removed}, force_push_casualties: {deleted, overwritten}, reason}`. Read it before every worktree export: `clean: true` with empty `conflicts` is a push that applies as-is; a non-empty `conflicts` or `force_push_casualties` is a conversation with the user, not a `--force`. + +The main app pulls the merged branch from a **different, unpinned** profile — `git-sync import` into the main app and `transform run` are both main-app operations and a pinned profile refuses them: + +```bash +mb git-sync import --profile +mb transform run --wait --profile --json +``` + +### Scope precedence and what a pinned profile refuses + +The scope comes from `--worktree `, else `MB_WORKTREE`, else the profile's pin. **The pin is a lock, not a default:** while it stands, the flag and the env var may only re-state it, and naming a different worktree exits 2 with `profile "

" is pinned to worktree (); refusing --worktree `. + +Under a scope, a command that changes or runs main-app state refuses before any request and exits 2 — `transform run` / `cancel`, `card` / `library` writes, and the main-app git-sync verbs `stash`, `create-branch`, `add-collection`, `remove-collection`: + +``` +transform run is not available inside a worktree (scope: worktree 3 (feat/order-metrics) from the profile pin); it changes main-app content. Unpin the profile (`mb worktree unpin`) or drop MB_WORKTREE to run it against the main app. +``` + +Two more refusals belong to the scope: + +- Fetching a row that lives elsewhere: `transform 12 is not in worktree 3 (feat/order-metrics); refusing to touch main-app content`. +- `--branch` on `import` / `export` / `export-preflight`: `a worktree is pinned to its branch; drop --branch`. + +### Confirmations these flags need + +- `--force` (on `import`, `export`, and `worktree delete`) and `--merge` (on `import` / `export`) are **lossy or history-rewriting** — ask the user first, with `AskUserQuestion`, naming what gets discarded. `export-preflight`'s `force_push_casualties` is the list to show them. +- `mb worktree delete` refuses a worktree holding unpushed changes: `worktree () has unpushed changes; push them with mb git-sync export or pass --force to discard`. Export first, or ask before forcing — the content in a worktree exists nowhere else once it is gone. + +### Don't (worktrees) + +- **Don't unpin to reach the main app.** The pin is the isolation boundary a harness put there; `mb worktree unpin` to get past a refusal turns an isolated session into one that can write production content. Report the refusal and what you would need instead. +- **Don't run a worktree's transforms**, and don't route around the refusal with `mb query`, a native card, or a second profile. Validate the query shape with `--dry-run` (see `mbql`) and run it for real in the main app after the merge. +- **Don't create worktrees for branches you were not asked to touch.** A worktree checks a whole branch's content into the instance; an unwanted one is content to clean up, not a free experiment. +- **Don't `git-sync export` from the main app while the work lives in a worktree.** It pushes the main app's state to the tracked branch and the worktree's edits are not in it. Check `.worktree` in `git-sync status` before exporting. +- **Don't hand-write the branch's YAML in the repo to "help" the PR.** Worktree content round-trips through `export` exactly like main-app content — see the first entry of the general "Don't" list below. + ## Polling and cancelling ```bash diff --git a/packages/cli/skill-data/transform/SKILL.md b/packages/cli/skill-data/transform/SKILL.md index b7fd015c..da2d9c3f 100644 --- a/packages/cli/skill-data/transform/SKILL.md +++ b/packages/cli/skill-data/transform/SKILL.md @@ -48,7 +48,7 @@ mb transform run "$TRANSFORM_ID" --wait --profile --json - `--wait` polls until status is `succeeded` or `failed`. Without it you get only `{message: "Transform run started", run_id, final: null}` and must poll yourself — don't put bare `transform run` in a tight loop; let `--wait` do the polling. - `--sync` implies `--wait`, then waits until the run registers its output table (the run registers it itself — no `db sync-schema` needed), adding `target_table_id` to the envelope. Use it when you'll build MBQL on the output (see "Inspect"). - The `--json` envelope is shape-stable: `{message, run_id, final}` (plus `target_table_id` under `--sync` — a number, or `null` if the table didn't register before the timeout). `final` is `null` when `--wait` is omitted or the run never started, otherwise a full `TransformRun` with `status` and `message`. On a failed run (`final.status` ∈ {`failed`, `timeout`, `canceled`}) the CLI exits 1 and writes a one-line `transform run failed` to stderr; the failure detail lives only in `final.message` on stdout, so `jq -r '.final.message'` is where to look. -- `transform create --json` returns the agent-facing compact projection: `{id, name, description, source_type, target: {type, database, schema, name}, target_db_id}`. Read `target.schema`/`target.name` directly off it — no follow-up `transform get`. +- `transform create --json` returns the agent-facing compact projection: `{id, name, description, source_type, target: {type, database, schema, name}, target_db_id, worktree_id}`. Read `target.schema`/`target.name` directly off it — no follow-up `transform get`. - If a transform with the same `name` already has a YAML representation on disk under the configured remote-sync repo, `create` mints a `_2` suffix on the exported filename (the new transform gets a fresh `entity_id`; the prior one isn't touched). For "iterate on the same concept", prefer `transform update ` — see "Iterating on a failing transform". - **`collection_id` only accepts a collection in the `:transforms` namespace.** Transforms aren't filed next to cards and dashboards — a normal analytics collection id fails create/update with `collection_id: A Transform can only go in Collections in the :transforms namespace.` Omit `collection_id` to leave the transform uncollected (the common case), or provision one with `mb collection create --body '{"name":"…"}' --namespace transforms --json` (see `core`) and pass the returned `id`. Cards and dashboards you build **on top of** the output table go in ordinary collections — so "put the transform and its dashboard in collection X" means _X holds the dashboard + cards; the transform stays in the transforms namespace._ @@ -71,6 +71,14 @@ On `target_table_id: null` (still syncing when the poll timed out; exit 0) re-po Columns and types are inferred from the result set; change the SELECT shape and the next run fails on a column mismatch — drop the table first (`transform delete-table `). A changed shape also needs a re-run with `--sync` before MBQL sees the new/renamed columns. +## Editing transforms safely in a worktree + +A **worktree** is an isolated checkout of one git branch's content inside the same instance — the way to build a chain of transforms for PR review without touching production transforms or their tables. The lifecycle (create → pin → export-preflight → export → PR → delete) lives in the `git-sync` skill, section "Worktrees": **`mb skills get git-sync`**. What matters while authoring: + +- **`worktree_id` says where a transform lives.** `transform get` and `transform list` carry it: an integer for a worktree's transform, `null` for a main-app one. Read it before editing anything you did not create in this session — under a scope, `update` / `delete` / `delete-table` of a main-app transform is refused with `transform is not in worktree (); refusing to touch main-app content`. +- **The scope is implicit once the profile is pinned.** With `--worktree `, `MB_WORKTREE`, or a pin in force, `transform list` shows only that worktree's transforms and `transform create` files what it creates into it. Without a scope both address the main app, whose listing excludes every worktree's rows. +- **`transform run` is refused under a scope**, and the server rejects worktree runs anyway: `transform run is not available inside a worktree (…); it changes main-app content.` The create → run → fix loop therefore does not close inside a worktree. Validate the query with `--dry-run` (see `mbql`) before creating, iterate the body with `transform update`, and run it in the main app once the branch is merged and imported. Don't unpin, don't switch to another profile, and don't stand in a run with `mb query` — tell the user the run waits for the merge. + ## Inspect runs and cancel an in-flight run ```bash diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ee926f15..53d18950 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -8,8 +8,9 @@ import { hoistGlobalFlags } from "./commands/global-flags"; import { trustSystemCa } from "./core/system-ca"; import main from "./main"; import { reportError } from "./output/error"; -import { findUnknownCommand, resolveBreadcrumb, showUsage, showUsageJson } from "./output/help"; +import { resolveInvocation, showUsage, showUsageJson } from "./output/help"; import { installInterruptHandler } from "./runtime/interrupt"; +import { setVerbChain } from "./runtime/verb-chain"; const HELP_FLAGS: ReadonlySet = new Set(["--help", "-h"]); const JSON_HELP_FLAG = "--json"; @@ -24,7 +25,7 @@ async function run(): Promise { cmd: CommandDef, parent?: CommandDef, ): Promise => { - const breadcrumb = await resolveBreadcrumb(main, rawArgs); + const { breadcrumb } = await resolveInvocation(main, rawArgs); if (wantsJsonHelp) { await showUsageJson(cmd, breadcrumb); return; @@ -37,11 +38,12 @@ async function run(): Promise { return; } if (!rawArgs.some((arg) => HELP_FLAGS.has(arg))) { - const unknown = await findUnknownCommand(main, rawArgs); - if (unknown !== null) { - reportError(new ConfigError(`unknown command: ${unknown}`)); + const { unknownToken, verbs } = await resolveInvocation(main, rawArgs); + if (unknownToken !== null) { + reportError(new ConfigError(`unknown command: ${unknownToken}`)); return; } + setVerbChain(verbs); } await runMain(main, { showUsage: showUsageWithBreadcrumb, rawArgs }); } diff --git a/packages/cli/src/commands/alert/archive.ts b/packages/cli/src/commands/alert/archive.ts index fcf05b71..93472d8d 100644 --- a/packages/cli/src/commands/alert/archive.ts +++ b/packages/cli/src/commands/alert/archive.ts @@ -15,6 +15,7 @@ export default defineMetabaseCommand({ details: "Deactivates the alert and drops its scheduled trigger. `mb alert list --include-inactive` still shows it, and `mb alert update --body '{\"active\":true}'` brings it back.", capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/alert/create.ts b/packages/cli/src/commands/alert/create.ts index 3d732982..e7e69da7 100644 --- a/packages/cli/src/commands/alert/create.ts +++ b/packages/cli/src/commands/alert/create.ts @@ -18,6 +18,7 @@ export default defineMetabaseCommand({ 'each recipient is `{type: "notification-recipient/user", user_id}` or `{type: "notification-recipient/raw-value", details: {value: "a@b.com"}}`.', ].join(" "), capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/alert/get.ts b/packages/cli/src/commands/alert/get.ts index 0cdb2503..eaacfb43 100644 --- a/packages/cli/src/commands/alert/get.ts +++ b/packages/cli/src/commands/alert/get.ts @@ -10,6 +10,7 @@ export default defineMetabaseCommand({ details: "`--full` includes the hydrated card the alert watches, alongside its schedules and handlers.", capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/alert/list.ts b/packages/cli/src/commands/alert/list.ts index d9152e31..6d5d45f4 100644 --- a/packages/cli/src/commands/alert/list.ts +++ b/packages/cli/src/commands/alert/list.ts @@ -14,6 +14,7 @@ export default defineMetabaseCommand({ details: "Archived (inactive) alerts are hidden unless you pass --include-inactive. System-event notifications, which share the same API, are never listed here.", capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/alert/send.ts b/packages/cli/src/commands/alert/send.ts index c31354fa..78161ab2 100644 --- a/packages/cli/src/commands/alert/send.ts +++ b/packages/cli/src/commands/alert/send.ts @@ -25,6 +25,7 @@ export default defineMetabaseCommand({ details: "Delivers to every handler on the alert, ignoring its send condition and schedule. Requires the channel to be configured on the server (email needs SMTP set up).", capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/alert/update.ts b/packages/cli/src/commands/alert/update.ts index ed36c1ef..27ed8618 100644 --- a/packages/cli/src/commands/alert/update.ts +++ b/packages/cli/src/commands/alert/update.ts @@ -18,6 +18,7 @@ export default defineMetabaseCommand({ "`mb alert get ` prints the current ones. An alert's card cannot be moved to a different card.", ].join(" "), capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/auth/list.test.ts b/packages/cli/src/commands/auth/list.test.ts index 93971cfd..d408ee98 100644 --- a/packages/cli/src/commands/auth/list.test.ts +++ b/packages/cli/src/commands/auth/list.test.ts @@ -216,6 +216,7 @@ describe("auth list command", () => { tokenFeatures: null, lastProbedAt: envelope.data[0]?.lastProbedAt, lastFailure: null, + worktree: null, }, ], returned: 1, diff --git a/packages/cli/src/commands/auth/list.ts b/packages/cli/src/commands/auth/list.ts index 84f55a34..24a65c19 100644 --- a/packages/cli/src/commands/auth/list.ts +++ b/packages/cli/src/commands/auth/list.ts @@ -18,6 +18,7 @@ import { profileAuthMethod, ProfileAuthMethod, ProfileLastFailure, + ProfilePin, type ProfileLastProbe, type ProfileRecord, } from "../../core/auth/profile-record"; @@ -28,7 +29,13 @@ import type { ResourceView } from "../../output/view"; import { windowList } from "../../output/window"; import { interruptSignal } from "../../runtime/interrupt"; import { listFlags, outputFlags } from "../flags"; -import { renderAuthMethod, renderTimestamp, renderUserRole, renderVersionTag } from "./render"; +import { + renderAuthMethod, + renderTimestamp, + renderUserRole, + renderVersionTag, + renderWorktreePin, +} from "./render"; import { defineMetabaseCommand } from "../runtime"; const AuthProfileStatus = z.enum([ @@ -51,6 +58,7 @@ const AuthProfile = z.object({ tokenFeatures: TokenFeatures.nullable(), lastProbedAt: z.iso.datetime().nullable(), lastFailure: ProfileLastFailure.nullable(), + worktree: ProfilePin.nullable(), }); type AuthProfileJson = z.infer; @@ -74,6 +82,7 @@ const authProfileView: ResourceView = { { key: "user", label: "Role", format: (value) => renderUserRole(value) }, { key: "version", label: "Version", format: (value) => renderVersionTag(value) }, { key: "lastProbedAt", label: "Last probed", format: (value) => renderTimestamp(value) }, + { key: "worktree", label: "Worktree pin", format: (value) => renderWorktreePin(value) }, ], }; @@ -85,6 +94,7 @@ function renderStatus(value: unknown): string { export default defineMetabaseCommand({ meta: { name: "list", description: "List configured authentication profiles" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags }, outputSchema: AuthProfileListEnvelope, examples: ["mb auth list", "mb auth list --json"], @@ -198,6 +208,7 @@ function projectSuccess( tokenFeatures: probe.tokenFeatures, lastProbedAt: probe.at, lastFailure: null, + worktree: record.worktree, }; } @@ -214,6 +225,7 @@ function toJson(record: ProfileRecord, status: AuthProfileStatusValue): AuthProf tokenFeatures: probe?.tokenFeatures ?? null, lastProbedAt: probe?.at ?? null, lastFailure: record.lastFailure, + worktree: record.worktree, }; } diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 1ad852b3..bcd39548 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -72,6 +72,7 @@ export default defineMetabaseCommand({ details: "Interactive login offers browser OAuth (recommended; Metabase v63+) or an API key — older servers fall back to the API key prompt automatically. Browser login opens Metabase, you sign in (password or SSO) and approve, and the CLI stores a refreshing access token. For CI/non-interactive use, supply an API key via --api-key, piped stdin, or $MB_API_KEY (first non-empty wins); any of these skips the browser flow, even on a TTY. The URL comes from --url or $MB_URL, prompted when stdin is a TTY.", capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 913c7d8d..cebbf82b 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -36,6 +36,7 @@ const logoutView: ResourceView = { export default defineMetabaseCommand({ meta: { name: "logout", description: "Clear stored credentials for a profile" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/auth/render.ts b/packages/cli/src/commands/auth/render.ts index 9428d577..6cecbda6 100644 --- a/packages/cli/src/commands/auth/render.ts +++ b/packages/cli/src/commands/auth/render.ts @@ -44,6 +44,15 @@ export function renderVersionTag(value: unknown): string { return pickString(value, "tag") ?? EMPTY_CELL; } +export function renderWorktreePin(value: unknown): string { + const id = pickProperty(value, "id"); + const branch = pickString(value, "branch"); + if (typeof id !== "number" || branch === null) { + return EMPTY_CELL; + } + return `${id} (${branch})`; +} + export function renderTimestamp(value: unknown): string { return typeof value === "string" ? value : EMPTY_CELL; } diff --git a/packages/cli/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts index 043c7bf3..f4cbaa3c 100644 --- a/packages/cli/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -65,6 +65,7 @@ describe("auth status command", () => { tokenFeatures: null, lastProbedAt: null, lastFailure: null, + worktree: null, }); }); @@ -82,6 +83,7 @@ describe("auth status command", () => { tokenFeatures: null, lastProbedAt: null, lastFailure: null, + worktree: null, }); }); @@ -120,6 +122,7 @@ describe("auth status command", () => { tokenFeatures: null, lastProbedAt: probe?.at ?? null, lastFailure: null, + worktree: null, }); }); }); diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index bff0b765..31886eb1 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -11,6 +11,7 @@ import { profileAuthMethod, ProfileAuthMethod, ProfileLastFailure, + ProfilePin, } from "../../core/auth/profile-record"; import { renderItem } from "../../output/render"; import type { ResourceView } from "../../output/view"; @@ -22,6 +23,7 @@ import { renderUserName, renderUserRole, renderVersionTag, + renderWorktreePin, } from "./render"; export const AuthStatus = z.object({ @@ -34,6 +36,7 @@ export const AuthStatus = z.object({ tokenFeatures: TokenFeatures.nullable(), lastProbedAt: z.iso.datetime().nullable(), lastFailure: ProfileLastFailure.nullable(), + worktree: ProfilePin.nullable(), }); type AuthStatusJson = z.infer; @@ -48,12 +51,14 @@ const authStatusView: ResourceView = { { key: "user", label: "Role", format: (value) => renderUserRole(value) }, { key: "version", label: "Version", format: (value) => renderVersionTag(value) }, { key: "lastProbedAt", label: "Last probed", format: (value) => renderTimestamp(value) }, + { key: "worktree", label: "Worktree pin", format: (value) => renderWorktreePin(value) }, ], }; export default defineMetabaseCommand({ meta: { name: "status", description: "Show authentication status for a profile" }, capabilities: null, + worktree: "any", args: { ...outputFlags, ...profileFlag }, outputSchema: AuthStatus, examples: ["mb auth status --json", "mb auth status --profile staging"], @@ -73,6 +78,7 @@ export default defineMetabaseCommand({ tokenFeatures: null, lastProbedAt: null, lastFailure: null, + worktree: null, }, authStatusView, ctx, @@ -92,6 +98,7 @@ export default defineMetabaseCommand({ tokenFeatures: probe?.tokenFeatures ?? null, lastProbedAt: probe?.at ?? null, lastFailure: record.lastFailure, + worktree: record.worktree, }, authStatusView, ctx, diff --git a/packages/cli/src/commands/card/alerts.ts b/packages/cli/src/commands/card/alerts.ts index e8b2f49e..d081e88e 100644 --- a/packages/cli/src/commands/card/alerts.ts +++ b/packages/cli/src/commands/card/alerts.ts @@ -11,6 +11,7 @@ export default defineMetabaseCommand({ details: "Manage them with `mb alert create|update|send|archive`, which take the alert id printed here.", capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/card/archive.ts b/packages/cli/src/commands/card/archive.ts index 3b781606..9198e6f0 100644 --- a/packages/cli/src/commands/card/archive.ts +++ b/packages/cli/src/commands/card/archive.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a card by id" }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/card/create.ts b/packages/cli/src/commands/card/create.ts index 3bef7c85..d7f951f2 100644 --- a/packages/cli/src/commands/card/create.ts +++ b/packages/cli/src/commands/card/create.ts @@ -24,6 +24,7 @@ export default defineMetabaseCommand({ { skill: "visualization", purpose: "choose display and visualization_settings" }, ], capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/card/get.ts b/packages/cli/src/commands/card/get.ts index b2a10bc9..f3f32630 100644 --- a/packages/cli/src/commands/card/get.ts +++ b/packages/cli/src/commands/card/get.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a card by id" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/card/list.ts b/packages/cli/src/commands/card/list.ts index 3c6f3cea..62cc2b21 100644 --- a/packages/cli/src/commands/card/list.ts +++ b/packages/cli/src/commands/card/list.ts @@ -3,20 +3,24 @@ import { cardView } from "../../output/views/card"; import { renderList } from "../../output/render"; import { listEnvelopeSchema } from "../../output/types"; import { windowList } from "../../output/window"; -import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, listFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseEnumFlag } from "../parse-enum"; import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export const CardListEnvelope = listEnvelopeSchema(CardCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List cards (questions, models, metrics)" }, capabilities: { minVersion: 58 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, filter: { type: "string", description: `Filter preset: ${CardListFilter.options.join("|")}`, @@ -33,12 +37,18 @@ export default defineMetabaseCommand({ "mb card list", "mb card list --filter archived --json", "mb card list --filter using_model --model-id 42 --json", + "mb card list --worktree feat/transforms", ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const filter = parseEnumFlag(args.filter, CardListFilter, "filter"); const modelId = args.modelId === undefined || args.modelId === "" ? undefined : args.modelId; const client = await getClient(); - const { data, total } = await client.card.list({ f: filter, model_id: modelId }); + const scope = await getWorktree(); + const { data, total } = await client.card.list({ + f: filter, + model_id: modelId, + ...scopeQuery(scope), + }); renderList(windowList(data, ctx.range, total), cardView, ctx); }, }); diff --git a/packages/cli/src/commands/card/query.ts b/packages/cli/src/commands/card/query.ts index 0f46557e..e8fd347e 100644 --- a/packages/cli/src/commands/card/query.ts +++ b/packages/cli/src/commands/card/query.ts @@ -22,6 +22,7 @@ export default defineMetabaseCommand({ "Run a saved card and return results (json envelope, or stream CSV/JSON/XLSX via --export-format)", }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/card/update.ts b/packages/cli/src/commands/card/update.ts index 0f885bd5..f8485467 100644 --- a/packages/cli/src/commands/card/update.ts +++ b/packages/cli/src/commands/card/update.ts @@ -24,6 +24,7 @@ export default defineMetabaseCommand({ { skill: "visualization", purpose: "choose display and visualization_settings" }, ], capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/collection/archive.ts b/packages/cli/src/commands/collection/archive.ts index 06157876..236e30f2 100644 --- a/packages/cli/src/commands/collection/archive.ts +++ b/packages/cli/src/commands/collection/archive.ts @@ -1,24 +1,33 @@ import { Collection } from "@metabase/client/domain/collection"; import { collectionView } from "../../output/views/collection"; import { renderSummary } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { assertInWorktree, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a collection by id" }, capabilities: { minVersion: 58 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, id: { type: "positional", description: "Collection id", required: true }, }, outputSchema: Collection, examples: ["mb collection archive 4", "mb collection archive 4 --json"], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const id = parseId(args.id); const client = await getClient(); + const scope = await getWorktree(); + if (scope !== null) { + const existing = await client.collection.get(id); + assertInWorktree("collection", id, existing.worktree_id, scope); + } const updated = await client.collection.archive(id); renderSummary( updated, diff --git a/packages/cli/src/commands/collection/create.ts b/packages/cli/src/commands/collection/create.ts index 6e23e458..72aa4444 100644 --- a/packages/cli/src/commands/collection/create.ts +++ b/packages/cli/src/commands/collection/create.ts @@ -7,19 +7,23 @@ import { collectionView } from "../../output/views/collection"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseEnumFlag } from "../parse-enum"; import { defineMetabaseCommand } from "../runtime"; +import { scopeBody, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { name: "create", description: "Create a collection from a JSON spec" }, details: - 'Body keys: `name` (required), `description`, `parent_id`, `authority_level`, `namespace`. Most collections use the default namespace (omit it). Pass `namespace: "transforms"` (or `--namespace transforms`) to create the kind of collection a transform\'s `collection_id` can point at — a regular collection is rejected there.', + 'Body keys: `name` (required), `description`, `parent_id`, `authority_level`, `namespace`. Most collections use the default namespace (omit it). Pass `namespace: "transforms"` (or `--namespace transforms`) to create the kind of collection a transform\'s `collection_id` can point at — a regular collection is rejected there. ' + + WORKTREE_SCOPE_DETAIL, capabilities: { minVersion: 58 }, + worktree: "scoped", args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, ...bodyInputFlags, namespace: { type: "string", @@ -33,14 +37,16 @@ export default defineMetabaseCommand({ "mb collection create --file collection.json", 'mb collection create --body \'{"name":"My Collection","parent_id":4}\'', 'mb collection create --body \'{"name":"ETL"}\' --namespace transforms', + 'mb collection create --body \'{"name":"ETL"}\' --worktree feat/transforms', ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const body = await readBody({ flag: args.body, file: args.file }, CollectionCreateInput); if (typeof args.namespace === "string" && args.namespace !== "") { body.namespace = parseEnumFlag(args.namespace, CollectionNamespace, "namespace"); } const client = await getClient(); - const created = await client.collection.create(body); + const scope = await getWorktree(); + const created = await client.collection.create(scopeBody(body, scope)); renderSummary( created, collectionView, diff --git a/packages/cli/src/commands/collection/get.ts b/packages/cli/src/commands/collection/get.ts index 8804b1e8..cff27733 100644 --- a/packages/cli/src/commands/collection/get.ts +++ b/packages/cli/src/commands/collection/get.ts @@ -1,10 +1,11 @@ import { Collection } from "@metabase/client/domain/collection"; import { collectionView } from "../../output/views/collection"; import { renderItem } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { assertInWorktree, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; -import { parseCollectionRef } from "./parse-ref"; +import { isCollectionAlias, parseCollectionRef } from "./parse-ref"; export default defineMetabaseCommand({ meta: { @@ -12,10 +13,16 @@ export default defineMetabaseCommand({ description: 'Get a collection by id, 21-char entity id, or "root"/"trash"', }, capabilities: { minVersion: 58 }, + worktree: "scoped", + details: + 'The "root" and "trash" aliases name pseudo-collections the server assembles, so a scope does ' + + "not apply to them. " + + WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, id: { type: "positional", description: 'Collection id, 21-char entity id, or one of: "root", "trash"', @@ -28,11 +35,16 @@ export default defineMetabaseCommand({ "mb collection get root --json", "mb collection get trash --json", "mb collection get voo1If9y8Sld0lXej6xl0 --json", + "mb collection get 4 --worktree feat/transforms", ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const ref = parseCollectionRef(args.id); const client = await getClient(); + const scope = await getWorktree(); const collection = await client.collection.get(ref); + if (!isCollectionAlias(ref)) { + assertInWorktree("collection", ref, collection.worktree_id, scope); + } renderItem(collection, collectionView, ctx); }, }); diff --git a/packages/cli/src/commands/collection/items.ts b/packages/cli/src/commands/collection/items.ts index 4f961f64..00283bd9 100644 --- a/packages/cli/src/commands/collection/items.ts +++ b/packages/cli/src/commands/collection/items.ts @@ -11,20 +11,27 @@ import { listEnvelopeSchema } from "../../output/types"; import { collectionItemView } from "../../output/views/collection"; import { collectForOutput } from "../../output/window"; import { parseEnum, parseEnumCsv } from "../../runtime/csv"; -import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, listFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { parseCollectionRef } from "./parse-ref"; +import { assertInWorktree, scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; +import { COLLECTION_ROOT_REF, isCollectionAlias, parseCollectionRef } from "./parse-ref"; export const CollectionItemListEnvelope = listEnvelopeSchema(CollectionItemCompact); export default defineMetabaseCommand({ meta: { name: "items", description: "List items inside a collection" }, capabilities: { minVersion: 58 }, + worktree: "scoped", + details: + 'Under a scope the "root" alias walks the worktree\'s own top level; every other ref must name ' + + "a collection inside the worktree. " + + WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, id: { type: "positional", description: 'Collection id, 21-char entity id, or one of: "root", "trash"', @@ -51,18 +58,30 @@ export default defineMetabaseCommand({ "mb collection items root --json", "mb collection items 4 --models card,dashboard --json", "mb collection items 4 --pinned-state is_pinned --json", + "mb collection items root --worktree feat/transforms", ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const ref = parseCollectionRef(args.id); const models = parseEnumCsv(args.models, CollectionItemFilterModel, "--models"); const pinnedState = parseEnum(args["pinned-state"], CollectionPinnedState, "--pinned-state"); const client = await getClient(); + const scope = await getWorktree(); + if (scope !== null && !isCollectionAlias(ref)) { + const collection = await client.collection.get(ref); + assertInWorktree("collection", ref, collection.worktree_id, scope); + } + const rootQuery = ref === COLLECTION_ROOT_REF ? scopeQuery(scope) : scopeQuery(null); const envelope = await collectForOutput( (request) => client.collection.itemPages( ref, - { models, archived: args.archived ? true : undefined, pinned_state: pinnedState }, + { + models, + archived: args.archived ? true : undefined, + pinned_state: pinnedState, + ...rootQuery, + }, request, ), collectionItemView, diff --git a/packages/cli/src/commands/collection/list.ts b/packages/cli/src/commands/collection/list.ts index c17628f5..fdc7b06f 100644 --- a/packages/cli/src/commands/collection/list.ts +++ b/packages/cli/src/commands/collection/list.ts @@ -4,20 +4,24 @@ import { collectionView } from "../../output/views/collection"; import { renderList } from "../../output/render"; import { listEnvelopeSchema } from "../../output/types"; import { windowList } from "../../output/window"; -import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, listFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseEnumFlag } from "../parse-enum"; import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export const CollectionListEnvelope = listEnvelopeSchema(CollectionCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List collections" }, capabilities: { minVersion: 58 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, filter: { type: "string", description: `Filter preset: ${CollectionListFilter.options.join("|")}`, @@ -29,11 +33,13 @@ export default defineMetabaseCommand({ "mb collection list", "mb collection list --json", "mb collection list --filter archived --json", + "mb collection list --worktree feat/transforms", ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const filter = parseEnumFlag(args.filter, CollectionListFilter, "filter"); const client = await getClient(); - const collections = await client.collection.list({ filter }); + const scope = await getWorktree(); + const collections = await client.collection.list({ filter, ...scopeQuery(scope) }); renderList(windowList(collections.data, ctx.range), collectionView, ctx); }, }); diff --git a/packages/cli/src/commands/collection/parse-ref.ts b/packages/cli/src/commands/collection/parse-ref.ts index 3e751b7c..bf22023f 100644 --- a/packages/cli/src/commands/collection/parse-ref.ts +++ b/packages/cli/src/commands/collection/parse-ref.ts @@ -1,11 +1,20 @@ import { ConfigError } from "@metabase/client/errors"; -const SPECIAL_TOKENS: ReadonlySet = new Set(["root", "trash"]); +// The only alias the content endpoints scope to a worktree; `trash` has no worktree counterpart. +export const COLLECTION_ROOT_REF = "root"; + +const SPECIAL_TOKENS: ReadonlySet = new Set([COLLECTION_ROOT_REF, "trash"]); const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/; const NANO_ID_PATTERN = /^[A-Za-z0-9_-]{21}$/; const FORMAT_HINT = 'expected integer, "root", "trash", or 21-char entity id'; +// An alias names a pseudo-collection the server assembles rather than a row, so it carries no +// worktree tag to compare a scope against. +export function isCollectionAlias(ref: string): boolean { + return SPECIAL_TOKENS.has(ref); +} + export function parseCollectionRef(raw: string): string { const trimmed = raw.trim(); if (trimmed === "") { diff --git a/packages/cli/src/commands/collection/tree.ts b/packages/cli/src/commands/collection/tree.ts index d4987311..c052b4fd 100644 --- a/packages/cli/src/commands/collection/tree.ts +++ b/packages/cli/src/commands/collection/tree.ts @@ -3,8 +3,9 @@ import { z } from "zod"; import { ConfigError } from "@metabase/client/errors"; import { CollectionTreeNode } from "@metabase/client/domain/collection"; import { writeJson } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export const CollectionTreeResponse = z.array(CollectionTreeNode); @@ -14,15 +15,22 @@ export default defineMetabaseCommand({ description: "Fetch the collection hierarchy as a nested tree (JSON only)", }, capabilities: { minVersion: 58 }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, + args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...worktreeFlag }, outputSchema: CollectionTreeResponse, - examples: ["mb collection tree", "mb collection tree --json"], - async run({ ctx, getClient }) { + examples: [ + "mb collection tree", + "mb collection tree --json", + "mb collection tree --worktree feat/transforms", + ], + async run({ ctx, getClient, getWorktree }) { if (ctx.format === "text") { throw new ConfigError("collection tree output is JSON-only; --format text is not supported"); } const client = await getClient(); - const tree = await client.collection.tree(); + const scope = await getWorktree(); + const tree = await client.collection.tree(scopeQuery(scope)); writeJson(tree.data); }, }); diff --git a/packages/cli/src/commands/content-translation/download.ts b/packages/cli/src/commands/content-translation/download.ts index fb3fd7b3..47b40ebb 100644 --- a/packages/cli/src/commands/content-translation/download.ts +++ b/packages/cli/src/commands/content-translation/download.ts @@ -14,6 +14,7 @@ export default defineMetabaseCommand({ details: "Streams the active dictionary to stdout with the server's CSV formatting. Redirect stdout to preserve it as a file that can be reviewed, versioned, or uploaded later. An empty dictionary downloads as Metabase's four-row sample dictionary, not a header-only file.", capabilities: { minVersion: 58, tokenFeature: "content_translation" }, + worktree: "any", args: { ...profileFlag, ...connectionFlags, diff --git a/packages/cli/src/commands/content-translation/upload.ts b/packages/cli/src/commands/content-translation/upload.ts index e4c99aad..7fb65d44 100644 --- a/packages/cli/src/commands/content-translation/upload.ts +++ b/packages/cli/src/commands/content-translation/upload.ts @@ -14,6 +14,7 @@ export default defineMetabaseCommand({ details: "Uploads one complete dictionary and replaces every active content translation on the server. Keep the canonical full CSV in version control and download the current dictionary before replacing it. Metabase accepts dictionaries up to 1.5 MiB.", capabilities: { minVersion: 58, tokenFeature: "content_translation" }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/context.test.ts b/packages/cli/src/commands/context.test.ts index 26c75b17..37e5332e 100644 --- a/packages/cli/src/commands/context.test.ts +++ b/packages/cli/src/commands/context.test.ts @@ -150,6 +150,7 @@ describe("resolveCommonFlags — full result shape", () => { url: undefined, apiKey: undefined, profile: undefined, + worktree: undefined, skipPreflight: false, }); }); @@ -160,6 +161,7 @@ describe("resolveCommonFlags — full result shape", () => { url: "https://m.example.com", apiKey: "secret", profile: "prod", + worktree: "feat/transforms", format: "text", fields: "id,name", maxBytes: "1024", @@ -178,6 +180,7 @@ describe("resolveCommonFlags — full result shape", () => { url: "https://m.example.com", apiKey: "secret", profile: "prod", + worktree: "feat/transforms", skipPreflight: true, }); }); diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts index 57b29ca3..165b0561 100644 --- a/packages/cli/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -3,7 +3,7 @@ import { resolveFormat } from "../output/format"; import { DEFAULT_MAX_BYTES, type Format, type ListRange } from "../output/types"; import { parseCsv } from "../runtime/csv"; -import type { connectionFlags, listFlags, outputFlags, profileFlag } from "./flags"; +import type { connectionFlags, listFlags, outputFlags, profileFlag, worktreeFlag } from "./flags"; import { parseInteger, parseOptionalInteger } from "./parse-integer"; type FlagValue = T extends { type: "boolean" } @@ -15,7 +15,8 @@ type FlagValue = T extends { type: "boolean" } type AllKnownFlags = typeof outputFlags & typeof profileFlag & typeof connectionFlags & - typeof listFlags; + typeof listFlags & + typeof worktreeFlag; export type CommonArgs = { -readonly [K in keyof AllKnownFlags]?: FlagValue; @@ -30,6 +31,7 @@ export interface CommonContext { url: string | undefined; apiKey: string | undefined; profile: string | undefined; + worktree: string | undefined; skipPreflight: boolean; } @@ -63,6 +65,7 @@ export function resolveCommonFlags(args: CommonArgs, options: ResolveOptions = { url: args.url, apiKey: args.apiKey, profile: args.profile, + worktree: args.worktree, skipPreflight: args.skipPreflight === true, }; } diff --git a/packages/cli/src/commands/dashboard/archive.ts b/packages/cli/src/commands/dashboard/archive.ts index c3e83cee..27fa04b2 100644 --- a/packages/cli/src/commands/dashboard/archive.ts +++ b/packages/cli/src/commands/dashboard/archive.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a dashboard by id" }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/dashboard/cards.ts b/packages/cli/src/commands/dashboard/cards.ts index 41dc0b67..8e110e1c 100644 --- a/packages/cli/src/commands/dashboard/cards.ts +++ b/packages/cli/src/commands/dashboard/cards.ts @@ -12,6 +12,7 @@ export const DashcardListEnvelope = listEnvelopeSchema(DashcardCompact); export default defineMetabaseCommand({ meta: { name: "cards", description: "List dashcards on a dashboard" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/dashboard/create.ts b/packages/cli/src/commands/dashboard/create.ts index 025eb55b..c72deaf3 100644 --- a/packages/cli/src/commands/dashboard/create.ts +++ b/packages/cli/src/commands/dashboard/create.ts @@ -20,6 +20,7 @@ export default defineMetabaseCommand({ { skill: "visualization", purpose: "dashcard visualization_settings and the 24-column grid" }, ], capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/dashboard/get.ts b/packages/cli/src/commands/dashboard/get.ts index 1da2ddf5..f33f0707 100644 --- a/packages/cli/src/commands/dashboard/get.ts +++ b/packages/cli/src/commands/dashboard/get.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a dashboard by id" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts index 39de7694..5f2c7a9f 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -12,6 +12,7 @@ export const DashboardListEnvelope = listEnvelopeSchema(DashboardCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List dashboards" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/dashboard/parameter-values.ts b/packages/cli/src/commands/dashboard/parameter-values.ts index 8c4c866e..af2c9a87 100644 --- a/packages/cli/src/commands/dashboard/parameter-values.ts +++ b/packages/cli/src/commands/dashboard/parameter-values.ts @@ -15,6 +15,7 @@ export default defineMetabaseCommand({ "Reads the chain-filter value endpoint. With --query the server returns only values containing the substring (first 1000 matches).", skills: [{ skill: "dashboard", purpose: "dashboard parameters, mapping, value sources" }], capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/dashboard/subscriptions.ts b/packages/cli/src/commands/dashboard/subscriptions.ts index 21cdf9ef..a4aa7872 100644 --- a/packages/cli/src/commands/dashboard/subscriptions.ts +++ b/packages/cli/src/commands/dashboard/subscriptions.ts @@ -11,6 +11,7 @@ export default defineMetabaseCommand({ details: "Manage them with `mb subscription create|update|archive`, which take the subscription id printed here.", capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/dashboard/update-dashcard.ts b/packages/cli/src/commands/dashboard/update-dashcard.ts index 52aaeeeb..4e6a5cb1 100644 --- a/packages/cli/src/commands/dashboard/update-dashcard.ts +++ b/packages/cli/src/commands/dashboard/update-dashcard.ts @@ -13,6 +13,7 @@ export default defineMetabaseCommand({ description: "Patch a single dashcard's layout or settings on a dashboard", }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/dashboard/update.ts b/packages/cli/src/commands/dashboard/update.ts index 1e475fbf..99c929a7 100644 --- a/packages/cli/src/commands/dashboard/update.ts +++ b/packages/cli/src/commands/dashboard/update.ts @@ -21,6 +21,7 @@ export default defineMetabaseCommand({ { skill: "visualization", purpose: "dashcard visualization_settings and the 24-column grid" }, ], capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/db/get.ts b/packages/cli/src/commands/db/get.ts index 9579c4ad..6bf32e57 100644 --- a/packages/cli/src/commands/db/get.ts +++ b/packages/cli/src/commands/db/get.ts @@ -26,6 +26,7 @@ export default defineMetabaseCommand({ details: "`--include tables` returns the table map (id, name, schema, description per table) — one call that fits most databases. `--include tables.fields` is the full rollup; on all but small databases prefer the map plus `mb table fields ` per table of interest.", capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/db/list.ts b/packages/cli/src/commands/db/list.ts index 9bf5a4a7..5b6a8372 100644 --- a/packages/cli/src/commands/db/list.ts +++ b/packages/cli/src/commands/db/list.ts @@ -12,6 +12,7 @@ export const DatabaseListEnvelope = listEnvelopeSchema(DatabaseCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List databases" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/db/rescan-values.ts b/packages/cli/src/commands/db/rescan-values.ts index 1cd2c72f..be9c3679 100644 --- a/packages/cli/src/commands/db/rescan-values.ts +++ b/packages/cli/src/commands/db/rescan-values.ts @@ -11,6 +11,7 @@ export default defineMetabaseCommand({ description: "Trigger a rescan of cached field values for a database", }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/db/schema-tables.ts b/packages/cli/src/commands/db/schema-tables.ts index 50bf1be1..1052fc49 100644 --- a/packages/cli/src/commands/db/schema-tables.ts +++ b/packages/cli/src/commands/db/schema-tables.ts @@ -15,6 +15,7 @@ export default defineMetabaseCommand({ description: "List tables in a database schema", }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/db/schemas.ts b/packages/cli/src/commands/db/schemas.ts index 4c0fd4a5..9c4d094c 100644 --- a/packages/cli/src/commands/db/schemas.ts +++ b/packages/cli/src/commands/db/schemas.ts @@ -21,6 +21,7 @@ export const DatabaseSchemaListEnvelope = listEnvelopeSchema(SchemaName); export default defineMetabaseCommand({ meta: { name: "schemas", description: "List schemas in a database" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/db/sync-schema.ts b/packages/cli/src/commands/db/sync-schema.ts index 01c07275..7827de63 100644 --- a/packages/cli/src/commands/db/sync-schema.ts +++ b/packages/cli/src/commands/db/sync-schema.ts @@ -15,6 +15,7 @@ export default defineMetabaseCommand({ details: "Queues an async schema sync and returns immediately. Pass --wait to poll the database until its initial_sync_status reports `complete` (a database that has already finished its initial sync returns at once). To wait for a specific newly-materialized transform table to register, prefer `mb transform run --sync`.", capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/delete-runtime.test.ts b/packages/cli/src/commands/delete-runtime.test.ts index 0606b595..0caab2c9 100644 --- a/packages/cli/src/commands/delete-runtime.test.ts +++ b/packages/cli/src/commands/delete-runtime.test.ts @@ -19,6 +19,7 @@ const CTX: CommonContext = { url: undefined, apiKey: undefined, profile: undefined, + worktree: undefined, skipPreflight: false, }; diff --git a/packages/cli/src/commands/document/archive.ts b/packages/cli/src/commands/document/archive.ts index b2d0c318..337a79ec 100644 --- a/packages/cli/src/commands/document/archive.ts +++ b/packages/cli/src/commands/document/archive.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a document by id" }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/document/create.ts b/packages/cli/src/commands/document/create.ts index 46bce565..bcf97e5c 100644 --- a/packages/cli/src/commands/document/create.ts +++ b/packages/cli/src/commands/document/create.ts @@ -9,6 +9,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "create", description: "Create a document" }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, inputSchema: DocumentCreateInput, outputSchema: Document, diff --git a/packages/cli/src/commands/document/get.ts b/packages/cli/src/commands/document/get.ts index 1a29dee3..836e0675 100644 --- a/packages/cli/src/commands/document/get.ts +++ b/packages/cli/src/commands/document/get.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a document by id" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/document/list.ts b/packages/cli/src/commands/document/list.ts index 1dc819cb..b5cd8b4f 100644 --- a/packages/cli/src/commands/document/list.ts +++ b/packages/cli/src/commands/document/list.ts @@ -11,6 +11,7 @@ export const DocumentListEnvelope = listEnvelopeSchema(DocumentCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List documents" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: DocumentListEnvelope, examples: ["mb document list", "mb document list --json"], diff --git a/packages/cli/src/commands/document/update.ts b/packages/cli/src/commands/document/update.ts index 12d4f9af..a6435a73 100644 --- a/packages/cli/src/commands/document/update.ts +++ b/packages/cli/src/commands/document/update.ts @@ -10,6 +10,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "update", description: "Update a document by id" }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/eid.ts b/packages/cli/src/commands/eid.ts index 89193d77..6ed66b46 100644 --- a/packages/cli/src/commands/eid.ts +++ b/packages/cli/src/commands/eid.ts @@ -24,6 +24,7 @@ export default defineMetabaseCommand({ "Entity ids are NanoIDs that can start with `-`; pass those via `--body` so the positional parser doesn't read them as a flag.", skills: [{ skill: "core", purpose: "entity-id translation and body input" }], capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/field/get.ts b/packages/cli/src/commands/field/get.ts index 9df3d6a3..c15c61c4 100644 --- a/packages/cli/src/commands/field/get.ts +++ b/packages/cli/src/commands/field/get.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a field by id" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/field/summary.ts b/packages/cli/src/commands/field/summary.ts index 20ba3a6c..6aa273a4 100644 --- a/packages/cli/src/commands/field/summary.ts +++ b/packages/cli/src/commands/field/summary.ts @@ -11,6 +11,7 @@ export default defineMetabaseCommand({ description: "Get the row count and distinct count for a field", }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/field/update.ts b/packages/cli/src/commands/field/update.ts index 8c004fd8..1fcfaa64 100644 --- a/packages/cli/src/commands/field/update.ts +++ b/packages/cli/src/commands/field/update.ts @@ -13,6 +13,7 @@ export default defineMetabaseCommand({ description: "Update a field (description, semantic_type, FK target, visibility, etc.)", }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/field/values.ts b/packages/cli/src/commands/field/values.ts index b3cbe50b..c042d54c 100644 --- a/packages/cli/src/commands/field/values.ts +++ b/packages/cli/src/commands/field/values.ts @@ -11,6 +11,7 @@ export default defineMetabaseCommand({ description: "Fetch the cached distinct values for a field (FieldValues list)", }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/flags.ts b/packages/cli/src/commands/flags.ts index d43cd860..01752bec 100644 --- a/packages/cli/src/commands/flags.ts +++ b/packages/cli/src/commands/flags.ts @@ -49,6 +49,14 @@ export const profileFlag = { profile: { type: "string", description: "Named profile (default: 'default')", alias: "p" }, } as const; +export const worktreeFlag = { + worktree: { + type: "string", + description: + "Scope to a git-sync worktree (id or branch); defaults to MB_WORKTREE or the profile's pinned worktree", + }, +} as const; + export const connectionFlags = { url: { type: "string", description: "Metabase URL" }, apiKey: { type: "string", description: "API key", alias: "api-key" }, diff --git a/packages/cli/src/commands/git-sync/add-collection.ts b/packages/cli/src/commands/git-sync/add-collection.ts index 677e11b9..a19cbcd8 100644 --- a/packages/cli/src/commands/git-sync/add-collection.ts +++ b/packages/cli/src/commands/git-sync/add-collection.ts @@ -14,6 +14,7 @@ export default defineMetabaseCommand({ description: "Mark a collection as git-synced; cascades to descendants by location prefix", }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/git-sync/branch.test.ts b/packages/cli/src/commands/git-sync/branch.test.ts new file mode 100644 index 00000000..380e4f82 --- /dev/null +++ b/packages/cli/src/commands/git-sync/branch.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { ConfigError } from "@metabase/client/errors"; + +import type { WorktreeScope } from "../../core/worktree-scope"; + +import { BRANCH_PINNED_REFUSAL, BRANCH_UNSET_REFUSAL, syncBranchTarget } from "./branch"; + +const SCOPE: WorktreeScope = { id: 3, branch: "feat/transforms" }; + +describe("syncBranchTarget", () => { + it("uses the worktree's own branch as both the target and the branch left behind", () => { + expect(syncBranchTarget({ scope: SCOPE, flag: undefined, tracked: "main" })).toEqual({ + branch: "feat/transforms", + expected: "feat/transforms", + }); + }); + + it("refuses --branch inside a worktree, which is bound to one branch for life", () => { + expect(() => syncBranchTarget({ scope: SCOPE, flag: "other", tracked: null })).toThrow( + new ConfigError(BRANCH_PINNED_REFUSAL), + ); + }); + + it("falls back to the tracked branch in the main app", () => { + expect(syncBranchTarget({ scope: null, flag: undefined, tracked: "main" })).toEqual({ + branch: "main", + expected: "main", + }); + }); + + it("retargets the main app with --branch while still stating the tracked branch", () => { + expect(syncBranchTarget({ scope: null, flag: "release", tracked: "main" })).toEqual({ + branch: "release", + expected: "main", + }); + }); + + it("treats an empty --branch as unstated", () => { + expect(syncBranchTarget({ scope: null, flag: "", tracked: "main" })).toEqual({ + branch: "main", + expected: "main", + }); + }); + + it("refuses a main-app sync on an instance with no branch configured", () => { + expect(() => syncBranchTarget({ scope: null, flag: undefined, tracked: null })).toThrow( + new ConfigError(BRANCH_UNSET_REFUSAL), + ); + }); +}); diff --git a/packages/cli/src/commands/git-sync/branch.ts b/packages/cli/src/commands/git-sync/branch.ts new file mode 100644 index 00000000..49ed27d7 --- /dev/null +++ b/packages/cli/src/commands/git-sync/branch.ts @@ -0,0 +1,47 @@ +import type { MetabaseClient } from "@metabase/client/client"; +import { ConfigError } from "@metabase/client/errors"; + +import type { WorktreeScope } from "../../core/worktree-scope"; + +export const BRANCH_PINNED_REFUSAL = "a worktree is pinned to its branch; drop --branch"; +export const BRANCH_UNSET_REFUSAL = "remote-sync-branch is not set; configure git-sync first"; + +interface SyncBranchInput { + scope: WorktreeScope | null; + flag: string | undefined; + tracked: string | null; +} + +// `branch` is where the content moves, `expected` the branch the caller believes the scope is on: +// the server compares `expected` against the scope's own branch and refuses the request outright +// when they disagree, so pointing `--branch` elsewhere in the main app still has to state the +// tracked branch as the one being left. +interface SyncBranchTarget { + branch: string; + expected: string; +} + +export function syncBranchTarget(input: SyncBranchInput): SyncBranchTarget { + const stated = input.flag === undefined || input.flag === "" ? null : input.flag; + if (input.scope !== null) { + if (stated !== null) { + throw new ConfigError(BRANCH_PINNED_REFUSAL); + } + return { branch: input.scope.branch, expected: input.scope.branch }; + } + if (input.tracked === null) { + throw new ConfigError(BRANCH_UNSET_REFUSAL); + } + return { branch: stated ?? input.tracked, expected: input.tracked }; +} + +// A worktree carries its branch, so only the main app has to read the setting — and reading it +// under a scope would fail on an instance that never configured one. +export async function resolveSyncBranch( + mb: MetabaseClient, + scope: WorktreeScope | null, + flag: string | undefined, +): Promise { + const tracked = scope === null ? await mb.gitSync.branch() : null; + return syncBranchTarget({ scope, flag, tracked }); +} diff --git a/packages/cli/src/commands/git-sync/branches.ts b/packages/cli/src/commands/git-sync/branches.ts index f270dce1..36c0ba8b 100644 --- a/packages/cli/src/commands/git-sync/branches.ts +++ b/packages/cli/src/commands/git-sync/branches.ts @@ -20,6 +20,7 @@ const SyncBranchListEnvelope = listEnvelopeSchema(BranchRow); export default defineMetabaseCommand({ meta: { name: "branches", description: "List branches on the configured git remote" }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + worktree: "any", args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncBranchListEnvelope, examples: ["mb git-sync branches", "mb git-sync branches --json"], diff --git a/packages/cli/src/commands/git-sync/cancel-task.ts b/packages/cli/src/commands/git-sync/cancel-task.ts index 6894f8bc..9d126ef4 100644 --- a/packages/cli/src/commands/git-sync/cancel-task.ts +++ b/packages/cli/src/commands/git-sync/cancel-task.ts @@ -1,19 +1,25 @@ import { SyncTask } from "@metabase/client/domain/git-sync"; +import type { SyncCancelTaskParams } from "@metabase/client/resources/git-sync"; import { syncTaskView } from "../../output/views/git-sync"; import { renderSummary } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { name: "cancel-task", description: "Cancel the running git-sync task" }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, + args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...worktreeFlag }, outputSchema: SyncTask, examples: ["mb git-sync cancel-task", "mb git-sync cancel-task --json"], - async run({ ctx, getClient }) { + async run({ ctx, getClient, getWorktree }) { const mb = await getClient(); - const task = await mb.gitSync.cancelTask(); + const scope = await getWorktree(); + const params: SyncCancelTaskParams = scope === null ? {} : { worktree_id: scope.id }; + const task = await mb.gitSync.cancelTask(params); renderSummary( task, syncTaskView, diff --git a/packages/cli/src/commands/git-sync/create-branch.ts b/packages/cli/src/commands/git-sync/create-branch.ts index 78a24350..6714f925 100644 --- a/packages/cli/src/commands/git-sync/create-branch.ts +++ b/packages/cli/src/commands/git-sync/create-branch.ts @@ -12,16 +12,26 @@ export default defineMetabaseCommand({ description: "Create a new branch on the git remote and switch git-sync to it", }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + worktree: "main-only", + details: + "The instance switches to the new branch unless --no-checkout, which creates it on the remote and leaves git-sync where it is — what `mb worktree create` does before checking the branch out into a worktree.", args: { ...outputFlags, ...profileFlag, ...connectionFlags, name: { type: "positional", description: "Branch name", required: true }, + checkout: { + type: "boolean", + description: + "Switch git-sync to the new branch (default: true; --no-checkout only creates it)", + default: true, + }, }, outputSchema: SyncBranchCreated, examples: [ "mb git-sync create-branch feat/dashboards", "mb git-sync create-branch feat/x --json", + "mb git-sync create-branch feat/x --no-checkout", ], async run({ args, ctx, getClient }) { const name = args.name.trim(); @@ -29,12 +39,10 @@ export default defineMetabaseCommand({ throw new ConfigError("invalid name: branch name must not be blank"); } const mb = await getClient(); - const result = await mb.gitSync.createBranch({ name }); - renderSummary( - result, - syncBranchCreatedView, - `Created branch "${name}" and switched git-sync to it.`, - ctx, - ); + const result = await mb.gitSync.createBranch({ name, checkout: args.checkout }); + const message = args.checkout + ? `Created branch "${name}" and switched git-sync to it.` + : `Created branch "${name}".`; + renderSummary(result, syncBranchCreatedView, message, ctx); }, }); diff --git a/packages/cli/src/commands/git-sync/current-task.ts b/packages/cli/src/commands/git-sync/current-task.ts index d25996b2..1fdaebac 100644 --- a/packages/cli/src/commands/git-sync/current-task.ts +++ b/packages/cli/src/commands/git-sync/current-task.ts @@ -1,7 +1,8 @@ import { syncTaskView } from "../../output/views/git-sync"; import { renderSummary } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; import { formatSyncTask, syncTaskIdleView, SyncTaskIdle, SyncTaskOrIdle } from "./sync-task"; @@ -13,12 +14,15 @@ export default defineMetabaseCommand({ description: "Get the most recent git-sync task (or idle if none)", }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, + args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...worktreeFlag }, outputSchema: CurrentTaskResult, examples: ["mb git-sync current-task", "mb git-sync current-task --json"], - async run({ ctx, getClient }) { + async run({ ctx, getClient, getWorktree }) { const mb = await getClient(); - const task = await mb.gitSync.currentTask(); + const scope = await getWorktree(); + const task = await mb.gitSync.currentTask(scopeQuery(scope)); if (task === null) { const idle: SyncTaskIdle = { status: "idle" }; renderSummary(idle, syncTaskIdleView, "No git-sync task is running.", ctx); diff --git a/packages/cli/src/commands/git-sync/dirty.ts b/packages/cli/src/commands/git-sync/dirty.ts index f408eca3..b8c4b123 100644 --- a/packages/cli/src/commands/git-sync/dirty.ts +++ b/packages/cli/src/commands/git-sync/dirty.ts @@ -4,20 +4,28 @@ import { syncDirtyItemView } from "../../output/views/git-sync"; import { renderList } from "../../output/render"; import { listEnvelopeSchema } from "../../output/types"; import { windowList } from "../../output/window"; -import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, listFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export const SyncDirtyListEnvelope = listEnvelopeSchema(SyncDirtyItemCompact); export default defineMetabaseCommand({ meta: { name: "dirty", description: "List objects with unsynced local changes" }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, - args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags, ...worktreeFlag }, outputSchema: SyncDirtyListEnvelope, - examples: ["mb git-sync dirty", "mb git-sync dirty --json"], - async run({ ctx, getClient }) { + examples: [ + "mb git-sync dirty", + "mb git-sync dirty --json", + "mb git-sync dirty --worktree feat/transforms", + ], + async run({ ctx, getClient, getWorktree }) { const mb = await getClient(); - const { data } = await mb.gitSync.dirty(); + const scope = await getWorktree(); + const { data } = await mb.gitSync.dirty(scopeQuery(scope)); renderList(windowList(data, ctx.range), syncDirtyItemView, ctx); }, }); diff --git a/packages/cli/src/commands/git-sync/export-preflight.ts b/packages/cli/src/commands/git-sync/export-preflight.ts new file mode 100644 index 00000000..91a1fda5 --- /dev/null +++ b/packages/cli/src/commands/git-sync/export-preflight.ts @@ -0,0 +1,89 @@ +import { SyncExportPreflight } from "@metabase/client/domain/git-sync"; +import type { SyncExportPreflightParams } from "@metabase/client/resources/git-sync"; + +import { renderSummary } from "../../output/render"; +import { syncExportPreflightView } from "../../output/views/git-sync"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; + +import { resolveSyncBranch } from "./branch"; + +export { SyncExportPreflight }; + +export default defineMetabaseCommand({ + meta: { + name: "export-preflight", + description: "Preview what exporting to the remote branch would do, without writing anything", + }, + capabilities: { minVersion: 63, tokenFeature: "remote_sync" }, + worktree: "scoped", + details: + "Compares the local state against the live remote branch: whether a three-way merge would " + + "apply cleanly, which entities conflict, and what a --force push would discard. " + + "Inside a worktree the branch is the worktree's own, so --branch is refused there. " + + WORKTREE_SCOPE_DETAIL, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...worktreeFlag, + branch: { + type: "string", + description: "Branch to preview against (defaults to remote-sync-branch setting)", + alias: "b", + }, + }, + outputSchema: SyncExportPreflight, + examples: [ + "mb git-sync export-preflight", + "mb git-sync export-preflight --json", + "mb git-sync export-preflight --worktree feat/transforms", + ], + async run({ args, ctx, getClient, getWorktree }) { + const mb = await getClient(); + const scope = await getWorktree(); + const target = await resolveSyncBranch(mb, scope, args.branch); + + const params: SyncExportPreflightParams = { branch: target.branch, ...scopeQuery(scope) }; + const preflight = await mb.gitSync.exportPreflight(params); + renderSummary(preflight, syncExportPreflightView, summarize(preflight, target.branch), ctx); + }, +}); + +function summarize(preflight: SyncExportPreflight, branch: string): string { + const lines = [`${countsSentence(preflight)} against branch "${branch}".`]; + if (preflight.conflicts.length > 0) { + lines.push(`Conflicts (${preflight.conflicts.length}): ${preflight.conflicts.join(", ")}.`); + } + const casualties = casualtiesSentence(preflight); + if (casualties !== null) { + lines.push(casualties); + } + if (preflight.reason !== null) { + lines.push(`Reason: ${preflight.reason}.`); + } + return lines.join("\n"); +} + +function countsSentence(preflight: SyncExportPreflight): string { + if (!preflight.has_changes) { + return "Nothing to export"; + } + const { added, updated, removed } = preflight.summary; + const counts = `${added} added, ${updated} updated, ${removed} removed`; + return preflight.clean + ? `Exporting would apply cleanly: ${counts}` + : `Exporting would not apply cleanly: ${counts}`; +} + +function casualtiesSentence(preflight: SyncExportPreflight): string | null { + const { deleted, overwritten } = preflight.force_push_casualties; + if (deleted.length === 0 && overwritten.length === 0) { + return null; + } + return ( + `A --force push would delete ${deleted.length} and overwrite ${overwritten.length} ` + + "remote objects." + ); +} diff --git a/packages/cli/src/commands/git-sync/export.ts b/packages/cli/src/commands/git-sync/export.ts index e3a39529..72fbb676 100644 --- a/packages/cli/src/commands/git-sync/export.ts +++ b/packages/cli/src/commands/git-sync/export.ts @@ -5,10 +5,12 @@ import { warn } from "../../output/notice"; import { renderSummary } from "../../output/render"; import { syncExportView } from "../../output/views/git-sync"; import type { CommonContext } from "../context"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; import { gitSyncWaitFlags, parseWaitFlags } from "../wait-flags"; +import { WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; +import { resolveSyncBranch } from "./branch"; import { formatSyncTask, taskPollOptions, throwIfFailedTask } from "./sync-task"; export default defineMetabaseCommand({ @@ -17,10 +19,16 @@ export default defineMetabaseCommand({ description: "Export Metabase changes back to the configured git remote", }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + worktree: "scoped", + details: + "Inside a worktree the branch is the worktree's own, so --branch is refused there. " + + "Preview what a push would do with `mb git-sync export-preflight` first. " + + WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, branch: { type: "string", description: "Branch to export to (defaults to remote-sync-branch setting)", @@ -36,6 +44,11 @@ export default defineMetabaseCommand({ description: "Force-push / overwrite remote", default: false, }, + merge: { + type: "boolean", + description: "Three-way merge remote changes instead of failing on divergence", + default: false, + }, ...gitSyncWaitFlags, }, outputSchema: SyncExportResult, @@ -43,12 +56,17 @@ export default defineMetabaseCommand({ 'mb git-sync export -m "update dashboards"', "mb git-sync export --branch main --json", "mb git-sync export --no-wait", + 'mb git-sync export --worktree feat/transforms -m "new transforms"', ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const wait = parseWaitFlags(args); - const params: SyncExportParams = {}; - if (args.branch !== undefined && args.branch !== "") { - params.branch = args.branch; + const mb = await getClient(); + const scope = await getWorktree(); + const target = await resolveSyncBranch(mb, scope, args.branch); + + const params: SyncExportParams = { branch: target.branch }; + if (scope !== null) { + params.worktree_id = scope.id; } if (args.message !== undefined && args.message !== "") { params.message = args.message; @@ -56,11 +74,13 @@ export default defineMetabaseCommand({ if (args.force) { params.force = true; } + if (args.merge) { + params.merge = true; + } if (wait.enabled) { params.wait = taskPollOptions(wait.schedule); } - const mb = await getClient(); const result = await mb.gitSync.export(params); if (!wait.enabled) { @@ -72,10 +92,13 @@ export default defineMetabaseCommand({ renderSummary(result, syncExportView, text, ctx); throwIfFailedTask(final, "export"); } - emitRealignHint(ctx); + if (scope === null) { + emitRealignHint(ctx); + } }, }); +// A worktree is not the instance's own checkout, so nothing on the host tree moved with it. function emitRealignHint(ctx: CommonContext): void { if (ctx.format !== "text") { return; diff --git a/packages/cli/src/commands/git-sync/has-remote-changes.ts b/packages/cli/src/commands/git-sync/has-remote-changes.ts index 2a4b0d86..16135421 100644 --- a/packages/cli/src/commands/git-sync/has-remote-changes.ts +++ b/packages/cli/src/commands/git-sync/has-remote-changes.ts @@ -2,8 +2,9 @@ import { SyncRemoteChanges } from "@metabase/client/domain/git-sync"; import { renderSummary } from "../../output/render"; import { syncRemoteChangesView } from "../../output/views/git-sync"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { @@ -11,10 +12,13 @@ export default defineMetabaseCommand({ description: "Check whether the remote branch has unimported changes", }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, forceRefresh: { type: "boolean", description: "Bypass the in-memory cache and re-check the remote", @@ -27,9 +31,13 @@ export default defineMetabaseCommand({ "mb git-sync has-remote-changes", "mb git-sync has-remote-changes --force-refresh --json", ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const mb = await getClient(); - const result = await mb.gitSync.hasRemoteChanges({ "force-refresh": args.forceRefresh }); + const scope = await getWorktree(); + const result = await mb.gitSync.hasRemoteChanges({ + "force-refresh": args.forceRefresh, + ...scopeQuery(scope), + }); const base = result.has_changes ? `The remote branch has changes not yet imported (remote ${result.remote_version ?? "?"}, local ${result.local_version ?? "?"}).` : "Up to date with the remote branch."; diff --git a/packages/cli/src/commands/git-sync/import.ts b/packages/cli/src/commands/git-sync/import.ts index 763e827f..c6d7a297 100644 --- a/packages/cli/src/commands/git-sync/import.ts +++ b/packages/cli/src/commands/git-sync/import.ts @@ -3,10 +3,12 @@ import type { SyncImportParams } from "@metabase/client/resources/git-sync"; import { renderSummary } from "../../output/render"; import { syncImportView } from "../../output/views/git-sync"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; import { gitSyncWaitFlags, parseWaitFlags } from "../wait-flags"; +import { WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; +import { resolveSyncBranch } from "./branch"; import { formatSyncTask, taskPollOptions, throwIfFailedTask } from "./sync-task"; export default defineMetabaseCommand({ @@ -15,10 +17,15 @@ export default defineMetabaseCommand({ description: "Import content from the configured git remote into Metabase", }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + worktree: "scoped", + details: + "Inside a worktree the branch is the worktree's own, so --branch is refused there. " + + WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, branch: { type: "string", description: "Branch to import from (defaults to remote-sync-branch setting)", @@ -29,6 +36,11 @@ export default defineMetabaseCommand({ description: "Discard local Metabase-side dirty changes (LOSSY)", default: false, }, + merge: { + type: "boolean", + description: "Three-way merge remote changes instead of failing on divergence", + default: false, + }, ...gitSyncWaitFlags, }, outputSchema: SyncImportResult, @@ -36,21 +48,28 @@ export default defineMetabaseCommand({ "mb git-sync import", "mb git-sync import --branch main --json", "mb git-sync import --force --no-wait", + "mb git-sync import --worktree feat/transforms", ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const wait = parseWaitFlags(args); - const params: SyncImportParams = {}; - if (args.branch !== undefined && args.branch !== "") { - params.branch = args.branch; + const mb = await getClient(); + const scope = await getWorktree(); + const target = await resolveSyncBranch(mb, scope, args.branch); + + const params: SyncImportParams = { branch: target.branch, expected_branch: target.expected }; + if (scope !== null) { + params.worktree_id = scope.id; } if (args.force) { params.force = true; } + if (args.merge) { + params.merge = true; + } if (wait.enabled) { params.wait = taskPollOptions(wait.schedule); } - const mb = await getClient(); const result = await mb.gitSync.import(params); if (!wait.enabled || result.task_id === null) { diff --git a/packages/cli/src/commands/git-sync/index.ts b/packages/cli/src/commands/git-sync/index.ts index ab024300..077bc8a8 100644 --- a/packages/cli/src/commands/git-sync/index.ts +++ b/packages/cli/src/commands/git-sync/index.ts @@ -14,6 +14,7 @@ export default defineCommandGroup({ wait: () => import("./wait").then((mod) => mod.default), import: () => import("./import").then((mod) => mod.default), export: () => import("./export").then((mod) => mod.default), + "export-preflight": () => import("./export-preflight").then((mod) => mod.default), stash: () => import("./stash").then((mod) => mod.default), branches: () => import("./branches").then((mod) => mod.default), "create-branch": () => import("./create-branch").then((mod) => mod.default), diff --git a/packages/cli/src/commands/git-sync/is-dirty.ts b/packages/cli/src/commands/git-sync/is-dirty.ts index cf920862..bcd6e95b 100644 --- a/packages/cli/src/commands/git-sync/is-dirty.ts +++ b/packages/cli/src/commands/git-sync/is-dirty.ts @@ -2,8 +2,9 @@ import { z } from "zod"; import type { ResourceView } from "../../output/view"; import { renderSummary } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export const IsDirtyResult = z.object({ is_dirty: z.boolean(), @@ -21,12 +22,19 @@ export default defineMetabaseCommand({ description: "Check whether Metabase has unsynced local changes", }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, + args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...worktreeFlag }, outputSchema: IsDirtyResult, - examples: ["mb git-sync is-dirty", "mb git-sync is-dirty --json"], - async run({ ctx, getClient }) { + examples: [ + "mb git-sync is-dirty", + "mb git-sync is-dirty --json", + "mb git-sync is-dirty --worktree feat/transforms", + ], + async run({ ctx, getClient, getWorktree }) { const mb = await getClient(); - const result: IsDirtyResult = { is_dirty: await mb.gitSync.isDirty() }; + const scope = await getWorktree(); + const result: IsDirtyResult = { is_dirty: await mb.gitSync.isDirty(scopeQuery(scope)) }; renderSummary(result, isDirtyView, result.is_dirty ? "dirty" : "clean", ctx); }, }); diff --git a/packages/cli/src/commands/git-sync/remove-collection.ts b/packages/cli/src/commands/git-sync/remove-collection.ts index d77d1cf1..26bc6251 100644 --- a/packages/cli/src/commands/git-sync/remove-collection.ts +++ b/packages/cli/src/commands/git-sync/remove-collection.ts @@ -12,6 +12,7 @@ export default defineMetabaseCommand({ description: "Unmark a collection as git-synced; cascades to descendants by location prefix", }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/git-sync/stash.ts b/packages/cli/src/commands/git-sync/stash.ts index 5107f21b..bbd55498 100644 --- a/packages/cli/src/commands/git-sync/stash.ts +++ b/packages/cli/src/commands/git-sync/stash.ts @@ -18,6 +18,7 @@ export default defineMetabaseCommand({ description: "Export current Metabase state to a new branch on the git remote", }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/git-sync/status.ts b/packages/cli/src/commands/git-sync/status.ts index 88a5bda0..27315fbf 100644 --- a/packages/cli/src/commands/git-sync/status.ts +++ b/packages/cli/src/commands/git-sync/status.ts @@ -2,19 +2,24 @@ import { z } from "zod"; import { Collection } from "@metabase/client/domain/collection"; import { SyncTask } from "@metabase/client/domain/git-sync"; +import { Worktree } from "@metabase/client/domain/worktree"; import type { ResourceView } from "../../output/view"; import { renderSummary } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; import { formatSyncTask } from "./sync-task"; const SyncedCollection = Collection.pick({ id: true, name: true }).strip(); type SyncedCollection = z.infer; +const SyncStatusWorktree = Worktree.pick({ id: true, branch: true }).strip(); + export const SyncStatus = z.object({ branch: z.string().nullable(), + worktree: SyncStatusWorktree.nullable(), is_dirty: z.boolean(), current_task: SyncTask.nullable(), synced_collections: z.array(SyncedCollection), @@ -25,6 +30,7 @@ const syncStatusView: ResourceView = { compactPick: SyncStatus, tableColumns: [ { key: "branch", label: "Branch" }, + { key: "worktree", label: "Worktree" }, { key: "is_dirty", label: "Dirty" }, { key: "current_task", label: "Current task" }, { key: "synced_collections", label: "Synced collections" }, @@ -37,15 +43,45 @@ export default defineMetabaseCommand({ description: "Show current git-sync state (branch, dirty, current task)", }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, + worktree: "scoped", + details: + "Inside a worktree the branch reported is the worktree's own and the dirty flag and current " + + "task are the worktree's; the per-collection sync scope is the main app's alone, so it reads " + + "empty there. " + + WORKTREE_SCOPE_DETAIL, + args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...worktreeFlag }, outputSchema: SyncStatus, - examples: ["mb git-sync status", "mb git-sync status --json"], - async run({ ctx, getClient }) { + examples: [ + "mb git-sync status", + "mb git-sync status --json", + "mb git-sync status --worktree feat/transforms", + ], + async run({ ctx, getClient, getWorktree }) { const mb = await getClient(); - const [branch, isDirty, currentTask, collections] = await Promise.all([ + const scope = await getWorktree(); + const query = scopeQuery(scope); + const [isDirty, currentTask] = await Promise.all([ + mb.gitSync.isDirty(query), + mb.gitSync.currentTask(query), + ]); + const taskPart = currentTask === null ? "No task running." : formatSyncTask(currentTask); + const dirtyPart = isDirty ? "Metabase has unsynced local changes" : "in sync with the remote"; + + if (scope !== null) { + const status: SyncStatus = { + branch: scope.branch, + worktree: scope, + is_dirty: isDirty, + current_task: currentTask, + synced_collections: [], + }; + const scopePart = `Worktree ${scope.id} (${scope.branch})`; + renderSummary(status, syncStatusView, `${scopePart} — ${dirtyPart}. ${taskPart}`, ctx); + return; + } + + const [branch, collections] = await Promise.all([ mb.gitSync.branch(), - mb.gitSync.isDirty(), - mb.gitSync.currentTask(), mb.gitSync.syncedCollections(), ]); const syncedCollections: SyncedCollection[] = collections.data.map((collection) => ({ @@ -55,13 +91,12 @@ export default defineMetabaseCommand({ const summary: SyncStatus = { branch, + worktree: null, is_dirty: isDirty, current_task: currentTask, synced_collections: syncedCollections, }; const branchPart = branch === null ? "git-sync branch not set" : `Branch ${branch}`; - const dirtyPart = isDirty ? "Metabase has unsynced local changes" : "in sync with the remote"; - const taskPart = currentTask === null ? "No task running." : formatSyncTask(currentTask); const scopePart = formatSyncedCollections(syncedCollections); renderSummary( summary, diff --git a/packages/cli/src/commands/git-sync/wait.ts b/packages/cli/src/commands/git-sync/wait.ts index abece2ee..18c8c3f0 100644 --- a/packages/cli/src/commands/git-sync/wait.ts +++ b/packages/cli/src/commands/git-sync/wait.ts @@ -2,9 +2,10 @@ import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from "@metabase/client/poll"; import { renderSummary } from "../../output/render"; import { syncTaskView } from "../../output/views/git-sync"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; import { parseWaitSchedule } from "../wait-flags"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; import { formatSyncTask, @@ -23,10 +24,13 @@ export default defineMetabaseCommand({ description: "Poll the current git-sync task until it reaches a terminal status", }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, timeout: { type: "string", description: "Polling timeout in ms", @@ -40,10 +44,11 @@ export default defineMetabaseCommand({ }, outputSchema: WaitResult, examples: ["mb git-sync wait", "mb git-sync wait --timeout 300000 --json"], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const schedule = parseWaitSchedule(args); const mb = await getClient(); - const final = await mb.gitSync.waitForTask(taskPollOptions(schedule)); + const scope = await getWorktree(); + const final = await mb.gitSync.waitForTask(taskPollOptions(schedule), scopeQuery(scope)); if (final === null) { const idle: SyncTaskIdle = { status: "idle" }; diff --git a/packages/cli/src/commands/global-flags.ts b/packages/cli/src/commands/global-flags.ts index 4cce28f7..7e706659 100644 --- a/packages/cli/src/commands/global-flags.ts +++ b/packages/cli/src/commands/global-flags.ts @@ -2,7 +2,7 @@ import type { ArgsDef } from "citty"; import { flagConsumesValue, normalizeFlag, toAliasArray } from "../runtime/citty"; -import { connectionFlags, listFlags, outputFlags, profileFlag } from "./flags"; +import { connectionFlags, listFlags, outputFlags, profileFlag, worktreeFlag } from "./flags"; const ARGUMENT_SEPARATOR = "--"; const NEGATION_PREFIX = "no-"; @@ -12,6 +12,7 @@ const GLOBAL_FLAG_ARGS: ArgsDef = { ...listFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, }; const GLOBAL_FLAG_NAMES: ReadonlySet = buildGlobalFlagNames(); diff --git a/packages/cli/src/commands/group.ts b/packages/cli/src/commands/group.ts index adade155..b6be4984 100644 --- a/packages/cli/src/commands/group.ts +++ b/packages/cli/src/commands/group.ts @@ -29,6 +29,7 @@ export function defineCommandGroup(def: CommandGroupDef): CommandDef { inputSchema: null, outputSchema: null, capabilities: null, + worktree: "any", }); return cmd; } diff --git a/packages/cli/src/commands/library/create.ts b/packages/cli/src/commands/library/create.ts index 45a2432c..46d052a3 100644 --- a/packages/cli/src/commands/library/create.ts +++ b/packages/cli/src/commands/library/create.ts @@ -12,6 +12,7 @@ export default defineMetabaseCommand({ details: "Creates the Library subtree if it doesn't exist and returns it; a no-op that returns the existing Library when it's already there. Only admins and data analysts can curate the Library.", capabilities: { minVersion: 59, tokenFeature: "library" }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/library/get.ts b/packages/cli/src/commands/library/get.ts index 4f2b8318..f7e2b9a9 100644 --- a/packages/cli/src/commands/library/get.ts +++ b/packages/cli/src/commands/library/get.ts @@ -1,8 +1,9 @@ import { Library } from "@metabase/client/domain/library"; import { libraryView } from "../../output/views/library"; import { renderItem } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { @@ -10,18 +11,26 @@ export default defineMetabaseCommand({ description: "Show the Library and its Data / Metrics collection ids", }, details: - "Reads the Library root and its child collections. Use the `library-data` child's id as the target for publishing tables (or just run `mb library publish`, which resolves it for you).", + "Reads the Library root and its child collections. Use the `library-data` child's id as the target for publishing tables (or just run `mb library publish`, which resolves it for you). " + + WORKTREE_SCOPE_DETAIL, capabilities: { minVersion: 59, tokenFeature: "library" }, + worktree: "scoped", args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, }, outputSchema: Library, - examples: ["mb library get", "mb library get --json"], - async run({ ctx, getClient }) { + examples: [ + "mb library get", + "mb library get --json", + "mb library get --worktree feat/transforms", + ], + async run({ ctx, getClient, getWorktree }) { const client = await getClient(); - const library = await client.library.get(); + const scope = await getWorktree(); + const library = await client.library.get(scopeQuery(scope)); if (library === null) { throw new Error( "The Library has not been created yet — run `mb library create` (or publish a table with `mb library publish`).", diff --git a/packages/cli/src/commands/library/publish.ts b/packages/cli/src/commands/library/publish.ts index e7a709ba..89a7dfbc 100644 --- a/packages/cli/src/commands/library/publish.ts +++ b/packages/cli/src/commands/library/publish.ts @@ -31,6 +31,7 @@ export default defineMetabaseCommand({ details: "Sets each selected table and every upstream table it depends on into the Library Data collection, so they appear first in data pickers and rank up in search. The Library Data collection is resolved automatically (and the Library is created if it doesn't exist yet). Select with --table-ids, --db-ids, or --schemas (each schema id is \":\", e.g. 1:public); the filters are combined. Publishing does not add the Library Data collection to the git-sync scope — on an instance with remote sync configured, run `mb git-sync add-collection ` so exports carry the published tables' metadata.", capabilities: { minVersion: 59, tokenFeature: "library" }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/library/unpublish.ts b/packages/cli/src/commands/library/unpublish.ts index 46d027e8..6ace684b 100644 --- a/packages/cli/src/commands/library/unpublish.ts +++ b/packages/cli/src/commands/library/unpublish.ts @@ -33,6 +33,7 @@ export default defineMetabaseCommand({ details: 'Clears the Library collection for each selected table and recursively for every downstream table that depends on it. Select with --table-ids, --db-ids, or --schemas (each schema id is ":", e.g. 1:public); the filters are combined.', capabilities: { minVersion: 59, tokenFeature: "library" }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/measure/archive.ts b/packages/cli/src/commands/measure/archive.ts index 0045be39..531cee24 100644 --- a/packages/cli/src/commands/measure/archive.ts +++ b/packages/cli/src/commands/measure/archive.ts @@ -9,6 +9,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a measure by id" }, capabilities: { minVersion: 59 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/measure/create.ts b/packages/cli/src/commands/measure/create.ts index 4f28aef2..d367d39a 100644 --- a/packages/cli/src/commands/measure/create.ts +++ b/packages/cli/src/commands/measure/create.ts @@ -20,6 +20,7 @@ export default defineMetabaseCommand({ "A measure is a reusable, saved aggregation tied to a table. The JSON body needs `name`, `table_id`, and a `definition` (an MBQL query holding exactly one aggregation). An MBQL 5 `definition` is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass.", skills: [{ skill: "mbql", purpose: "the definition aggregation" }], capabilities: { minVersion: 59 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/measure/get.ts b/packages/cli/src/commands/measure/get.ts index 403ba035..2e50c247 100644 --- a/packages/cli/src/commands/measure/get.ts +++ b/packages/cli/src/commands/measure/get.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a measure by id" }, capabilities: { minVersion: 59 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/measure/list.ts b/packages/cli/src/commands/measure/list.ts index a8cb6351..d699ee51 100644 --- a/packages/cli/src/commands/measure/list.ts +++ b/packages/cli/src/commands/measure/list.ts @@ -11,6 +11,7 @@ export const MeasureListEnvelope = listEnvelopeSchema(MeasureCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List measures" }, capabilities: { minVersion: 59 }, + worktree: "any", args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: MeasureListEnvelope, examples: ["mb measure list", "mb measure list --json"], diff --git a/packages/cli/src/commands/measure/update.ts b/packages/cli/src/commands/measure/update.ts index 526b868f..5f1a4591 100644 --- a/packages/cli/src/commands/measure/update.ts +++ b/packages/cli/src/commands/measure/update.ts @@ -21,6 +21,7 @@ export default defineMetabaseCommand({ "Patches only the fields you send and must include `revision_message` (recorded in the audit log). When `definition` is an MBQL 5 query it is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass.", skills: [{ skill: "mbql", purpose: "the definition aggregation" }], capabilities: { minVersion: 59 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/query.ts b/packages/cli/src/commands/query.ts index 17b4adbc..acd61926 100644 --- a/packages/cli/src/commands/query.ts +++ b/packages/cli/src/commands/query.ts @@ -33,6 +33,7 @@ export default defineMetabaseCommand({ 'Reads a JSON query body from --body, --file, or stdin and runs it. MBQL 5 is Metabase\'s structured query format, shaped {"lib/type":"mbql/query", "database": , "stages": [...]}; it is checked against a bundled JSON Schema before sending — --print-schema prints that schema, and --dry-run reports any errors as {ok, errors:[{path, message}]} and exits 2 without sending. Legacy MBQL 4 and native-SQL bodies are not checked and run as-is.', skills: [{ skill: "mbql", purpose: "body shape, clause rules, and the dry-run loop" }], capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/runtime.test.ts b/packages/cli/src/commands/runtime.test.ts index fa64ef69..2d71c64c 100644 --- a/packages/cli/src/commands/runtime.test.ts +++ b/packages/cli/src/commands/runtime.test.ts @@ -16,8 +16,11 @@ vi.mock("@napi-rs/keyring", async () => { }); const { defineMetabaseCommand, SKIP_PREFLIGHT_ENV } = await import("./runtime"); -const { connectionFlags, listFlags, outputFlags, profileFlag } = await import("./flags"); -const { writeProbeResult, writeProfile } = await import("../core/auth/storage"); +const { connectionFlags, listFlags, outputFlags, profileFlag, worktreeFlag } = + await import("./flags"); +const { writeProbeResult, writeProfile, writeProfileWorktree } = + await import("../core/auth/storage"); +const { setVerbChain } = await import("../runtime/verb-chain"); async function seedProbedProfile(name: string, info: ServerInfo): Promise { await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }, name); @@ -50,11 +53,13 @@ describe("defineMetabaseCommand", () => { beforeEach(() => { hoisted.store.clear(); home = setupTempConfigHome(); + delete process.env["MB_WORKTREE"]; for (const name of ["URL", "API_KEY", "PROFILE"]) { delete process.env[`MB_${name}`]; delete process.env[`METABASE_${name}`]; } delete process.env[SKIP_PREFLIGHT_ENV]; + setVerbChain(null); previousExitCode = process.exitCode; process.exitCode = 0; }); @@ -62,6 +67,8 @@ describe("defineMetabaseCommand", () => { afterEach(() => { vi.restoreAllMocks(); home.cleanup(); + setVerbChain(null); + delete process.env["MB_WORKTREE"]; delete process.env[SKIP_PREFLIGHT_ENV]; process.exitCode = previousExitCode; }); @@ -72,6 +79,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo" }, capabilities: {}, + worktree: "any", args: { ...outputFlags, custom: { type: "string", description: "custom flag" } }, run({ args, ctx }) { observed(ctx.format, args.custom); @@ -87,6 +95,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "bare", description: "no opt-ins" }, capabilities: {}, + worktree: "any", args: {}, run({ ctx }) { observed(ctx.profile, ctx.url); @@ -104,6 +113,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "uses-client", description: "uses the client" }, capabilities: {}, + worktree: "any", args: { ...profileFlag }, async run({ getClient }) { const client = await getClient(); @@ -119,6 +129,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "no-client", description: "does not need the client" }, capabilities: {}, + worktree: "any", args: {}, run() { return; @@ -135,6 +146,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "cached", description: "client is cached" }, capabilities: {}, + worktree: "any", args: {}, async run({ getClient }) { first = await getClient(); @@ -149,6 +161,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "needs-creds", description: "needs creds" }, capabilities: {}, + worktree: "any", args: {}, async run({ getClient }) { await getClient(); @@ -176,6 +189,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "ranged", description: "takes a range" }, capabilities: {}, + worktree: "any", args: { ...outputFlags, ...listFlags }, run() { ran(); @@ -202,6 +216,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "capped", description: "takes a cap" }, capabilities: {}, + worktree: "any", args: { ...outputFlags }, run() { return; @@ -227,6 +242,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "misformatted", description: "bad format" }, capabilities: {}, + worktree: "any", args: { ...outputFlags }, run() { return; @@ -248,6 +264,7 @@ describe("defineMetabaseCommand", () => { meta: { name: "needs-v60", description: "wants v60" }, args: {}, capabilities: { minVersion: 60 }, + worktree: "any", async run({ getClient }) { await getClient(); ran(); @@ -272,6 +289,7 @@ describe("defineMetabaseCommand", () => { meta: { name: "needs-transforms", description: "wants transforms" }, args: {}, capabilities: { tokenFeature: "transforms" }, + worktree: "any", async run({ getClient }) { await getClient(); ran(); @@ -295,6 +313,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "no-caps", description: "no caps" }, capabilities: {}, + worktree: "any", args: {}, async run({ getClient }) { await getClient(); @@ -314,6 +333,7 @@ describe("defineMetabaseCommand", () => { meta: { name: "needs-v60-warn", description: "wants v60" }, args: {}, capabilities: { minVersion: 60 }, + worktree: "any", async run({ getClient }) { await getClient(); ran(); @@ -342,6 +362,7 @@ describe("defineMetabaseCommand", () => { meta: { name: "needs-v60-unknown", description: "wants v60" }, args: {}, capabilities: { minVersion: 60 }, + worktree: "any", async run({ getClient }) { await getClient(); ran(); @@ -364,6 +385,7 @@ describe("defineMetabaseCommand", () => { meta: { name: "skip-preflight-flag", description: "skip via flag" }, args: { ...connectionFlags }, capabilities: { minVersion: 99 }, + worktree: "any", async run({ getClient }) { await getClient(); ran(); @@ -374,6 +396,160 @@ describe("defineMetabaseCommand", () => { expect(ran).toHaveBeenCalledOnce(); }); + it("refuses a main-only command under an MB_WORKTREE scope before any request", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + process.env["MB_WORKTREE"] = "feat/transforms"; + setVerbChain("transform run"); + + const ran = vi.fn(); + const cmd = defineMetabaseCommand({ + meta: { name: "run", description: "runs main-app content" }, + capabilities: {}, + worktree: "main-only", + args: {}, + async run({ getClient }) { + await getClient(); + ran(); + }, + }); + const stderr = captureStderr(); + + await runCommand(cmd, { rawArgs: [] }); + + const parsed: unknown = JSON.parse(stderr.join("")); + expect(parsed).toEqual({ + ok: false, + error: { + category: "config", + message: + 'transform run is not available inside a worktree (scope: worktree "feat/transforms" ' + + "from MB_WORKTREE); it changes main-app content. Unpin the profile " + + "(`mb worktree unpin`) or drop MB_WORKTREE to run it against the main app.", + exitCode: 2, + }, + }); + expect(ran).not.toHaveBeenCalled(); + }); + + it("names the leaf in a main-only refusal when no verb chain was recorded", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + process.env["MB_WORKTREE"] = "feat/transforms"; + + const cmd = defineMetabaseCommand({ + meta: { name: "run", description: "runs main-app content" }, + capabilities: {}, + worktree: "main-only", + args: {}, + async run({ getClient }) { + await getClient(); + }, + }); + const stderr = captureStderr(); + + await runCommand(cmd, { rawArgs: [] }); + + const parsed: unknown = JSON.parse(stderr.join("")); + expect(parsed).toEqual({ + ok: false, + error: { + category: "config", + message: + 'run is not available inside a worktree (scope: worktree "feat/transforms" from ' + + "MB_WORKTREE); it changes main-app content. Unpin the profile (`mb worktree unpin`) or " + + "drop MB_WORKTREE to run it against the main app.", + exitCode: 2, + }, + }); + }); + + it("runs a main-only command untouched when nothing names a worktree", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + + const ran = vi.fn(); + const cmd = defineMetabaseCommand({ + meta: { name: "run", description: "runs main-app content" }, + capabilities: {}, + worktree: "main-only", + args: {}, + async run({ getClient }) { + await getClient(); + ran(); + }, + }); + + await runCommand(cmd, { rawArgs: [] }); + expect(ran).toHaveBeenCalledOnce(); + }); + + it("hands a scoped command the pinned worktree without contacting the server", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + await writeProfileWorktree("default", { id: 3, branch: "feat/transforms" }); + + const observed = vi.fn<(scope: unknown) => void>(); + const cmd = defineMetabaseCommand({ + meta: { name: "scoped", description: "honours a scope" }, + capabilities: {}, + worktree: "scoped", + args: { ...worktreeFlag }, + async run({ getWorktree }) { + observed(await getWorktree()); + }, + }); + + await runCommand(cmd, { rawArgs: [] }); + expect(observed).toHaveBeenCalledWith({ id: 3, branch: "feat/transforms" }); + }); + + it("hands a scoped command a null scope when nothing names a worktree", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + + const observed = vi.fn<(scope: unknown) => void>(); + const cmd = defineMetabaseCommand({ + meta: { name: "scoped", description: "honours a scope" }, + capabilities: {}, + worktree: "scoped", + args: { ...worktreeFlag }, + async run({ getWorktree }) { + observed(await getWorktree()); + }, + }); + + await runCommand(cmd, { rawArgs: [] }); + expect(observed).toHaveBeenCalledWith(null); + }); + + it("refuses a --worktree that names a worktree other than the profile's pin", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + await writeProfileWorktree("default", { id: 3, branch: "feat/transforms" }); + + const ran = vi.fn(); + const cmd = defineMetabaseCommand({ + meta: { name: "scoped", description: "honours a scope" }, + capabilities: {}, + worktree: "scoped", + args: { ...worktreeFlag }, + async run({ getWorktree }) { + await getWorktree(); + ran(); + }, + }); + const stderr = captureStderr(); + + await runCommand(cmd, { rawArgs: ["--worktree", "9"] }); + + const parsed: unknown = JSON.parse(stderr.join("")); + expect(parsed).toEqual({ + ok: false, + error: { + category: "config", + message: + 'profile "default" is pinned to worktree 3 (feat/transforms); refusing --worktree 9', + exitCode: 2, + }, + }); + expect(ran).not.toHaveBeenCalled(); + }); + it("bypasses the preflight check when MB_CLI_SKIP_PREFLIGHT=1 is set", async () => { await seedProbedProfile("default", fakeServerInfo(58)); process.env[SKIP_PREFLIGHT_ENV] = "1"; @@ -383,6 +559,7 @@ describe("defineMetabaseCommand", () => { meta: { name: "skip-preflight", description: "skip" }, args: {}, capabilities: { minVersion: 99 }, + worktree: "any", async run({ getClient }) { await getClient(); ran(); diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts index 63e5faad..eb135f53 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -9,6 +9,7 @@ import { mergeCapabilities, type Capabilities, } from "@metabase/client/version/capabilities"; +import { ConfigError } from "@metabase/client/errors"; import { CapabilityError } from "@metabase/client/version/preflight-error"; import { type ServerInfo } from "@metabase/client/version/probe"; @@ -25,12 +26,20 @@ import { type ConfigFlags, type ResolvedConfig, } from "../core/config"; -import { consumeLegacyEnvWarnings } from "../core/env"; +import { ENV_WORKTREE, consumeLegacyEnvWarnings, readEnv } from "../core/env"; import { USER_AGENT } from "../core/user-agent"; +import { + mainOnlyRefusal, + resolveScopeSource, + type WorktreePolicy, + type WorktreeScope, + type WorktreeScopeSource, +} from "../core/worktree-scope"; import { reportError } from "../output/error"; import { warn } from "../output/notice"; import { setMetabaseAugment, type SkillPointer } from "../runtime/command-augment"; import { interruptSignal } from "../runtime/interrupt"; +import { readVerbChain } from "../runtime/verb-chain"; import { resolveCommonFlags, resolveOutputFormat, @@ -38,15 +47,21 @@ import { type CommonContext, } from "./context"; import { assertKnownFlags } from "./known-flags"; +import { resolveWorktreeScope } from "./worktree-scope"; export { SKIP_PREFLIGHT_ENV }; +// A refusal reaches the user before any verb chain is recorded only when the command tree was never +// walked, which outside the entry point means a test driving one command definition directly. +const UNNAMED_COMMAND = "this command"; + interface MetabaseCommandContext { args: ParsedArgs; ctx: CommonContext; getClient: () => Promise; getResolvedConfig: () => Promise; getServerInfo: () => Promise; + getWorktree: () => Promise; } interface MetabaseCommandDef { @@ -58,6 +73,7 @@ interface MetabaseCommandDef { inputSchema?: ZodType; outputSchema?: ZodType; capabilities: Partial | null; + worktree: WorktreePolicy; run: (context: MetabaseCommandContext) => Promise | void; } @@ -115,11 +131,37 @@ export function defineMetabaseCommand( getServerInfo, ctx.skipPreflight, ); + let cachedScopeSource: Promise | null = null; + const getScopeSource = (): Promise => { + if (cachedScopeSource === null) { + cachedScopeSource = loadScopeSource(ctx, getResolvedConfig); + } + return cachedScopeSource; + }; + // Resolving the scope source is what enforces the pin lock, and the main-only refusal has + // to land before the command can reach the network. Both need no more than the pin and the + // environment, so they ride on `getClient`, which every server-touching command goes + // through and which has itself opened no socket yet. + const enforceWorktreePolicy = async (): Promise => { + const source = await getScopeSource(); + if (source === null || def.worktree !== "main-only") { + return; + } + throw new ConfigError(mainOnlyRefusal(commandName(def.meta), source)); + }; const getClient = async (): Promise => { const client = await rawGetClient(); + await enforceWorktreePolicy(); await enforcePreflight(); return client; }; + let cachedScope: Promise | null = null; + const getWorktree = (): Promise => { + if (cachedScope === null) { + cachedScope = loadWorktreeScope(getScopeSource, getClient); + } + return cachedScope; + }; try { await def.run({ args, @@ -127,6 +169,7 @@ export function defineMetabaseCommand( getClient, getResolvedConfig, getServerInfo, + getWorktree, }); } finally { emitPendingWarnings(); @@ -143,10 +186,48 @@ export function defineMetabaseCommand( inputSchema: def.inputSchema ?? null, outputSchema: def.outputSchema ?? null, capabilities: required, + worktree: def.worktree, }); return cmd; } +// The verb chain the user typed is what identifies the command; the leaf's own name is the fallback +// for a definition run outside the CLI entry. +function commandName(meta: CommandMeta): string { + const chain = readVerbChain(); + if (chain !== null) { + return chain; + } + return typeof meta.name === "string" && meta.name !== "" ? meta.name : UNNAMED_COMMAND; +} + +// The pin lives on the profile record, which is read straight off disk; resolving the config first +// is what names the profile whose pin applies. +async function loadScopeSource( + ctx: CommonContext, + getResolvedConfig: () => Promise, +): Promise { + const resolved = await getResolvedConfig(); + const record = await readProfileRecord(resolved.profile); + return resolveScopeSource({ + profile: resolved.profile, + flag: ctx.worktree, + env: readEnv(ENV_WORKTREE), + pin: record?.worktree ?? null, + }); +} + +async function loadWorktreeScope( + getScopeSource: () => Promise, + getClient: () => Promise, +): Promise { + const source = await getScopeSource(); + if (source === null) { + return null; + } + return resolveWorktreeScope(source, await getClient()); +} + function emitPendingWarnings(): void { for (const message of consumeLegacyEnvWarnings()) { warn(message); @@ -237,6 +318,9 @@ function pickCommonArgs(args: ParsedArgs): CommonArgs { if (typeof args["profile"] === "string") { out.profile = args["profile"]; } + if (typeof args["worktree"] === "string") { + out.worktree = args["worktree"]; + } if (typeof args["url"] === "string") { out.url = args["url"]; } diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index f4cde249..948addf9 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -5,9 +5,16 @@ import { listEnvelopeSchema } from "../output/types"; import { windowServerPage } from "../output/window"; import { parseEnumCsv } from "../runtime/csv"; -import { connectionFlags, listFlagsWithDefaultLimit, outputFlags, profileFlag } from "./flags"; +import { + connectionFlags, + listFlagsWithDefaultLimit, + outputFlags, + profileFlag, + worktreeFlag, +} from "./flags"; import { parseId } from "./parse-id"; import { defineMetabaseCommand } from "./runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "./worktree-scope"; // Unbounded, the server ranks and then hydrates up to `max-filtered-results` (1000) rows, running // the per-row `can_write` permission check on every one — a cost the output cap would then throw @@ -23,14 +30,17 @@ export default defineMetabaseCommand({ description: "Search Metabase content (cards, dashboards, collections, …)", }, details: - "Ranks content against a query string. To simply enumerate a resource, prefer its `… list` verb.", + "Ranks content against a query string. To simply enumerate a resource, prefer its `… list` verb. " + + WORKTREE_SCOPE_DETAIL, skills: [{ skill: "core", purpose: "search vs. list" }], capabilities: { minVersion: 58 }, + worktree: "scoped", args: { ...outputFlags, ...listFlagsWithDefaultLimit(DEFAULT_LIMIT), ...profileFlag, ...connectionFlags, + ...worktreeFlag, query: { type: "positional", description: "Search query string", @@ -60,14 +70,17 @@ export default defineMetabaseCommand({ "mb search orders", "mb search --models card,dashboard --limit 10 --json", "mb search products --archived", + "mb search orders --worktree feat/transforms", ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const tableDbIdRaw = args["db-id"]; const tableDbId = tableDbIdRaw ? parseId(tableDbIdRaw, "--db-id") : undefined; const models = parseEnumCsv(args.models, SearchModel, "--models"); const client = await getClient(); + const scope = await getWorktree(); const { data, total } = await client.search.query({ + ...scopeQuery(scope), q: nonEmpty(args.query), models, archived: args.archived ? true : undefined, diff --git a/packages/cli/src/commands/segment/archive.ts b/packages/cli/src/commands/segment/archive.ts index 2ba5bc09..b0dbf5fc 100644 --- a/packages/cli/src/commands/segment/archive.ts +++ b/packages/cli/src/commands/segment/archive.ts @@ -9,6 +9,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a segment by id" }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/segment/create.ts b/packages/cli/src/commands/segment/create.ts index 5b6cf24a..db8a86a7 100644 --- a/packages/cli/src/commands/segment/create.ts +++ b/packages/cli/src/commands/segment/create.ts @@ -20,6 +20,7 @@ export default defineMetabaseCommand({ "A segment is a reusable, saved row filter tied to a table. The JSON body needs `name`, `table_id`, and a `definition` (an MBQL query holding the filter). An MBQL 5 `definition` is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass.", skills: [{ skill: "mbql", purpose: "the definition filter clause" }], capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/segment/get.ts b/packages/cli/src/commands/segment/get.ts index a429843c..d0b6dabe 100644 --- a/packages/cli/src/commands/segment/get.ts +++ b/packages/cli/src/commands/segment/get.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a segment by id" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/segment/list.ts b/packages/cli/src/commands/segment/list.ts index 52075abd..5c7542f9 100644 --- a/packages/cli/src/commands/segment/list.ts +++ b/packages/cli/src/commands/segment/list.ts @@ -11,6 +11,7 @@ export const SegmentListEnvelope = listEnvelopeSchema(SegmentCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List segments" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: SegmentListEnvelope, examples: ["mb segment list", "mb segment list --json"], diff --git a/packages/cli/src/commands/segment/update.ts b/packages/cli/src/commands/segment/update.ts index a185f9bd..ed436eed 100644 --- a/packages/cli/src/commands/segment/update.ts +++ b/packages/cli/src/commands/segment/update.ts @@ -21,6 +21,7 @@ export default defineMetabaseCommand({ "Patches only the fields you send and must include `revision_message` (recorded in the audit log). When `definition` is an MBQL 5 query it is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass.", skills: [{ skill: "mbql", purpose: "the definition filter clause" }], capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/setting/get.ts b/packages/cli/src/commands/setting/get.ts index a8e8954e..45bad483 100644 --- a/packages/cli/src/commands/setting/get.ts +++ b/packages/cli/src/commands/setting/get.ts @@ -9,6 +9,7 @@ import { parseSettingKey } from "./key"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a setting value by key" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/setting/list.ts b/packages/cli/src/commands/setting/list.ts index f08b907b..50a41923 100644 --- a/packages/cli/src/commands/setting/list.ts +++ b/packages/cli/src/commands/setting/list.ts @@ -11,6 +11,7 @@ export const SettingListEnvelope = listEnvelopeSchema(SettingCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List all settings (admin)" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: SettingListEnvelope, examples: ["mb setting list", "mb setting list --json"], diff --git a/packages/cli/src/commands/setting/set.ts b/packages/cli/src/commands/setting/set.ts index 690f70ef..d585856c 100644 --- a/packages/cli/src/commands/setting/set.ts +++ b/packages/cli/src/commands/setting/set.ts @@ -16,6 +16,7 @@ const SettingValueInput = z export default defineMetabaseCommand({ meta: { name: "set", description: "Set a setting value (parsed strictly as JSON)" }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 2e6cd39b..b46c1ced 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -13,6 +13,7 @@ export default defineMetabaseCommand({ description: "Complete the initial Metabase setup wizard with a default user", }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/skills/get.ts b/packages/cli/src/commands/skills/get.ts index 2a6fce23..d0b6e03a 100644 --- a/packages/cli/src/commands/skills/get.ts +++ b/packages/cli/src/commands/skills/get.ts @@ -38,6 +38,7 @@ export default defineMetabaseCommand({ "Print one or more skills' SKILL.md content. Pass comma-separated names, or --all for every non-hidden skill. --full includes references and templates.", }, capabilities: null, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts index 6c59b5b2..28af602e 100644 --- a/packages/cli/src/commands/skills/list.ts +++ b/packages/cli/src/commands/skills/list.ts @@ -33,6 +33,7 @@ export default defineMetabaseCommand({ "List CLI-bundled skills — always consult the matching skill before acting on a task; they are the source of truth for every workflow.", }, capabilities: null, + worktree: "any", args: { ...outputFlags, ...listFlags }, outputSchema: SkillListEnvelope, examples: ["mb skills list", "mb skills list --json"], diff --git a/packages/cli/src/commands/skills/path.ts b/packages/cli/src/commands/skills/path.ts index 33c8e42b..f6f5a1b7 100644 --- a/packages/cli/src/commands/skills/path.ts +++ b/packages/cli/src/commands/skills/path.ts @@ -32,6 +32,7 @@ export default defineMetabaseCommand({ "Print the absolute path to a skill (or all skills). Useful when an agent needs to read the SKILL.md or its references with the Read tool directly.", }, capabilities: null, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/snippet/archive.ts b/packages/cli/src/commands/snippet/archive.ts index 436d0289..20cf306e 100644 --- a/packages/cli/src/commands/snippet/archive.ts +++ b/packages/cli/src/commands/snippet/archive.ts @@ -1,24 +1,33 @@ import { Snippet } from "@metabase/client/domain/snippet"; import { snippetView } from "../../output/views/snippet"; import { renderSummary } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { assertInWorktree, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a native query snippet by id" }, capabilities: { minVersion: 58 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, id: { type: "positional", description: "Snippet id", required: true }, }, outputSchema: Snippet, examples: ["mb snippet archive 1", "mb snippet archive 1 --json"], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const id = parseId(args.id); const client = await getClient(); + const scope = await getWorktree(); + if (scope !== null) { + const existing = await client.snippet.get(id); + assertInWorktree("snippet", id, existing.worktree_id, scope); + } const updated = await client.snippet.archive(id); renderSummary(updated, snippetView, `Archived snippet ${updated.id} "${updated.name}".`, ctx); }, diff --git a/packages/cli/src/commands/snippet/create.ts b/packages/cli/src/commands/snippet/create.ts index d7e6e999..3a9eedd0 100644 --- a/packages/cli/src/commands/snippet/create.ts +++ b/packages/cli/src/commands/snippet/create.ts @@ -3,16 +3,20 @@ import { snippetView } from "../../output/views/snippet"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeBody, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { name: "create", description: "Create a native query snippet from a JSON spec" }, capabilities: { minVersion: 58 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, ...bodyInputFlags, }, inputSchema: SnippetCreateInput, @@ -22,10 +26,11 @@ export default defineMetabaseCommand({ "mb snippet create --file snippet.json", 'mb snippet create --body \'{"name":"active","content":"WHERE active = true"}\'', ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const body = await readBody({ flag: args.body, file: args.file }, SnippetCreateInput); const client = await getClient(); - const created = await client.snippet.create(body); + const scope = await getWorktree(); + const created = await client.snippet.create(scopeBody(body, scope)); renderSummary(created, snippetView, `Created snippet ${created.id} "${created.name}".`, ctx); }, }); diff --git a/packages/cli/src/commands/snippet/get.ts b/packages/cli/src/commands/snippet/get.ts index 056886f6..5244c6a0 100644 --- a/packages/cli/src/commands/snippet/get.ts +++ b/packages/cli/src/commands/snippet/get.ts @@ -1,25 +1,35 @@ import { Snippet } from "@metabase/client/domain/snippet"; import { snippetView } from "../../output/views/snippet"; import { renderItem } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { assertInWorktree, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a native query snippet by id" }, capabilities: { minVersion: 58 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, id: { type: "positional", description: "Snippet id", required: true }, }, outputSchema: Snippet, - examples: ["mb snippet get 1", "mb snippet get 1 --json"], - async run({ args, ctx, getClient }) { + examples: [ + "mb snippet get 1", + "mb snippet get 1 --json", + "mb snippet get 1 --worktree feat/transforms", + ], + async run({ args, ctx, getClient, getWorktree }) { const id = parseId(args.id); const client = await getClient(); + const scope = await getWorktree(); const snippet = await client.snippet.get(id); + assertInWorktree("snippet", id, snippet.worktree_id, scope); renderItem(snippet, snippetView, ctx); }, }); diff --git a/packages/cli/src/commands/snippet/list.ts b/packages/cli/src/commands/snippet/list.ts index c8b176cc..9a6e5798 100644 --- a/packages/cli/src/commands/snippet/list.ts +++ b/packages/cli/src/commands/snippet/list.ts @@ -3,26 +3,39 @@ import { snippetView } from "../../output/views/snippet"; import { renderList } from "../../output/render"; import { listEnvelopeSchema } from "../../output/types"; import { windowList } from "../../output/window"; -import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, listFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export const SnippetListEnvelope = listEnvelopeSchema(SnippetCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List native query snippets" }, capabilities: { minVersion: 58 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, archived: { type: "boolean", description: "Show archived snippets instead of active ones" }, }, outputSchema: SnippetListEnvelope, - examples: ["mb snippet list", "mb snippet list --json", "mb snippet list --archived --json"], - async run({ args, ctx, getClient }) { + examples: [ + "mb snippet list", + "mb snippet list --json", + "mb snippet list --archived --json", + "mb snippet list --worktree feat/transforms", + ], + async run({ args, ctx, getClient, getWorktree }) { const client = await getClient(); - const { data, total } = await client.snippet.list({ archived: args.archived || undefined }); + const scope = await getWorktree(); + const { data, total } = await client.snippet.list({ + archived: args.archived || undefined, + ...scopeQuery(scope), + }); renderList(windowList(data, ctx.range, total), snippetView, ctx); }, }); diff --git a/packages/cli/src/commands/snippet/update.ts b/packages/cli/src/commands/snippet/update.ts index 953cff79..d6f5ef10 100644 --- a/packages/cli/src/commands/snippet/update.ts +++ b/packages/cli/src/commands/snippet/update.ts @@ -3,17 +3,21 @@ import { snippetView } from "../../output/views/snippet"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { assertInWorktree, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { name: "update", description: "Update a native query snippet by id" }, capabilities: { minVersion: 58 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, ...bodyInputFlags, id: { type: "positional", description: "Snippet id", required: true }, }, @@ -25,10 +29,15 @@ export default defineMetabaseCommand({ 'mb snippet update 1 --body \'{"name":"renamed"}\'', "mb snippet update 1 --body '{\"archived\":true}'", ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, SnippetUpdateInput); const client = await getClient(); + const scope = await getWorktree(); + if (scope !== null) { + const existing = await client.snippet.get(id); + assertInWorktree("snippet", id, existing.worktree_id, scope); + } const updated = await client.snippet.update(id, body); renderSummary(updated, snippetView, `Updated snippet ${updated.id} "${updated.name}".`, ctx); }, diff --git a/packages/cli/src/commands/subscription/archive.ts b/packages/cli/src/commands/subscription/archive.ts index 0c096b5f..7481f4b2 100644 --- a/packages/cli/src/commands/subscription/archive.ts +++ b/packages/cli/src/commands/subscription/archive.ts @@ -13,6 +13,7 @@ export default defineMetabaseCommand({ details: "Archiving also disables every channel on the subscription. Restore it with `mb subscription update --body '{\"archived\":false}'`, then re-enable its channels.", capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/subscription/create.ts b/packages/cli/src/commands/subscription/create.ts index cacf6c0b..2db43601 100644 --- a/packages/cli/src/commands/subscription/create.ts +++ b/packages/cli/src/commands/subscription/create.ts @@ -16,6 +16,7 @@ export default defineMetabaseCommand({ 'Email recipients are `[{email: "a@b.com"} | {id: }]`; Slack targets a channel via `details: {channel: "#general"}`.', ].join(" "), capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/subscription/get.ts b/packages/cli/src/commands/subscription/get.ts index 0deb35bd..5c593872 100644 --- a/packages/cli/src/commands/subscription/get.ts +++ b/packages/cli/src/commands/subscription/get.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a dashboard subscription by id" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/subscription/list.ts b/packages/cli/src/commands/subscription/list.ts index bfdf346d..ff7b1445 100644 --- a/packages/cli/src/commands/subscription/list.ts +++ b/packages/cli/src/commands/subscription/list.ts @@ -12,6 +12,7 @@ export const SubscriptionListEnvelope = listEnvelopeSchema(PulseCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List dashboard subscriptions" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/subscription/update.ts b/packages/cli/src/commands/subscription/update.ts index f39d6fd9..37058450 100644 --- a/packages/cli/src/commands/subscription/update.ts +++ b/packages/cli/src/commands/subscription/update.ts @@ -16,6 +16,7 @@ export default defineMetabaseCommand({ "A subscription's `dashboard_id` and `collection_id` are fixed at creation and cannot be changed.", ].join(" "), capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/table/fields.ts b/packages/cli/src/commands/table/fields.ts index d347b0f4..ffdbdd2e 100644 --- a/packages/cli/src/commands/table/fields.ts +++ b/packages/cli/src/commands/table/fields.ts @@ -16,6 +16,7 @@ export default defineMetabaseCommand({ description: "List fields on a table (projection over query_metadata.fields)", }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/table/get.ts b/packages/cli/src/commands/table/get.ts index 9504f16b..90339fb0 100644 --- a/packages/cli/src/commands/table/get.ts +++ b/packages/cli/src/commands/table/get.ts @@ -14,6 +14,7 @@ export default defineMetabaseCommand({ description: "Get a table by id; pass --include fields to bundle hydrated fields", }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/table/list.ts b/packages/cli/src/commands/table/list.ts index 05534db9..610670e7 100644 --- a/packages/cli/src/commands/table/list.ts +++ b/packages/cli/src/commands/table/list.ts @@ -12,6 +12,7 @@ export const TableListEnvelope = listEnvelopeSchema(TableCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List tables (optionally filtered by database)" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/table/update.ts b/packages/cli/src/commands/table/update.ts index f6e10cfe..5e986839 100644 --- a/packages/cli/src/commands/table/update.ts +++ b/packages/cli/src/commands/table/update.ts @@ -13,6 +13,7 @@ export default defineMetabaseCommand({ description: "Update a table (display name, description, visibility, etc.)", }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline-event/archive.ts b/packages/cli/src/commands/timeline-event/archive.ts index 8a86e30a..b6dfeb93 100644 --- a/packages/cli/src/commands/timeline-event/archive.ts +++ b/packages/cli/src/commands/timeline-event/archive.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a timeline event by id" }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline-event/create.ts b/packages/cli/src/commands/timeline-event/create.ts index 63aba116..fa2f079a 100644 --- a/packages/cli/src/commands/timeline-event/create.ts +++ b/packages/cli/src/commands/timeline-event/create.ts @@ -11,6 +11,7 @@ export default defineMetabaseCommand({ details: "The JSON body needs `name`, `timestamp` (ISO 8601), `timezone` (IANA name like UTC or America/New_York), `time_matters` (true when the time of day is significant, false when only the date is), and `timeline_id`; optional fields: `description`, `icon` (star|cake|mail|warning|bell|cloud, default: the timeline's icon).", capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline-event/delete.ts b/packages/cli/src/commands/timeline-event/delete.ts index db239e87..c810e5ff 100644 --- a/packages/cli/src/commands/timeline-event/delete.ts +++ b/packages/cli/src/commands/timeline-event/delete.ts @@ -6,6 +6,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "delete", description: "Permanently delete a timeline event by id" }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline-event/get.ts b/packages/cli/src/commands/timeline-event/get.ts index 312641a6..c11ecdb8 100644 --- a/packages/cli/src/commands/timeline-event/get.ts +++ b/packages/cli/src/commands/timeline-event/get.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a timeline event by id" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline-event/update.ts b/packages/cli/src/commands/timeline-event/update.ts index f80a62c9..426271a0 100644 --- a/packages/cli/src/commands/timeline-event/update.ts +++ b/packages/cli/src/commands/timeline-event/update.ts @@ -12,6 +12,7 @@ export default defineMetabaseCommand({ details: "Patches only the fields you send: `name`, `description`, `timestamp`, `timezone`, `time_matters`, `icon`, `timeline_id` (moves the event to another timeline), `archived`.", capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline/archive.ts b/packages/cli/src/commands/timeline/archive.ts index aa32f6a1..ff15bf3e 100644 --- a/packages/cli/src/commands/timeline/archive.ts +++ b/packages/cli/src/commands/timeline/archive.ts @@ -11,6 +11,7 @@ export default defineMetabaseCommand({ description: "Archive (soft-delete) a timeline and all its events by id", }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline/create.ts b/packages/cli/src/commands/timeline/create.ts index b63e5708..b92e9e93 100644 --- a/packages/cli/src/commands/timeline/create.ts +++ b/packages/cli/src/commands/timeline/create.ts @@ -11,6 +11,7 @@ export default defineMetabaseCommand({ details: "The JSON body needs `name`; optional fields: `description`, `icon` (star|cake|mail|warning|bell|cloud, default star), `collection_id` (null = root collection), `default`.", capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline/delete.ts b/packages/cli/src/commands/timeline/delete.ts index 51fe9c99..63ff90d5 100644 --- a/packages/cli/src/commands/timeline/delete.ts +++ b/packages/cli/src/commands/timeline/delete.ts @@ -9,6 +9,7 @@ export default defineMetabaseCommand({ description: "Permanently delete a timeline and all its events by id", }, capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline/events.ts b/packages/cli/src/commands/timeline/events.ts index 8c0c847a..e9671f93 100644 --- a/packages/cli/src/commands/timeline/events.ts +++ b/packages/cli/src/commands/timeline/events.ts @@ -12,6 +12,7 @@ export const TimelineEventListEnvelope = listEnvelopeSchema(TimelineEventCompact export default defineMetabaseCommand({ meta: { name: "events", description: "List events on a timeline" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/timeline/get.ts b/packages/cli/src/commands/timeline/get.ts index 0911aa63..37d36595 100644 --- a/packages/cli/src/commands/timeline/get.ts +++ b/packages/cli/src/commands/timeline/get.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a timeline by id" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline/list.ts b/packages/cli/src/commands/timeline/list.ts index 531a1d07..0160c783 100644 --- a/packages/cli/src/commands/timeline/list.ts +++ b/packages/cli/src/commands/timeline/list.ts @@ -11,6 +11,7 @@ export const TimelineListEnvelope = listEnvelopeSchema(TimelineCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List timelines" }, capabilities: { minVersion: 58 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/timeline/update.ts b/packages/cli/src/commands/timeline/update.ts index d2309b5a..748bede2 100644 --- a/packages/cli/src/commands/timeline/update.ts +++ b/packages/cli/src/commands/timeline/update.ts @@ -12,6 +12,7 @@ export default defineMetabaseCommand({ details: "Patches only the fields you send: `name`, `description`, `icon`, `collection_id`, `default`, `archived`. Changing `archived` cascades to every event on the timeline.", capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform-job/create.ts b/packages/cli/src/commands/transform-job/create.ts index 102edb0a..42640855 100644 --- a/packages/cli/src/commands/transform-job/create.ts +++ b/packages/cli/src/commands/transform-job/create.ts @@ -9,6 +9,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "create", description: "Create a transform job" }, capabilities: { minVersion: 59 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, inputSchema: TransformJobCreateInput, outputSchema: TransformJob, diff --git a/packages/cli/src/commands/transform-job/delete.ts b/packages/cli/src/commands/transform-job/delete.ts index 3307556a..64cf9459 100644 --- a/packages/cli/src/commands/transform-job/delete.ts +++ b/packages/cli/src/commands/transform-job/delete.ts @@ -6,6 +6,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "delete", description: "Delete a transform job by id" }, capabilities: { minVersion: 59 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform-job/get.ts b/packages/cli/src/commands/transform-job/get.ts index 5eb360af..a4ce3d09 100644 --- a/packages/cli/src/commands/transform-job/get.ts +++ b/packages/cli/src/commands/transform-job/get.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a transform job by id" }, capabilities: { minVersion: 59 }, + worktree: "any", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform-job/list.ts b/packages/cli/src/commands/transform-job/list.ts index 3ec4ba35..d64d03c7 100644 --- a/packages/cli/src/commands/transform-job/list.ts +++ b/packages/cli/src/commands/transform-job/list.ts @@ -11,6 +11,7 @@ export const TransformJobListEnvelope = listEnvelopeSchema(TransformJobCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List transform jobs" }, capabilities: { minVersion: 59 }, + worktree: "any", args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: TransformJobListEnvelope, examples: ["mb transform-job list", "mb transform-job list --json"], diff --git a/packages/cli/src/commands/transform-job/run.ts b/packages/cli/src/commands/transform-job/run.ts index c420bd18..0ce1943d 100644 --- a/packages/cli/src/commands/transform-job/run.ts +++ b/packages/cli/src/commands/transform-job/run.ts @@ -23,6 +23,7 @@ export default defineMetabaseCommand({ details: "Starts the job and returns immediately. The job runs every transform carrying one of its tags, plus those transforms' dependencies. Dependencies that are already fresh are skipped by default; --force-refresh re-runs the whole plan including them.", capabilities: { minVersion: 59 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform-job/set-active.ts b/packages/cli/src/commands/transform-job/set-active.ts index 838264b2..5e47ff1b 100644 --- a/packages/cli/src/commands/transform-job/set-active.ts +++ b/packages/cli/src/commands/transform-job/set-active.ts @@ -26,6 +26,7 @@ export default defineMetabaseCommand({ details: "Flips the active flag on every transform job at once. Inactive jobs do not run on schedule; manual runs ignore the flag. Requires admin.", capabilities: { minVersion: 61 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform-job/transforms.ts b/packages/cli/src/commands/transform-job/transforms.ts index 605a04b9..5ed8113a 100644 --- a/packages/cli/src/commands/transform-job/transforms.ts +++ b/packages/cli/src/commands/transform-job/transforms.ts @@ -14,6 +14,7 @@ export default defineMetabaseCommand({ details: "Resolves the transforms a job would execute, matched by the job's tags. The positional id is a JOB id, not a transform id.", capabilities: { minVersion: 59 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/transform-job/update.ts b/packages/cli/src/commands/transform-job/update.ts index f3d38bb4..1058505c 100644 --- a/packages/cli/src/commands/transform-job/update.ts +++ b/packages/cli/src/commands/transform-job/update.ts @@ -10,6 +10,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "update", description: "Update a transform job by id" }, capabilities: { minVersion: 59 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform-tag/create.ts b/packages/cli/src/commands/transform-tag/create.ts index 3fa474c2..2711c415 100644 --- a/packages/cli/src/commands/transform-tag/create.ts +++ b/packages/cli/src/commands/transform-tag/create.ts @@ -3,24 +3,35 @@ import { transformTagView } from "../../output/views/transform-tag"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeBody, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { name: "create", description: "Create a transform tag" }, capabilities: { minVersion: 59 }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...worktreeFlag, + ...bodyInputFlags, + }, inputSchema: TransformTagCreateInput, outputSchema: TransformTag, examples: [ 'mb transform-tag create --body \'{"name":"nightly"}\'', "mb transform-tag create --file tag.json", 'echo \'{"name":"nightly"}\' | mb transform-tag create', + 'mb transform-tag create --body \'{"name":"nightly"}\' --worktree feat/transforms', ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const body = await readBody({ flag: args.body, file: args.file }, TransformTagCreateInput); const client = await getClient(); - const created = await client.transformTag.create(body); + const scope = await getWorktree(); + const created = await client.transformTag.create(scopeBody(body, scope)); renderSummary( created, transformTagView, diff --git a/packages/cli/src/commands/transform-tag/delete.ts b/packages/cli/src/commands/transform-tag/delete.ts index 25e7b6ee..d43187f0 100644 --- a/packages/cli/src/commands/transform-tag/delete.ts +++ b/packages/cli/src/commands/transform-tag/delete.ts @@ -1,23 +1,30 @@ import { confirmAndDelete, DeleteResult } from "../delete-runtime"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; + +import { assertTagInWorktree } from "./scope"; export default defineMetabaseCommand({ meta: { name: "delete", description: "Delete a transform tag by id" }, capabilities: { minVersion: 59 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, yes: { type: "boolean", description: "Skip confirmation", default: false }, id: { type: "positional", description: "Transform tag id", required: true }, }, outputSchema: DeleteResult, examples: ["mb transform-tag delete 5 --yes", "mb transform-tag delete 5"], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const id = parseId(args.id); const client = await getClient(); + await assertTagInWorktree(client, id, await getWorktree()); await confirmAndDelete({ id, yes: args.yes, diff --git a/packages/cli/src/commands/transform-tag/list.ts b/packages/cli/src/commands/transform-tag/list.ts index 0fc42bb0..924abde8 100644 --- a/packages/cli/src/commands/transform-tag/list.ts +++ b/packages/cli/src/commands/transform-tag/list.ts @@ -3,20 +3,28 @@ import { transformTagView } from "../../output/views/transform-tag"; import { renderList } from "../../output/render"; import { listEnvelopeSchema } from "../../output/types"; import { windowList } from "../../output/window"; -import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, listFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export const TransformTagListEnvelope = listEnvelopeSchema(TransformTagCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List transform tags" }, capabilities: { minVersion: 59 }, - args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags, ...worktreeFlag }, outputSchema: TransformTagListEnvelope, - examples: ["mb transform-tag list", "mb transform-tag list --json"], - async run({ ctx, getClient }) { + examples: [ + "mb transform-tag list", + "mb transform-tag list --json", + "mb transform-tag list --worktree feat/transforms", + ], + async run({ ctx, getClient, getWorktree }) { const client = await getClient(); - const { data } = await client.transformTag.list(); + const scope = await getWorktree(); + const { data } = await client.transformTag.list(scopeQuery(scope)); renderList(windowList(data, ctx.range), transformTagView, ctx); }, }); diff --git a/packages/cli/src/commands/transform-tag/scope.ts b/packages/cli/src/commands/transform-tag/scope.ts new file mode 100644 index 00000000..e02db098 --- /dev/null +++ b/packages/cli/src/commands/transform-tag/scope.ts @@ -0,0 +1,20 @@ +import type { MetabaseClient } from "@metabase/client/client"; + +import type { WorktreeScope } from "../../core/worktree-scope"; +import { assertInWorktree } from "../worktree-scope"; + +const TRANSFORM_TAG_KIND = "transform tag"; + +// Tags have no get-by-id endpoint, so the scope's own listing is what says whether a tag is in it. +export async function assertTagInWorktree( + client: MetabaseClient, + id: number, + scope: WorktreeScope | null, +): Promise { + if (scope === null) { + return; + } + const { data } = await client.transformTag.list({ "worktree-id": scope.id }); + const match = data.find((tag) => tag.id === id); + assertInWorktree(TRANSFORM_TAG_KIND, id, match === undefined ? null : match.worktree_id, scope); +} diff --git a/packages/cli/src/commands/transform-tag/update.ts b/packages/cli/src/commands/transform-tag/update.ts index 38049994..2cafaf5d 100644 --- a/packages/cli/src/commands/transform-tag/update.ts +++ b/packages/cli/src/commands/transform-tag/update.ts @@ -3,17 +3,23 @@ import { transformTagView } from "../../output/views/transform-tag"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; + +import { assertTagInWorktree } from "./scope"; export default defineMetabaseCommand({ meta: { name: "update", description: "Update a transform tag by id" }, capabilities: { minVersion: 59 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, ...bodyInputFlags, id: { type: "positional", description: "Transform tag id", required: true }, }, @@ -24,10 +30,11 @@ export default defineMetabaseCommand({ "mb transform-tag update 5 --file tag.json", 'echo \'{"name":"renamed"}\' | mb transform-tag update 5', ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, TransformTagUpdateInput); const client = await getClient(); + await assertTagInWorktree(client, id, await getWorktree()); const updated = await client.transformTag.update(id, body); renderSummary( updated, diff --git a/packages/cli/src/commands/transform/cancel.ts b/packages/cli/src/commands/transform/cancel.ts index 62605aff..12bd2dd3 100644 --- a/packages/cli/src/commands/transform/cancel.ts +++ b/packages/cli/src/commands/transform/cancel.ts @@ -23,6 +23,7 @@ const transformCancelView: ResourceView = { export default defineMetabaseCommand({ meta: { name: "cancel", description: "Cancel the current run for a transform" }, capabilities: { minVersion: 59 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform/create.ts b/packages/cli/src/commands/transform/create.ts index ee6df658..fb3a1345 100644 --- a/packages/cli/src/commands/transform/create.ts +++ b/packages/cli/src/commands/transform/create.ts @@ -3,8 +3,9 @@ import { transformView } from "../../output/views/transform"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeBody, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; import { TRANSFORM_SOURCE_QUERY_LABELS, preflightMbql5Query, @@ -19,13 +20,16 @@ export default defineMetabaseCommand({ description: "Create a transform from JSON", }, details: - "The JSON body needs a `name`, a `source` (the query to run — native SQL or MBQL — under `source.query`), and a `target` (the warehouse table to write, with `database`/`schema`/`name`). When `source.query` is an MBQL 5 query it is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass.", + "The JSON body needs a `name`, a `source` (the query to run — native SQL or MBQL — under `source.query`), and a `target` (the warehouse table to write, with `database`/`schema`/`name`). When `source.query` is an MBQL 5 query it is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass. " + + WORKTREE_SCOPE_DETAIL, skills: [{ skill: "mbql", purpose: "MBQL source.query bodies" }], capabilities: { minVersion: 59 }, + worktree: "scoped", args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, ...bodyInputFlags, ...skipValidateFlag, }, @@ -35,8 +39,9 @@ export default defineMetabaseCommand({ "cat transform.json | mb transform create", "mb transform create --file transform.json", "mb transform create --file transform.json --skip-validate", + "mb transform create --file transform.json --worktree feat/transforms", ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const body = await readBody({ flag: args.body, file: args.file }, TransformCreateInput); if (body.source.type === "query") { preflightMbql5Query(body.source.query, TRANSFORM_SOURCE_QUERY_LABELS, { @@ -44,9 +49,12 @@ export default defineMetabaseCommand({ }); } const client = await getClient(); - const created = await client.transform.create(body).catch((error: unknown) => { - throw enrichTransformCollectionError(error); - }); + const scope = await getWorktree(); + const created = await client.transform + .create(scopeBody(body, scope)) + .catch((error: unknown) => { + throw enrichTransformCollectionError(error); + }); renderSummary( created, transformView, diff --git a/packages/cli/src/commands/transform/delete-table.ts b/packages/cli/src/commands/transform/delete-table.ts index eae221d0..a431d730 100644 --- a/packages/cli/src/commands/transform/delete-table.ts +++ b/packages/cli/src/commands/transform/delete-table.ts @@ -1,7 +1,8 @@ import { confirmAndDelete, DeleteResult } from "../delete-runtime"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { assertInWorktree, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { @@ -9,18 +10,26 @@ export default defineMetabaseCommand({ description: "Drop a transform's materialized output table (keeps the transform definition)", }, capabilities: { minVersion: 59 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, yes: { type: "boolean", description: "Skip confirmation", default: false }, id: { type: "positional", description: "Transform id", required: true }, }, outputSchema: DeleteResult, examples: ["mb transform delete-table 1 --yes", "mb transform delete-table 1"], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const id = parseId(args.id); const client = await getClient(); + const scope = await getWorktree(); + if (scope !== null) { + const existing = await client.transform.get(id); + assertInWorktree("transform", id, existing.worktree_id, scope); + } await confirmAndDelete({ id, yes: args.yes, diff --git a/packages/cli/src/commands/transform/delete.ts b/packages/cli/src/commands/transform/delete.ts index ff033a8e..4ddfef95 100644 --- a/packages/cli/src/commands/transform/delete.ts +++ b/packages/cli/src/commands/transform/delete.ts @@ -1,23 +1,32 @@ import { confirmAndDelete, DeleteResult } from "../delete-runtime"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { assertInWorktree, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { name: "delete", description: "Delete a transform by id" }, capabilities: { minVersion: 59 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, yes: { type: "boolean", description: "Skip confirmation", default: false }, id: { type: "positional", description: "Transform id", required: true }, }, outputSchema: DeleteResult, examples: ["mb transform delete 1 --yes", "mb transform delete 1"], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const id = parseId(args.id); const client = await getClient(); + const scope = await getWorktree(); + if (scope !== null) { + const existing = await client.transform.get(id); + assertInWorktree("transform", id, existing.worktree_id, scope); + } await confirmAndDelete({ id, yes: args.yes, diff --git a/packages/cli/src/commands/transform/dependencies.ts b/packages/cli/src/commands/transform/dependencies.ts index 4c7bf0a8..4c36a536 100644 --- a/packages/cli/src/commands/transform/dependencies.ts +++ b/packages/cli/src/commands/transform/dependencies.ts @@ -3,29 +3,38 @@ import { transformView } from "../../output/views/transform"; import { renderList } from "../../output/render"; import { listEnvelopeSchema } from "../../output/types"; import { windowList } from "../../output/window"; -import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, listFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { assertInWorktree, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export const TransformDependenciesEnvelope = listEnvelopeSchema(TransformCompact); export default defineMetabaseCommand({ meta: { name: "dependencies", description: "List the transforms a transform depends on" }, details: - "Returns the upstream transforms in this transform's dependency graph — the ones that must run before it. The positional id is a transform id.", + "Returns the upstream transforms in this transform's dependency graph — the ones that must run before it. The positional id is a transform id. " + + WORKTREE_SCOPE_DETAIL, capabilities: { minVersion: 59 }, + worktree: "scoped", args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, id: { type: "positional", description: "Transform id", required: true }, }, outputSchema: TransformDependenciesEnvelope, examples: ["mb transform dependencies 1", "mb transform dependencies 1 --json"], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const id = parseId(args.id); const client = await getClient(); + const scope = await getWorktree(); + if (scope !== null) { + const transform = await client.transform.get(id); + assertInWorktree("transform", id, transform.worktree_id, scope); + } const { data } = await client.transform.dependencies(id); renderList(windowList(data, ctx.range), transformView, ctx); }, diff --git a/packages/cli/src/commands/transform/get-run.ts b/packages/cli/src/commands/transform/get-run.ts index 376256d4..ab85ea8e 100644 --- a/packages/cli/src/commands/transform/get-run.ts +++ b/packages/cli/src/commands/transform/get-run.ts @@ -8,6 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get-run", description: "Get a transform run by run id (not the transform id)" }, capabilities: { minVersion: 59 }, + worktree: "any", details: "The positional id is a RUN id (from `transform runs`), not a transform id. To fetch a transform itself, use `transform get `.", args: { diff --git a/packages/cli/src/commands/transform/get.ts b/packages/cli/src/commands/transform/get.ts index 8585e840..a2c0fb7d 100644 --- a/packages/cli/src/commands/transform/get.ts +++ b/packages/cli/src/commands/transform/get.ts @@ -1,25 +1,35 @@ import { Transform } from "@metabase/client/domain/transform"; import { transformView } from "../../output/views/transform"; import { renderItem } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { assertInWorktree, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a transform by id" }, capabilities: { minVersion: 59 }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, id: { type: "positional", description: "Transform id", required: true }, }, outputSchema: Transform, - examples: ["mb transform get 1", "mb transform get 1 --json"], - async run({ args, ctx, getClient }) { + examples: [ + "mb transform get 1", + "mb transform get 1 --json", + "mb transform get 1 --worktree feat/transforms", + ], + async run({ args, ctx, getClient, getWorktree }) { const id = parseId(args.id); const client = await getClient(); + const scope = await getWorktree(); const transform = await client.transform.get(id); + assertInWorktree("transform", id, transform.worktree_id, scope); renderItem(transform, transformView, ctx); }, }); diff --git a/packages/cli/src/commands/transform/list.ts b/packages/cli/src/commands/transform/list.ts index c99c647f..1a901c7c 100644 --- a/packages/cli/src/commands/transform/list.ts +++ b/packages/cli/src/commands/transform/list.ts @@ -3,20 +3,28 @@ import { transformView } from "../../output/views/transform"; import { renderList } from "../../output/render"; import { listEnvelopeSchema } from "../../output/types"; import { windowList } from "../../output/window"; -import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, listFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { scopeQuery, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; export const TransformListEnvelope = listEnvelopeSchema(TransformCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List transforms" }, capabilities: { minVersion: 59 }, - args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + worktree: "scoped", + details: WORKTREE_SCOPE_DETAIL, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags, ...worktreeFlag }, outputSchema: TransformListEnvelope, - examples: ["mb transform list", "mb transform list --json"], - async run({ ctx, getClient }) { + examples: [ + "mb transform list", + "mb transform list --json", + "mb transform list --worktree feat/transforms", + ], + async run({ ctx, getClient, getWorktree }) { const client = await getClient(); - const { data } = await client.transform.list(); + const scope = await getWorktree(); + const { data } = await client.transform.list(scopeQuery(scope)); renderList(windowList(data, ctx.range), transformView, ctx); }, }); diff --git a/packages/cli/src/commands/transform/run.ts b/packages/cli/src/commands/transform/run.ts index a2e7a613..86d79f6e 100644 --- a/packages/cli/src/commands/transform/run.ts +++ b/packages/cli/src/commands/transform/run.ts @@ -21,6 +21,7 @@ export default defineMetabaseCommand({ details: "Starts a run and returns immediately. --wait polls the run to a terminal status. --sync additionally waits until the run's output table is registered and returns its `target_table_id`, so you can build MBQL cards against it — the run registers the table itself, so no separate `db sync-schema` is needed; --sync implies waiting for the run.", capabilities: { minVersion: 59 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform/runs.ts b/packages/cli/src/commands/transform/runs.ts index bd40963b..1ed52937 100644 --- a/packages/cli/src/commands/transform/runs.ts +++ b/packages/cli/src/commands/transform/runs.ts @@ -13,6 +13,7 @@ export const TransformRunListEnvelope = listEnvelopeSchema(TransformRunCompact); export default defineMetabaseCommand({ meta: { name: "runs", description: "List recent transform runs" }, capabilities: { minVersion: 59 }, + worktree: "any", args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/transform/update.ts b/packages/cli/src/commands/transform/update.ts index 74e4c9bf..177e89b4 100644 --- a/packages/cli/src/commands/transform/update.ts +++ b/packages/cli/src/commands/transform/update.ts @@ -3,9 +3,10 @@ import { transformView } from "../../output/views/transform"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, outputFlags, profileFlag, worktreeFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { assertInWorktree, WORKTREE_SCOPE_DETAIL } from "../worktree-scope"; import { TRANSFORM_SOURCE_QUERY_LABELS, preflightMbql5Query, @@ -20,13 +21,16 @@ export default defineMetabaseCommand({ description: "Update a transform by id (partial)", }, details: - "Patches only the fields you send (any of `name`, `source`, `target`, `tag_ids`, …). When a new `source.query` is an MBQL 5 query it is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass.", + "Patches only the fields you send (any of `name`, `source`, `target`, `tag_ids`, …). When a new `source.query` is an MBQL 5 query it is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass. " + + WORKTREE_SCOPE_DETAIL, skills: [{ skill: "mbql", purpose: "MBQL source.query bodies" }], capabilities: { minVersion: 59 }, + worktree: "scoped", args: { ...outputFlags, ...profileFlag, ...connectionFlags, + ...worktreeFlag, ...bodyInputFlags, id: { type: "positional", description: "Transform id", required: true }, ...skipValidateFlag, @@ -38,8 +42,9 @@ export default defineMetabaseCommand({ "mb transform update 1 --file patch.json", 'mb transform update 1 --body \'{"name":"renamed"}\'', "mb transform update 1 --file patch.json --skip-validate", + 'mb transform update 1 --body \'{"name":"renamed"}\' --worktree feat/transforms', ], - async run({ args, ctx, getClient }) { + async run({ args, ctx, getClient, getWorktree }) { const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, TransformUpdateInput); if (body.source !== undefined && body.source.type === "query") { @@ -48,6 +53,11 @@ export default defineMetabaseCommand({ }); } const client = await getClient(); + const scope = await getWorktree(); + if (scope !== null) { + const existing = await client.transform.get(id); + assertInWorktree("transform", id, existing.worktree_id, scope); + } const updated = await client.transform.update(id, body).catch((error: unknown) => { throw enrichTransformCollectionError(error); }); diff --git a/packages/cli/src/commands/upgrade.ts b/packages/cli/src/commands/upgrade.ts index 47f015fe..83693151 100644 --- a/packages/cli/src/commands/upgrade.ts +++ b/packages/cli/src/commands/upgrade.ts @@ -61,6 +61,7 @@ export default defineMetabaseCommand({ description: "Upgrade the Metabase CLI itself to the latest published release", }, capabilities: null, + worktree: "any", args: { ...outputFlags, check: { diff --git a/packages/cli/src/commands/upload/append.ts b/packages/cli/src/commands/upload/append.ts index 27b47dc5..7fca4d14 100644 --- a/packages/cli/src/commands/upload/append.ts +++ b/packages/cli/src/commands/upload/append.ts @@ -12,6 +12,7 @@ export default defineMetabaseCommand({ details: "Inserts the rows of the CSV into the table with the given id. The table must have been created by a CSV upload and the CSV columns must match.", capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/upload/csv.ts b/packages/cli/src/commands/upload/csv.ts index ee00cdd8..147343f3 100644 --- a/packages/cli/src/commands/upload/csv.ts +++ b/packages/cli/src/commands/upload/csv.ts @@ -14,6 +14,7 @@ export default defineMetabaseCommand({ details: "Uploads the CSV to the server's configured uploads database, creating a new table plus a model over it, and prints the new model id and table id. Requires an uploads database to be configured on the server. Pass --collection to place the model in a specific collection (defaults to root).", capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/upload/replace.ts b/packages/cli/src/commands/upload/replace.ts index 0ac70bd2..85548cfe 100644 --- a/packages/cli/src/commands/upload/replace.ts +++ b/packages/cli/src/commands/upload/replace.ts @@ -15,6 +15,7 @@ export default defineMetabaseCommand({ details: "Replaces the contents of the table with the given id with the rows of the CSV. The table must have been created by a CSV upload and the CSV columns must match.", capabilities: { minVersion: 58 }, + worktree: "main-only", args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/uuid.ts b/packages/cli/src/commands/uuid.ts index 37e6681c..2de685af 100644 --- a/packages/cli/src/commands/uuid.ts +++ b/packages/cli/src/commands/uuid.ts @@ -21,6 +21,7 @@ export default defineMetabaseCommand({ "For MBQL `lib/uuid` slots, native template-tag ids, and other UUID fields. Mint fresh values here rather than authoring them by hand — the MBQL 5 schema rejects placeholder strings.", skills: [{ skill: "mbql", purpose: "where lib/uuid values are used" }], capabilities: null, + worktree: "any", args: { ...outputFlags, count: { diff --git a/packages/cli/src/commands/worktree-scope.test.ts b/packages/cli/src/commands/worktree-scope.test.ts new file mode 100644 index 00000000..9aff6e5e --- /dev/null +++ b/packages/cli/src/commands/worktree-scope.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { ConfigError } from "@metabase/client/errors"; + +import type { WorktreeScope } from "../core/worktree-scope"; +import { assertInWorktree, scopeBody, scopeQuery, type ScopedBody } from "./worktree-scope"; + +const SCOPE: WorktreeScope = { id: 3, branch: "feat/transforms" }; + +describe("scopeQuery", () => { + it("names the scope's worktree so a listing returns only its rows", () => { + expect(scopeQuery(SCOPE)).toEqual({ "worktree-id": 3 }); + }); + + it("leaves the parameter unset without a scope, which is the main app's listing", () => { + expect(scopeQuery(null)).toEqual({ "worktree-id": undefined }); + }); +}); + +describe("assertInWorktree", () => { + it("accepts any entity without a scope, where main-app content is the point", () => { + expect(() => assertInWorktree("transform", 7, null, null)).not.toThrow(); + }); + + it("accepts an entity tagged with the scope's worktree", () => { + expect(() => assertInWorktree("transform", 7, 3, SCOPE)).not.toThrow(); + }); + + it("refuses a main-app entity, which carries no worktree tag", () => { + expect(() => assertInWorktree("transform", 7, null, SCOPE)).toThrow( + new ConfigError( + "transform 7 is not in worktree 3 (feat/transforms); refusing to touch main-app content", + ), + ); + }); + + it("refuses an entity belonging to a different worktree", () => { + expect(() => assertInWorktree("snippet", 12, 4, SCOPE)).toThrow( + new ConfigError( + "snippet 12 is not in worktree 3 (feat/transforms); refusing to touch main-app content", + ), + ); + }); + + it("refuses an entity from a server that reports no worktree field at all", () => { + expect(() => assertInWorktree("transform", 7, undefined, SCOPE)).toThrow( + new ConfigError( + "transform 7 is not in worktree 3 (feat/transforms); refusing to touch main-app content", + ), + ); + }); +}); + +interface TransformBody extends ScopedBody { + name: string; +} + +describe("scopeBody", () => { + it("returns the body untouched when there is no scope", () => { + const body: TransformBody = { name: "daily" }; + expect(scopeBody(body, null)).toEqual({ name: "daily" }); + }); + + it("injects the scope's worktree id into a body that names none", () => { + const body: TransformBody = { name: "daily" }; + expect(scopeBody(body, SCOPE)).toEqual({ name: "daily", worktree_id: 3 }); + }); + + it("accepts a body that already names the scope's own worktree", () => { + const body: TransformBody = { name: "daily", worktree_id: 3 }; + expect(scopeBody(body, SCOPE)).toEqual({ name: "daily", worktree_id: 3 }); + }); + + it("refuses a body that names a different worktree than the scope", () => { + const body: TransformBody = { name: "daily", worktree_id: 9 }; + expect(() => scopeBody(body, SCOPE)).toThrow( + new ConfigError( + "body names worktree_id 9, but the active scope is worktree 3 (feat/transforms)", + ), + ); + }); +}); diff --git a/packages/cli/src/commands/worktree-scope.ts b/packages/cli/src/commands/worktree-scope.ts new file mode 100644 index 00000000..cec00ea6 --- /dev/null +++ b/packages/cli/src/commands/worktree-scope.ts @@ -0,0 +1,84 @@ +import type { MetabaseClient } from "@metabase/client/client"; +import type { Worktree } from "@metabase/client/domain/worktree"; +import { ConfigError } from "@metabase/client/errors"; + +import { + formatWorktreeRef, + type WorktreeRef, + type WorktreeScope, + type WorktreeScopeSource, +} from "../core/worktree-scope"; + +// The label a parse error names when the ref came from a positional argument rather than a flag. +export const WORKTREE_REF_LABEL = "worktree ref"; + +// The sentence every scoped command appends to its `details`, so `--help` on any of them says how +// to reach a worktree without the reader having to find `mb worktree` first. +export const WORKTREE_SCOPE_DETAIL = + "Pass --worktree (or set MB_WORKTREE, or pin the profile with `mb worktree pin`) to operate inside a git-sync worktree."; + +export async function findWorktree(ref: WorktreeRef, mb: MetabaseClient): Promise { + if (ref.kind === "id") { + return mb.gitSync.getWorktree(ref.id); + } + const { data } = await mb.gitSync.worktrees(); + const match = data.find((worktree) => worktree.branch === ref.branch); + if (match === undefined) { + throw new ConfigError(`worktree ${formatWorktreeRef(ref)} not found; run \`mb worktree list\``); + } + return match; +} + +export async function resolveWorktreeScope( + source: WorktreeScopeSource, + mb: MetabaseClient, +): Promise { + if (source.scope !== null) { + return source.scope; + } + const worktree = await findWorktree(source.ref, mb); + return { id: worktree.id, branch: worktree.branch }; +} + +// The query parameter every scoped read carries. Metabase spells it `worktree-id` on a query +// string and `worktree_id` in a body, and both names are used verbatim. +interface WorktreeScopeQuery { + "worktree-id"?: number | undefined; +} + +export function scopeQuery(scope: WorktreeScope | null): WorktreeScopeQuery { + return { "worktree-id": scope === null ? undefined : scope.id }; +} + +// An entity fetched by id ignores the scope — the server answers for an admin whatever worktree the +// row belongs to — so a scoped command has to compare the row's own tag before touching it. +export function assertInWorktree( + kind: string, + id: number | string, + worktreeId: number | null | undefined, + scope: WorktreeScope | null, +): void { + if (scope === null || worktreeId === scope.id) { + return; + } + throw new ConfigError( + `${kind} ${id} is not in worktree ${scope.id} (${scope.branch}); refusing to touch main-app content`, + ); +} + +export interface ScopedBody { + worktree_id?: number | null | undefined; +} + +export function scopeBody(body: T, scope: WorktreeScope | null): T { + if (scope === null) { + return body; + } + const stated = body.worktree_id; + if (stated !== undefined && stated !== null && stated !== scope.id) { + throw new ConfigError( + `body names worktree_id ${stated}, but the active scope is worktree ${scope.id} (${scope.branch})`, + ); + } + return { ...body, worktree_id: scope.id }; +} diff --git a/packages/cli/src/commands/worktree/create.ts b/packages/cli/src/commands/worktree/create.ts new file mode 100644 index 00000000..97248b7e --- /dev/null +++ b/packages/cli/src/commands/worktree/create.ts @@ -0,0 +1,170 @@ +import { z } from "zod"; + +import type { MetabaseClient } from "@metabase/client/client"; +import { SyncImportResult } from "@metabase/client/domain/git-sync"; +import { Worktree, WorktreeCompact } from "@metabase/client/domain/worktree"; +import { ConfigError } from "@metabase/client/errors"; +import type { SyncImportParams } from "@metabase/client/resources/git-sync"; + +import { readProfileRecord, writeProfileWorktree } from "../../core/auth/storage"; +import { renderSummary } from "../../output/render"; +import { EMPTY_CELL } from "../../output/table"; +import type { ResourceView } from "../../output/view"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { formatSyncTask, taskPollOptions, throwIfFailedTask } from "../git-sync/sync-task"; +import { defineMetabaseCommand } from "../runtime"; +import { gitSyncWaitFlags, parseWaitFlags } from "../wait-flags"; + +export const WorktreeCreateResult = z.object({ + worktree: Worktree, + branch_created: z.boolean(), + pull: SyncImportResult.nullable(), +}); +type WorktreeCreateResult = z.infer; + +// The server hydrates `creator` onto the worktree it answers with; the default projection trims it +// to the same four columns `worktree list` and `worktree get` show, and --full still carries it. +const WorktreeCreateResultCompact = WorktreeCreateResult.extend({ worktree: WorktreeCompact }); + +const worktreeCreateView: ResourceView = { + compactPick: WorktreeCreateResultCompact, + tableColumns: [ + { key: "worktree", label: "Worktree", format: (value) => renderWorktreeCell(value) }, + { key: "branch_created", label: "Branch created" }, + { key: "pull", label: "Pull", format: (value) => renderPullCell(value) }, + ], +}; + +function renderWorktreeCell(value: unknown): string { + const parsed = Worktree.safeParse(value); + return parsed.success ? `${parsed.data.id} (${parsed.data.branch})` : EMPTY_CELL; +} + +function renderPullCell(value: unknown): string { + const parsed = SyncImportResult.safeParse(value); + if (!parsed.success) { + return EMPTY_CELL; + } + return parsed.data.task_id === null ? "no task" : `task #${parsed.data.task_id}`; +} + +export default defineMetabaseCommand({ + meta: { + name: "create", + description: "Create a git-sync worktree for a branch and pull the branch into it", + }, + capabilities: { minVersion: 64, tokenFeature: "remote_sync" }, + worktree: "any", + details: + "A worktree is bound to its branch for life. The branch is created on the remote first when it is missing, " + + "then the branch's content is pulled into the worktree so the checkout starts in sync.", + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + branch: { type: "positional", description: "Branch to check out", required: true }, + pull: { + type: "boolean", + description: "Import the branch into the new worktree (default: true; --no-pull skips it)", + default: true, + }, + createBranch: { + type: "boolean", + description: + "Create the branch on the remote when it is missing (default: true; --no-create-branch assumes it exists)", + default: true, + alias: "create-branch", + }, + pin: { + type: "boolean", + description: "Pin the profile to the new worktree so every later command runs inside it", + default: false, + }, + ...gitSyncWaitFlags, + }, + outputSchema: WorktreeCreateResult, + examples: [ + "mb worktree create feat/transforms", + "mb worktree create feat/transforms --pin --json", + "mb worktree create feat/transforms --no-create-branch --no-pull", + ], + async run({ args, ctx, getClient, getResolvedConfig }) { + const branch = args.branch.trim(); + if (branch === "") { + throw new ConfigError("invalid branch: branch name must not be blank"); + } + const wait = parseWaitFlags(args); + const profile = (await getResolvedConfig()).profile; + await assertPinAllows(profile, branch); + + const mb = await getClient(); + const branchCreated = args.createBranch ? await ensureBranch(mb, branch) : false; + const worktree = await mb.gitSync.createWorktree({ branch }); + + const pull = args.pull ? await pullBranch(mb, worktree.id, branch, wait) : null; + if (args.pin) { + await writeProfileWorktree(profile, { id: worktree.id, branch: worktree.branch }); + } + + const result: WorktreeCreateResult = { worktree, branch_created: branchCreated, pull }; + renderSummary(result, worktreeCreateView, () => summarize(result, profile, args.pin), ctx); + throwIfFailedTask(pull?.final ?? null, "import"); + }, +}); + +async function assertPinAllows(profile: string, branch: string): Promise { + const pin = (await readProfileRecord(profile))?.worktree ?? null; + if (pin === null || pin.branch === branch) { + return; + } + throw new ConfigError( + `profile "${profile}" is pinned to worktree ${pin.id} (${pin.branch}); ` + + `refusing to create a worktree for branch "${branch}"`, + ); +} + +async function ensureBranch(mb: MetabaseClient, branch: string): Promise { + const { data } = await mb.gitSync.branches(); + if (data.includes(branch)) { + return false; + } + await mb.gitSync.createBranch({ name: branch, checkout: false }); + return true; +} + +async function pullBranch( + mb: MetabaseClient, + worktreeId: number, + branch: string, + wait: ReturnType, +): Promise { + const params: SyncImportParams = { + branch, + expected_branch: branch, + worktree_id: worktreeId, + }; + if (wait.enabled) { + params.wait = taskPollOptions(wait.schedule); + } + return mb.gitSync.import(params); +} + +function summarize(result: WorktreeCreateResult, profile: string, pinned: boolean): string { + const origin = result.branch_created ? " (branch created on the remote)" : ""; + const lines = [ + `Created worktree ${result.worktree.id} for branch "${result.worktree.branch}"${origin}.`, + ]; + if (result.pull === null) { + lines.push("Skipped the initial pull; the worktree is empty until you import into it."); + } else if (result.pull.final !== null && result.pull.final !== undefined) { + lines.push(formatSyncTask(result.pull.final)); + } else if (result.pull.task_id === null) { + lines.push(result.pull.message ?? "Nothing to import."); + } else { + lines.push(`Started import task #${result.pull.task_id}.`); + } + if (pinned) { + lines.push(`Pinned profile "${profile}" to worktree ${result.worktree.id}.`); + } + return lines.join("\n"); +} diff --git a/packages/cli/src/commands/worktree/delete.ts b/packages/cli/src/commands/worktree/delete.ts new file mode 100644 index 00000000..faf1edaf --- /dev/null +++ b/packages/cli/src/commands/worktree/delete.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; + +import { ConfigError } from "@metabase/client/errors"; + +import { readProfileRecord, writeProfileWorktree } from "../../core/auth/storage"; +import { parseWorktreeRef } from "../../core/worktree-scope"; +import { renderSummary } from "../../output/render"; +import type { ResourceView } from "../../output/view"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; +import { findWorktree, WORKTREE_REF_LABEL } from "../worktree-scope"; + +export const WorktreeDeleteResult = z.object({ + id: z.number().int().positive(), + branch: z.string(), + deleted: z.literal(true), + unpinned: z.boolean(), +}); +type WorktreeDeleteResult = z.infer; + +const worktreeDeleteView: ResourceView = { + compactPick: WorktreeDeleteResult, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "branch", label: "Branch" }, + { key: "deleted", label: "Deleted" }, + { key: "unpinned", label: "Unpinned" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { + name: "delete", + description: "Delete a git-sync worktree and every piece of content checked out into it", + }, + capabilities: { minVersion: 64, tokenFeature: "remote_sync" }, + worktree: "any", + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ref: { type: "positional", description: "Worktree id or branch name", required: true }, + force: { + type: "boolean", + description: "Delete even when the worktree holds unpushed changes (LOSSY)", + default: false, + }, + }, + outputSchema: WorktreeDeleteResult, + examples: ["mb worktree delete 3", "mb worktree delete feat/transforms --force --json"], + async run({ args, ctx, getClient, getResolvedConfig, getWorktree }) { + const ref = parseWorktreeRef(args.ref, WORKTREE_REF_LABEL); + const mb = await getClient(); + const worktree = await findWorktree(ref, mb); + + const scope = await getWorktree(); + if (scope !== null && scope.id !== worktree.id) { + throw new ConfigError( + `this session is scoped to worktree ${scope.id} (${scope.branch}); ` + + `refusing to delete worktree ${worktree.id} (${worktree.branch})`, + ); + } + if (!args.force && (await mb.gitSync.isDirty({ "worktree-id": worktree.id }))) { + throw new ConfigError( + `worktree ${worktree.id} (${worktree.branch}) has unpushed changes; ` + + "push them with `mb git-sync export` or pass --force to discard", + ); + } + + await mb.gitSync.deleteWorktree(worktree.id); + + const profile = (await getResolvedConfig()).profile; + const pin = (await readProfileRecord(profile))?.worktree ?? null; + const unpinned = pin !== null && pin.id === worktree.id; + if (unpinned) { + await writeProfileWorktree(profile, null); + } + + const result: WorktreeDeleteResult = { + id: worktree.id, + branch: worktree.branch, + deleted: true, + unpinned, + }; + renderSummary(result, worktreeDeleteView, summarize(result, profile), ctx); + }, +}); + +function summarize(result: WorktreeDeleteResult, profile: string): string { + const deleted = `Deleted worktree ${result.id} (${result.branch}) and its content.`; + return result.unpinned ? `${deleted}\nUnpinned profile "${profile}".` : deleted; +} diff --git a/packages/cli/src/commands/worktree/get.ts b/packages/cli/src/commands/worktree/get.ts new file mode 100644 index 00000000..31319b71 --- /dev/null +++ b/packages/cli/src/commands/worktree/get.ts @@ -0,0 +1,27 @@ +import { Worktree } from "@metabase/client/domain/worktree"; + +import { parseWorktreeRef } from "../../core/worktree-scope"; +import { renderItem } from "../../output/render"; +import { worktreeView } from "../../output/views/worktree"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; +import { findWorktree, WORKTREE_REF_LABEL } from "../worktree-scope"; + +export default defineMetabaseCommand({ + meta: { name: "get", description: "Get one git-sync worktree by id or branch" }, + capabilities: { minVersion: 64, tokenFeature: "remote_sync" }, + worktree: "any", + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ref: { type: "positional", description: "Worktree id or branch name", required: true }, + }, + outputSchema: Worktree, + examples: ["mb worktree get 3", "mb worktree get feat/transforms --json"], + async run({ args, ctx, getClient }) { + const ref = parseWorktreeRef(args.ref, WORKTREE_REF_LABEL); + const worktree = await findWorktree(ref, await getClient()); + renderItem(worktree, worktreeView, ctx); + }, +}); diff --git a/packages/cli/src/commands/worktree/index.ts b/packages/cli/src/commands/worktree/index.ts new file mode 100644 index 00000000..39a2eed4 --- /dev/null +++ b/packages/cli/src/commands/worktree/index.ts @@ -0,0 +1,15 @@ +import { defineCommandGroup } from "../group"; + +export default defineCommandGroup({ + name: "worktree", + description: "Manage git-sync worktrees: isolated checkouts of a branch's content", + skills: [{ skill: "git-sync", purpose: "worktree lifecycle and pull/push" }], + subCommands: { + list: () => import("./list").then((mod) => mod.default), + get: () => import("./get").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + delete: () => import("./delete").then((mod) => mod.default), + pin: () => import("./pin").then((mod) => mod.default), + unpin: () => import("./unpin").then((mod) => mod.default), + }, +}); diff --git a/packages/cli/src/commands/worktree/list.ts b/packages/cli/src/commands/worktree/list.ts new file mode 100644 index 00000000..0fba611e --- /dev/null +++ b/packages/cli/src/commands/worktree/list.ts @@ -0,0 +1,24 @@ +import { WorktreeCompact } from "@metabase/client/domain/worktree"; + +import { renderList } from "../../output/render"; +import { listEnvelopeSchema } from "../../output/types"; +import { worktreeView } from "../../output/views/worktree"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const WorktreeListEnvelope = listEnvelopeSchema(WorktreeCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List the git-sync worktrees on this instance" }, + capabilities: { minVersion: 64, tokenFeature: "remote_sync" }, + worktree: "any", + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + outputSchema: WorktreeListEnvelope, + examples: ["mb worktree list", "mb worktree list --json"], + async run({ ctx, getClient }) { + const mb = await getClient(); + const { data } = await mb.gitSync.worktrees(); + renderList(windowList(data, ctx.range), worktreeView, ctx); + }, +}); diff --git a/packages/cli/src/commands/worktree/pin.ts b/packages/cli/src/commands/worktree/pin.ts new file mode 100644 index 00000000..bd421307 --- /dev/null +++ b/packages/cli/src/commands/worktree/pin.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import { ProfilePin } from "../../core/auth/profile-record"; +import { writeProfileWorktree } from "../../core/auth/storage"; +import { parseWorktreeRef } from "../../core/worktree-scope"; +import { renderSummary } from "../../output/render"; +import { EMPTY_CELL } from "../../output/table"; +import type { ResourceView } from "../../output/view"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; +import { findWorktree, WORKTREE_REF_LABEL } from "../worktree-scope"; + +export const WorktreePinResult = z.object({ + profile: z.string(), + worktree: ProfilePin, +}); +type WorktreePinResult = z.infer; + +const worktreePinView: ResourceView = { + compactPick: WorktreePinResult, + tableColumns: [ + { key: "profile", label: "Profile" }, + { key: "worktree", label: "Worktree", format: (value) => renderPin(value) }, + ], +}; + +function renderPin(value: unknown): string { + const parsed = ProfilePin.safeParse(value); + return parsed.success ? `${parsed.data.id} (${parsed.data.branch})` : EMPTY_CELL; +} + +export default defineMetabaseCommand({ + meta: { + name: "pin", + description: "Confine every command run under this profile to one worktree", + }, + capabilities: { minVersion: 64, tokenFeature: "remote_sync" }, + worktree: "any", + details: + "The pin is a lock, not a default: while it stands, --worktree and MB_WORKTREE may only re-state it, " + + "and commands that change main-app content refuse to run.", + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ref: { type: "positional", description: "Worktree id or branch name", required: true }, + }, + outputSchema: WorktreePinResult, + examples: ["mb worktree pin 3", "mb worktree pin feat/transforms --profile agent"], + async run({ args, ctx, getClient, getResolvedConfig }) { + const ref = parseWorktreeRef(args.ref, WORKTREE_REF_LABEL); + const worktree = await findWorktree(ref, await getClient()); + const profile = (await getResolvedConfig()).profile; + const pin: ProfilePin = { id: worktree.id, branch: worktree.branch }; + await writeProfileWorktree(profile, pin); + + const result: WorktreePinResult = { profile, worktree: pin }; + renderSummary( + result, + worktreePinView, + `Pinned profile "${profile}" to worktree ${pin.id} (${pin.branch}).`, + ctx, + ); + }, +}); diff --git a/packages/cli/src/commands/worktree/unpin.ts b/packages/cli/src/commands/worktree/unpin.ts new file mode 100644 index 00000000..33116d48 --- /dev/null +++ b/packages/cli/src/commands/worktree/unpin.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +import { readProfileRecord, writeProfileWorktree } from "../../core/auth/storage"; +import { resolveProfileName } from "../../core/config"; +import { renderSummary } from "../../output/render"; +import type { ResourceView } from "../../output/view"; +import { outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const WorktreeUnpinResult = z.object({ + profile: z.string(), + unpinned: z.boolean(), +}); +type WorktreeUnpinResult = z.infer; + +const worktreeUnpinView: ResourceView = { + compactPick: WorktreeUnpinResult, + tableColumns: [ + { key: "profile", label: "Profile" }, + { key: "unpinned", label: "Unpinned" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { name: "unpin", description: "Release a profile's worktree pin" }, + capabilities: null, + worktree: "any", + args: { ...outputFlags, ...profileFlag }, + outputSchema: WorktreeUnpinResult, + examples: ["mb worktree unpin", "mb worktree unpin --profile agent --json"], + async run({ args, ctx }) { + const profile = resolveProfileName(args.profile); + const pin = (await readProfileRecord(profile))?.worktree ?? null; + if (pin !== null) { + await writeProfileWorktree(profile, null); + } + const result: WorktreeUnpinResult = { profile, unpinned: pin !== null }; + const text = + pin === null + ? `Profile "${profile}" is not pinned to a worktree.` + : `Unpinned profile "${profile}" from worktree ${pin.id} (${pin.branch}).`; + renderSummary(result, worktreeUnpinView, text, ctx); + }, +}); diff --git a/packages/cli/src/core/auth/profile-record.test.ts b/packages/cli/src/core/auth/profile-record.test.ts index 7877cb71..1727ca2c 100644 --- a/packages/cli/src/core/auth/profile-record.test.ts +++ b/packages/cli/src/core/auth/profile-record.test.ts @@ -1,11 +1,36 @@ -import { describe, expectTypeOf, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { type ParsedVersion } from "@metabase/client/version/tag"; -import { type ProfileLastProbe } from "./profile-record"; +import { ProfileRecord, type ProfileLastProbe } from "./profile-record"; describe("ProfileLastProbe schema", () => { it("infers version as a nullable ParsedVersion (null for head/nightly builds)", () => { expectTypeOf().toEqualTypeOf(); }); }); + +describe("ProfileRecord worktree pin", () => { + it("defaults to null so a profile written before pinning existed still parses", () => { + const parsed = ProfileRecord.parse({ + name: "default", + url: "https://m.example.com", + apiKey: "k", + lastProbe: null, + lastFailure: null, + }); + expect(parsed.worktree).toBeNull(); + }); + + it("fails loudly on a malformed pin rather than falling back to the null default", () => { + const result = ProfileRecord.safeParse({ + name: "default", + url: "https://m.example.com", + apiKey: "k", + lastProbe: null, + lastFailure: null, + worktree: { id: 0, branch: "feat/x" }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/cli/src/core/auth/profile-record.ts b/packages/cli/src/core/auth/profile-record.ts index 874985b4..5cb4a77e 100644 --- a/packages/cli/src/core/auth/profile-record.ts +++ b/packages/cli/src/core/auth/profile-record.ts @@ -44,6 +44,15 @@ export type ProfileOAuth = z.infer; export const ProfileAuthMethod = z.enum(["oauth", "apiKey"]); export type ProfileAuthMethod = z.infer; +// The worktree every command run under this profile is confined to. Both halves are stored so a +// pinned session knows its scope without asking the server, and `.default(null)` keeps profiles +// written before pinning existed parseable. +export const ProfilePin = z.object({ + id: z.number().int().positive(), + branch: z.string(), +}); +export type ProfilePin = z.infer; + // `.loose()` so fields written by a newer CLI survive an older CLI's read-modify-write of the // shared profiles.json. Every write path round-trips the whole file; a strict-strip schema would // silently drop unknown keys (e.g. a future `oauth`-style block) and force a re-login. @@ -55,6 +64,7 @@ export const ProfileRecord = z oauth: ProfileOAuth.nullable().default(null), lastProbe: ProfileLastProbe.nullable(), lastFailure: ProfileLastFailure.nullable(), + worktree: ProfilePin.nullable().default(null), }) .loose(); export type ProfileRecord = z.infer; diff --git a/packages/cli/src/core/auth/storage.test.ts b/packages/cli/src/core/auth/storage.test.ts index 4f481024..eeb8608e 100644 --- a/packages/cli/src/core/auth/storage.test.ts +++ b/packages/cli/src/core/auth/storage.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ConfigError } from "@metabase/client/errors"; import { parseJson } from "@metabase/client/json"; import { ProfilesFile } from "./profile-record"; @@ -37,6 +38,7 @@ const { writeProbeFailure, writeProbeResult, writeProfile, + writeProfileWorktree, } = storage; import type { OAuthCredential } from "@metabase/client/auth/credential"; @@ -92,6 +94,7 @@ describe("profiles (keyring backend)", () => { oauth: null, lastProbe: null, lastFailure: null, + worktree: null, }, ], }); @@ -233,6 +236,38 @@ describe("readProfileRecord and listProfileRecords", () => { oauth: null, lastProbe: null, lastFailure: null, + worktree: null, + }); + }); + + it("round-trips a worktree pin onto an existing profile", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "k" }, "agent"); + await writeProfileWorktree("agent", { id: 4, branch: "feat/transforms" }); + expect((await readProfileRecord("agent"))?.worktree).toEqual({ + id: 4, + branch: "feat/transforms", + }); + }); + + it("clears a worktree pin when passed null", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "k" }, "agent"); + await writeProfileWorktree("agent", { id: 4, branch: "feat/transforms" }); + await writeProfileWorktree("agent", null); + expect((await readProfileRecord("agent"))?.worktree).toBeNull(); + }); + + it("refuses to pin a profile that does not exist", async () => { + await expect(writeProfileWorktree("missing", { id: 1, branch: "main" })).rejects.toThrow( + new ConfigError('no such profile "missing"; run `mb auth login --profile missing` first'), + ); + }); + + it("leaves the stored credential untouched when the pin changes", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "k" }, "agent"); + await writeProfileWorktree("agent", { id: 4, branch: "feat/transforms" }); + expect(await readProfileCredential("agent")).toEqual({ + url: "https://m.example.com", + credential: { kind: "apiKey", apiKey: "k" }, }); }); @@ -450,6 +485,7 @@ describe("OAuth profiles (keyring backend)", () => { }, lastProbe: null, lastFailure: null, + worktree: null, }); expect(hoisted.store.get("metabase-cli:profile:default:oauthAccess")).toBe("access-1"); expect(hoisted.store.get("metabase-cli:profile:default:oauthRefresh")).toBe("refresh-1"); diff --git a/packages/cli/src/core/auth/storage.ts b/packages/cli/src/core/auth/storage.ts index 045a4db7..39deac8c 100644 --- a/packages/cli/src/core/auth/storage.ts +++ b/packages/cli/src/core/auth/storage.ts @@ -3,7 +3,7 @@ import { promises as fs } from "node:fs"; import { dirname, join } from "node:path"; import type { Credential, OAuthCredential } from "@metabase/client/auth/credential"; -import { isFileNotFoundError, ValidationError } from "@metabase/client/errors"; +import { ConfigError, isFileNotFoundError, ValidationError } from "@metabase/client/errors"; import { parseJsonResult } from "@metabase/client/json"; import type { ServerInfo } from "@metabase/client/version/probe"; @@ -16,6 +16,7 @@ import { type ProbedUser, type ProfileFailureKind, type ProfileOAuth, + type ProfilePin, type ProfileRecord, } from "./profile-record"; @@ -402,6 +403,7 @@ export async function writeProfile( oauth: null, lastProbe: null, lastFailure: null, + worktree: null, } : { ...existing, url: profile.url, apiKey: inlineApiKey, oauth: null }; await upsertRecord(file, name, updated); @@ -436,7 +438,7 @@ export async function writeOAuthProfile( flagResidualIfUnconfirmed(existing, "apiKey", [removeKeyringEntry(account.profileApiKey(name))]); const updated: ProfileRecord = existing === null - ? { name, url, apiKey: null, oauth, lastProbe: null, lastFailure: null } + ? { name, url, apiKey: null, oauth, lastProbe: null, lastFailure: null, worktree: null } : { ...existing, url, apiKey: null, oauth }; await upsertRecord(file, name, updated); return onFile @@ -492,6 +494,21 @@ export async function writeProbeFailure( return failure; } +// Pinning is a property of the credential set the process can reach, so it is written onto the +// profile record itself rather than held for one invocation. +export async function writeProfileWorktree(name: string, pin: ProfilePin | null): Promise { + const file = await readProfilesFile(); + if (findRecord(file, name) === null) { + throw new ConfigError( + `no such profile "${name}"; run \`mb auth login --profile ${name}\` first`, + ); + } + const profiles = file.profiles.map((entry) => + entry.name === name ? { ...entry, worktree: pin } : entry, + ); + await writeProfilesFile({ ...file, profiles }); +} + export async function clearProfile(name: string = DEFAULT_PROFILE): Promise { const file = await readProfilesFile(); const existing = findRecord(file, name); diff --git a/packages/cli/src/core/env.ts b/packages/cli/src/core/env.ts index 81b2437b..88b73bb6 100644 --- a/packages/cli/src/core/env.ts +++ b/packages/cli/src/core/env.ts @@ -8,6 +8,7 @@ export const ENV_VERBOSE = "MB_VERBOSE"; export const ENV_SKIP_PREFLIGHT = "MB_CLI_SKIP_PREFLIGHT"; export const ENV_DISABLE_KEYRING = "MB_CLI_DISABLE_KEYRING"; export const ENV_SKILLS_DIR = "MB_SKILLS_DIR"; +export const ENV_WORKTREE = "MB_WORKTREE"; function legacyNameFor(canonical: string): string { return LEGACY_PREFIX + canonical.slice(CANONICAL_PREFIX.length); diff --git a/packages/cli/src/core/worktree-scope.test.ts b/packages/cli/src/core/worktree-scope.test.ts new file mode 100644 index 00000000..3e2f5eed --- /dev/null +++ b/packages/cli/src/core/worktree-scope.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; + +import { ConfigError } from "@metabase/client/errors"; + +import { + describeScopeSource, + mainOnlyRefusal, + parseWorktreeRef, + resolveScopeSource, + type WorktreeScope, + type WorktreeScopeInput, +} from "./worktree-scope"; + +const PIN: WorktreeScope = { id: 3, branch: "feat/transforms" }; + +function inputWith( + flag: string | undefined, + env: string | undefined, + pin: WorktreeScope | null, +): WorktreeScopeInput { + return { profile: "default", flag, env, pin }; +} + +describe("parseWorktreeRef", () => { + it("reads an all-digit value as a worktree id", () => { + expect(parseWorktreeRef("12", "--worktree")).toEqual({ kind: "id", id: 12 }); + }); + + it("reads a non-numeric value as a branch name", () => { + expect(parseWorktreeRef("feat/transforms", "--worktree")).toEqual({ + kind: "branch", + branch: "feat/transforms", + }); + }); + + it("trims surrounding whitespace before deciding which kind it is", () => { + expect(parseWorktreeRef(" 7 ", "--worktree")).toEqual({ kind: "id", id: 7 }); + }); + + it("rejects a blank value, naming the source it came from", () => { + expect(() => parseWorktreeRef(" ", "MB_WORKTREE")).toThrow( + new ConfigError("invalid MB_WORKTREE: value must not be blank"), + ); + }); + + it("rejects id 0, which no worktree can have", () => { + expect(() => parseWorktreeRef("0", "--worktree")).toThrow( + new ConfigError("invalid --worktree: 0 (must be ≥ 1)"), + ); + }); +}); + +describe("resolveScopeSource", () => { + it("returns null when nothing names a worktree", () => { + expect(resolveScopeSource(inputWith(undefined, undefined, null))).toBeNull(); + }); + + it("prefers the flag over the environment", () => { + expect(resolveScopeSource(inputWith("5", "9", null))).toEqual({ + ref: { kind: "id", id: 5 }, + origin: "flag", + scope: null, + }); + }); + + it("falls back to the environment when no flag is given", () => { + expect(resolveScopeSource(inputWith(undefined, "feat/x", null))).toEqual({ + ref: { kind: "branch", branch: "feat/x" }, + origin: "env", + scope: null, + }); + }); + + it("treats an empty flag as unset and falls through to the environment", () => { + expect(resolveScopeSource(inputWith("", "9", null))).toEqual({ + ref: { kind: "id", id: 9 }, + origin: "env", + scope: null, + }); + }); + + it("resolves a pinned profile without a ref to look up", () => { + expect(resolveScopeSource(inputWith(undefined, undefined, PIN))).toEqual({ + ref: { kind: "id", id: 3 }, + origin: "pin", + scope: PIN, + }); + }); + + it("accepts a flag that re-states the pinned worktree by id", () => { + expect(resolveScopeSource(inputWith("3", undefined, PIN))).toEqual({ + ref: { kind: "id", id: 3 }, + origin: "flag", + scope: PIN, + }); + }); + + it("accepts an environment value that re-states the pinned worktree by branch", () => { + expect(resolveScopeSource(inputWith(undefined, "feat/transforms", PIN))).toEqual({ + ref: { kind: "branch", branch: "feat/transforms" }, + origin: "env", + scope: PIN, + }); + }); + + it("refuses a flag naming a different worktree than the pin", () => { + expect(() => resolveScopeSource(inputWith("9", undefined, PIN))).toThrow( + new ConfigError( + 'profile "default" is pinned to worktree 3 (feat/transforms); refusing --worktree 9', + ), + ); + }); + + it("refuses an environment value naming a different branch than the pin", () => { + expect(() => resolveScopeSource(inputWith(undefined, "other", PIN))).toThrow( + new ConfigError( + 'profile "default" is pinned to worktree 3 (feat/transforms); refusing MB_WORKTREE "other"', + ), + ); + }); +}); + +describe("describeScopeSource", () => { + it("names both halves of a pinned scope", () => { + expect(describeScopeSource({ ref: { kind: "id", id: 3 }, origin: "pin", scope: PIN })).toBe( + "worktree 3 (feat/transforms) from the profile pin", + ); + }); + + it("falls back to the raw ref for a scope that has not been looked up yet", () => { + expect( + describeScopeSource({ + ref: { kind: "branch", branch: "feat/x" }, + origin: "env", + scope: null, + }), + ).toBe('worktree "feat/x" from MB_WORKTREE'); + }); +}); + +describe("mainOnlyRefusal", () => { + it("names the command, the scope, and both ways out", () => { + expect( + mainOnlyRefusal("transform run", { + ref: { kind: "id", id: 3 }, + origin: "pin", + scope: PIN, + }), + ).toBe( + "transform run is not available inside a worktree (scope: worktree 3 (feat/transforms) " + + "from the profile pin); it changes main-app content. Unpin the profile " + + "(`mb worktree unpin`) or drop MB_WORKTREE to run it against the main app.", + ); + }); +}); diff --git a/packages/cli/src/core/worktree-scope.ts b/packages/cli/src/core/worktree-scope.ts new file mode 100644 index 00000000..33487840 --- /dev/null +++ b/packages/cli/src/core/worktree-scope.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; + +import { ConfigError } from "@metabase/client/errors"; + +import { ENV_WORKTREE } from "./env"; + +// How a command relates to the worktree/main-app split. `scoped` honours a resolved scope, +// `any` is indifferent to one, and `main-only` mutates or runs main-app state and so may not +// execute while a scope is in force. +export const WorktreePolicy = z.enum(["scoped", "any", "main-only"]); +export type WorktreePolicy = z.infer; + +interface WorktreeIdRef { + kind: "id"; + id: number; +} + +interface WorktreeBranchRef { + kind: "branch"; + branch: string; +} + +// What the user named, before it is known to exist: the server takes an id, so a branch ref costs +// a lookup and an id ref does not. +export type WorktreeRef = WorktreeIdRef | WorktreeBranchRef; + +export interface WorktreeScope { + id: number; + branch: string; +} + +type WorktreeScopeOrigin = "flag" | "env" | "pin"; + +export interface WorktreeScopeSource { + ref: WorktreeRef; + origin: WorktreeScopeOrigin; + // A pin stores both halves of the scope, so a pinned source resolves without a request. + scope: WorktreeScope | null; +} + +export interface WorktreeScopeInput { + profile: string; + flag: string | undefined; + env: string | undefined; + pin: WorktreeScope | null; +} + +const WORKTREE_FLAG = "--worktree"; + +const ORIGIN_LABEL: Readonly> = Object.freeze({ + flag: WORKTREE_FLAG, + env: ENV_WORKTREE, + pin: "the profile pin", +}); + +const DIGITS = /^\d+$/; + +export function parseWorktreeRef(raw: string, label: string): WorktreeRef { + const value = raw.trim(); + if (value === "") { + throw new ConfigError(`invalid ${label}: value must not be blank`); + } + if (!DIGITS.test(value)) { + return { kind: "branch", branch: value }; + } + const id = Number(value); + if (id < 1) { + throw new ConfigError(`invalid ${label}: ${value} (must be ≥ 1)`); + } + return { kind: "id", id }; +} + +export function formatWorktreeRef(ref: WorktreeRef): string { + return ref.kind === "id" ? String(ref.id) : `"${ref.branch}"`; +} + +// A pin is a lock rather than a default: a harness hands an agent a config home holding one pinned +// profile, and a flag or env var that could point somewhere else would undo the isolation the pin +// exists to provide. Re-stating the same worktree is allowed, so a script may be explicit. +export function resolveScopeSource(input: WorktreeScopeInput): WorktreeScopeSource | null { + const stated = statedRef(input); + if (stated === null) { + return input.pin === null + ? null + : { ref: { kind: "id", id: input.pin.id }, origin: "pin", scope: input.pin }; + } + const pin = input.pin; + if (pin === null) { + return { ...stated, scope: null }; + } + if (!refMatchesScope(stated.ref, pin)) { + throw new ConfigError( + `profile "${input.profile}" is pinned to worktree ${pin.id} (${pin.branch}); ` + + `refusing ${ORIGIN_LABEL[stated.origin]} ${formatWorktreeRef(stated.ref)}`, + ); + } + return { ...stated, scope: pin }; +} + +export function describeScopeSource(source: WorktreeScopeSource): string { + const subject = + source.scope === null + ? `worktree ${formatWorktreeRef(source.ref)}` + : `worktree ${source.scope.id} (${source.scope.branch})`; + return `${subject} from ${ORIGIN_LABEL[source.origin]}`; +} + +export function mainOnlyRefusal(command: string, source: WorktreeScopeSource): string { + return ( + `${command} is not available inside a worktree (scope: ${describeScopeSource(source)}); ` + + `it changes main-app content. Unpin the profile (\`mb worktree unpin\`) or drop ${ENV_WORKTREE} ` + + `to run it against the main app.` + ); +} + +interface StatedRef { + ref: WorktreeRef; + origin: WorktreeScopeOrigin; +} + +function statedRef(input: WorktreeScopeInput): StatedRef | null { + if (input.flag !== undefined && input.flag !== "") { + return { ref: parseWorktreeRef(input.flag, WORKTREE_FLAG), origin: "flag" }; + } + if (input.env !== undefined && input.env !== "") { + return { ref: parseWorktreeRef(input.env, ENV_WORKTREE), origin: "env" }; + } + return null; +} + +function refMatchesScope(ref: WorktreeRef, scope: WorktreeScope): boolean { + return ref.kind === "id" ? ref.id === scope.id : ref.branch === scope.branch; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 420a0ace..8138f4f3 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -32,6 +32,7 @@ const main: CommandDef = defineCommand({ setting: () => import("./commands/setting").then((mod) => mod.default), search: () => import("./commands/search").then((mod) => mod.default), "git-sync": () => import("./commands/git-sync").then((mod) => mod.default), + worktree: () => import("./commands/worktree").then((mod) => mod.default), setup: () => import("./commands/setup").then((mod) => mod.default), snippet: () => import("./commands/snippet").then((mod) => mod.default), segment: () => import("./commands/segment").then((mod) => mod.default), @@ -59,6 +60,7 @@ setMetabaseAugment(main, { inputSchema: null, outputSchema: null, capabilities: null, + worktree: "any", }); export default main; diff --git a/packages/cli/src/output/help.test.ts b/packages/cli/src/output/help.test.ts index 6c979ef1..00156427 100644 --- a/packages/cli/src/output/help.test.ts +++ b/packages/cli/src/output/help.test.ts @@ -11,7 +11,7 @@ import { defineCommandGroup } from "../commands/group"; import { defineMetabaseCommand } from "../commands/runtime"; import { setMetabaseAugment } from "../runtime/command-augment"; import { CommandHelpEntry, CommandHelpIndex } from "../runtime/command-help"; -import { findUnknownCommand, resolveBreadcrumb, showUsage, showUsageJson } from "./help"; +import { resolveInvocation, showUsage, showUsageJson } from "./help"; describe("showUsage", () => { let chunks: string[]; @@ -65,6 +65,7 @@ describe("showUsage", () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo cmd" }, capabilities: {}, + worktree: "any", args: {}, examples: ["mb demo --json", "mb demo --profile staging"], outputSchema: z.object({ ok: z.boolean() }), @@ -84,6 +85,7 @@ describe("showUsage", () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo cmd" }, capabilities: {}, + worktree: "any", args: {}, run() { return; @@ -99,6 +101,7 @@ describe("showUsage", () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "Short summary" }, capabilities: {}, + worktree: "any", args: {}, details: "Longer per-command knowledge shown only on this page.", run() { @@ -120,6 +123,7 @@ describe("showUsage", () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "Short summary" }, capabilities: {}, + worktree: "any", args: {}, run() { return; @@ -169,6 +173,7 @@ describe("showUsage", () => { const cmd = defineMetabaseCommand({ meta: { name: "bar", description: "demo bar" }, capabilities: {}, + worktree: "any", args: {}, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -201,6 +206,7 @@ describe("showUsage", () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo cmd" }, capabilities: {}, + worktree: "any", args: {}, examples: ["mb demo --json"], outputSchema: z.object({ ok: z.boolean() }), @@ -218,6 +224,7 @@ describe("showUsage", () => { const cmd = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, capabilities: {}, + worktree: "any", args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -241,6 +248,7 @@ describe("showUsage", () => { const cmd = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, capabilities: {}, + worktree: "any", args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -258,6 +266,7 @@ describe("showUsage", () => { const cmd = defineMetabaseCommand({ meta: { name: "bar", description: "demo bar" }, capabilities: {}, + worktree: "any", args: { ...outputFlags }, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -292,6 +301,7 @@ describe("showUsage", () => { const cmd = defineMetabaseCommand({ meta: { name: "create", description: "demo create" }, capabilities: {}, + worktree: "any", args: {}, skills: [ { skill: "mbql", purpose: "author the dataset_query" }, @@ -314,6 +324,7 @@ describe("showUsage", () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo cmd" }, capabilities: {}, + worktree: "any", args: {}, run() { return; @@ -341,10 +352,12 @@ describe("showUsage", () => { inputSchema: null, outputSchema: null, capabilities: null, + worktree: "any", }); const leaf = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, capabilities: {}, + worktree: "any", args: {}, skills: [{ skill: "core", purpose: "auth and conventions" }], run() { @@ -376,6 +389,7 @@ describe("showUsage", () => { const leaf = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, capabilities: {}, + worktree: "any", args: {}, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -415,6 +429,7 @@ describe("showUsageJson", () => { const leaf = defineMetabaseCommand({ meta: { name: "bar", description: "demo bar" }, capabilities: {}, + worktree: "any", args: {}, outputSchema: z.object({ ok: z.boolean() }), examples: ["mb foo bar --json"], @@ -441,6 +456,7 @@ describe("showUsageJson", () => { additionalProperties: false, }, capabilities: BASELINE_CAPABILITIES, + worktree: "any", }); }); @@ -448,6 +464,7 @@ describe("showUsageJson", () => { const bar = defineMetabaseCommand({ meta: { name: "bar", description: "demo bar" }, capabilities: {}, + worktree: "any", args: {}, run() { return; @@ -456,6 +473,7 @@ describe("showUsageJson", () => { const baz = defineMetabaseCommand({ meta: { name: "baz", description: "demo baz" }, capabilities: {}, + worktree: "any", args: {}, run() { return; @@ -485,7 +503,7 @@ describe("showUsageJson", () => { }); }); -describe("resolveBreadcrumb", () => { +describe("resolveInvocation", () => { function tree(): CommandDef { const leaf = defineCommand({ meta: { name: "bar" }, @@ -502,59 +520,51 @@ describe("resolveBreadcrumb", () => { }); } - it("walks subcommand tokens into a full breadcrumb", async () => { - expect(await resolveBreadcrumb(tree(), ["foo", "bar", "123"])).toBe("mb foo bar"); + it("walks subcommand tokens past a trailing positional", async () => { + expect(await resolveInvocation(tree(), ["foo", "bar", "123"])).toEqual({ + breadcrumb: "mb foo bar", + verbs: "foo bar", + unknownToken: null, + }); }); it("resolves a command alias to its canonical name", async () => { - expect(await resolveBreadcrumb(tree(), ["f", "bar"])).toBe("mb foo bar"); + expect(await resolveInvocation(tree(), ["f", "bar"])).toEqual({ + breadcrumb: "mb foo bar", + verbs: "foo bar", + unknownToken: null, + }); }); it("skips a value-taking flag and its argument before the subcommand", async () => { - expect(await resolveBreadcrumb(tree(), ["--profile", "staging", "foo", "bar"])).toBe( - "mb foo bar", - ); - }); - - it("stops at the first unknown token", async () => { - expect(await resolveBreadcrumb(tree(), ["nope", "bar"])).toBe("mb"); - }); -}); - -describe("findUnknownCommand", () => { - function tree(): CommandDef { - const leaf = defineCommand({ - meta: { name: "bar" }, - args: { id: { type: "positional", required: false } }, + expect(await resolveInvocation(tree(), ["--profile", "staging", "foo", "bar"])).toEqual({ + breadcrumb: "mb foo bar", + verbs: "foo bar", + unknownToken: null, }); - const group = defineCommand({ - meta: { name: "foo", alias: "f" }, - subCommands: { bar: leaf }, - }); - return defineCommand({ - meta: { name: "mb" }, - args: { profile: { type: "string" } }, - subCommands: { foo: group }, - }); - } - - it("returns null for a valid subcommand path with a trailing positional", async () => { - expect(await findUnknownCommand(tree(), ["foo", "bar", "123"])).toBeNull(); - }); - - it("returns null when a command alias is used", async () => { - expect(await findUnknownCommand(tree(), ["f", "bar"])).toBeNull(); }); - it("returns null when only flags are present (a missing command, not an unknown one)", async () => { - expect(await findUnknownCommand(tree(), ["--profile", "staging"])).toBeNull(); + it("reports no verbs when only flags are present (a missing command, not an unknown one)", async () => { + expect(await resolveInvocation(tree(), ["--profile", "staging"])).toEqual({ + breadcrumb: "mb", + verbs: null, + unknownToken: null, + }); }); - it("returns the unknown token at the root", async () => { - expect(await findUnknownCommand(tree(), ["nope"])).toBe("nope"); + it("stops at an unknown token at the root", async () => { + expect(await resolveInvocation(tree(), ["nope", "bar"])).toEqual({ + breadcrumb: "mb", + verbs: null, + unknownToken: "nope", + }); }); - it("returns the unknown token nested under a group", async () => { - expect(await findUnknownCommand(tree(), ["foo", "frob"])).toBe("frob"); + it("stops at an unknown token nested under a group", async () => { + expect(await resolveInvocation(tree(), ["foo", "frob"])).toEqual({ + breadcrumb: "mb foo", + verbs: "foo", + unknownToken: "frob", + }); }); }); diff --git a/packages/cli/src/output/help.ts b/packages/cli/src/output/help.ts index 0e13c40a..929fad40 100644 --- a/packages/cli/src/output/help.ts +++ b/packages/cli/src/output/help.ts @@ -259,19 +259,26 @@ async function walkCommandPath(root: CommandDef, rawArgs: readonly string[]): Pr return { segments, unknownToken: null }; } -export async function resolveBreadcrumb( - root: CommandDef, - rawArgs: readonly string[], -): Promise { - const { segments } = await walkCommandPath(root, rawArgs); - return segments.length > 0 ? segments.join(" ") : CLI_NAME; +interface CommandInvocation { + breadcrumb: string; + // The verb chain alone, without the CLI name — `null` when the args named no subcommand. + verbs: string | null; + unknownToken: string | null; } -export async function findUnknownCommand( +// Citty resolves the leaf itself but exposes neither the path it took nor the token it choked on, +// so the entry re-walks the tree against the raw argv for both. +export async function resolveInvocation( root: CommandDef, rawArgs: readonly string[], -): Promise { - return (await walkCommandPath(root, rawArgs)).unknownToken; +): Promise { + const { segments, unknownToken } = await walkCommandPath(root, rawArgs); + const verbs = segments[0] === CLI_NAME ? segments.slice(1) : segments; + return { + breadcrumb: segments.length > 0 ? segments.join(" ") : CLI_NAME, + verbs: verbs.length > 0 ? verbs.join(" ") : null, + unknownToken, + }; } function skipFlags(rawArgs: readonly string[], start: number, argsDef: ArgsDef): number { diff --git a/packages/cli/src/output/views/git-sync.ts b/packages/cli/src/output/views/git-sync.ts index 0fcc7681..be2ffbe1 100644 --- a/packages/cli/src/output/views/git-sync.ts +++ b/packages/cli/src/output/views/git-sync.ts @@ -2,6 +2,7 @@ import { SyncBranchCreated, type SyncDirtyItem, SyncDirtyItemCompact, + SyncExportPreflight, SyncExportResult, SyncImportResult, SyncRemoteChanges, @@ -78,6 +79,18 @@ export const syncExportView: ResourceView = { ], }; +export const syncExportPreflightView: ResourceView = { + compactPick: SyncExportPreflight, + tableColumns: [ + { key: "has_changes", label: "Has changes" }, + { key: "clean", label: "Clean" }, + { key: "summary", label: "Summary" }, + { key: "conflicts", label: "Conflicts" }, + { key: "force_push_casualties", label: "Force-push casualties" }, + { key: "reason", label: "Reason" }, + ], +}; + export const syncStashView: ResourceView = { compactPick: SyncStashResult, tableColumns: [ diff --git a/packages/cli/src/output/views/worktree.ts b/packages/cli/src/output/views/worktree.ts new file mode 100644 index 00000000..e9f46a1b --- /dev/null +++ b/packages/cli/src/output/views/worktree.ts @@ -0,0 +1,13 @@ +import { type Worktree, WorktreeCompact } from "@metabase/client/domain/worktree"; + +import type { ResourceView } from "../view"; + +export const worktreeView: ResourceView = { + compactPick: WorktreeCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "branch", label: "Branch" }, + { key: "creator_id", label: "Creator" }, + { key: "created_at", label: "Created" }, + ], +}; diff --git a/packages/cli/src/runtime/command-augment.test.ts b/packages/cli/src/runtime/command-augment.test.ts index dd702fdc..4a0b246a 100644 --- a/packages/cli/src/runtime/command-augment.test.ts +++ b/packages/cli/src/runtime/command-augment.test.ts @@ -20,6 +20,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: z.object({ id: z.number() }), capabilities: BASELINE_CAPABILITIES, + worktree: "any", }; setMetabaseAugment(cmd, augment); expect(getMetabaseAugment(cmd)).toBe(augment); @@ -35,6 +36,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: null, capabilities: BASELINE_CAPABILITIES, + worktree: "any", }; const secondAugment: MetabaseAugment = { examples: ["b"], @@ -43,6 +45,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: null, capabilities: BASELINE_CAPABILITIES, + worktree: "any", }; setMetabaseAugment(first, firstAugment); setMetabaseAugment(second, secondAugment); @@ -59,6 +62,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: null, capabilities: BASELINE_CAPABILITIES, + worktree: "any", }; const replacement: MetabaseAugment = { examples: ["after"], @@ -67,6 +71,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: null, capabilities: BASELINE_CAPABILITIES, + worktree: "any", }; setMetabaseAugment(cmd, initial); setMetabaseAugment(cmd, replacement); @@ -82,6 +87,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: null, capabilities: BASELINE_CAPABILITIES, + worktree: "any", }; setMetabaseAugment(cmd, augment); const recalled = getMetabaseAugment(cmd); @@ -92,6 +98,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: null, capabilities: BASELINE_CAPABILITIES, + worktree: "any", }); }); }); diff --git a/packages/cli/src/runtime/command-augment.ts b/packages/cli/src/runtime/command-augment.ts index 959f02cc..ffc92c0b 100644 --- a/packages/cli/src/runtime/command-augment.ts +++ b/packages/cli/src/runtime/command-augment.ts @@ -2,6 +2,8 @@ import type { ZodType } from "zod"; import type { Capabilities } from "@metabase/client/version/capabilities"; +import type { WorktreePolicy } from "../core/worktree-scope"; + export interface SkillPointer { skill: string; purpose: string; @@ -14,6 +16,7 @@ export interface MetabaseAugment { inputSchema: ZodType | null; outputSchema: ZodType | null; capabilities: Capabilities | null; + worktree: WorktreePolicy; } const augments = new WeakMap(); diff --git a/packages/cli/src/runtime/command-help.test.ts b/packages/cli/src/runtime/command-help.test.ts index aac33b4f..c8900a42 100644 --- a/packages/cli/src/runtime/command-help.test.ts +++ b/packages/cli/src/runtime/command-help.test.ts @@ -14,6 +14,7 @@ describe("buildHelpIndex", () => { const leaf = defineMetabaseCommand({ meta: { name: "leaf", description: "a leaf" }, capabilities: {}, + worktree: "any", args: {}, run() { return; @@ -41,6 +42,7 @@ describe("buildHelpIndex", () => { const visible = defineMetabaseCommand({ meta: { name: "visible", description: "visible" }, capabilities: {}, + worktree: "any", args: {}, run() { return; @@ -49,6 +51,7 @@ describe("buildHelpIndex", () => { const hidden = defineMetabaseCommand({ meta: { name: "hidden", description: "hidden", hidden: true }, capabilities: {}, + worktree: "any", args: {}, run() { return; @@ -99,6 +102,7 @@ describe("buildHelpEntry", () => { const leaf = defineMetabaseCommand({ meta: { name: "leaf", description: "leaf" }, capabilities: {}, + worktree: "any", args: { flag: { type: "string", description: "a flag", alias: "f" }, toggle: { type: "boolean", description: "toggle", default: false }, @@ -147,6 +151,7 @@ describe("buildHelpEntry", () => { const leaf = defineMetabaseCommand({ meta: { name: "leaf", description: "leaf" }, capabilities: {}, + worktree: "any", args: {}, outputSchema: z.object({ ok: z.boolean(), name: z.string() }), examples: ["root leaf --json"], @@ -175,13 +180,31 @@ describe("buildHelpEntry", () => { additionalProperties: false, }, capabilities: BASELINE_CAPABILITIES, + worktree: "any", }); }); + it("forwards the declared worktree policy so an agent can see what a scope refuses", async () => { + const leaf = defineMetabaseCommand({ + meta: { name: "run", description: "run" }, + capabilities: {}, + worktree: "main-only", + args: {}, + run() { + return; + }, + }); + + const entry = await buildHelpEntry(leaf, ["run"]); + + expect(entry.worktree).toBe("main-only"); + }); + it("emits the JSON Schema of inputSchema for commands that declare a body contract", async () => { const leaf = defineMetabaseCommand({ meta: { name: "create", description: "create" }, capabilities: {}, + worktree: "any", args: {}, inputSchema: z.object({ name: z.string() }), run() { @@ -206,6 +229,7 @@ describe("buildHelpEntry", () => { const withDetails = defineMetabaseCommand({ meta: { name: "with", description: "short" }, capabilities: {}, + worktree: "any", args: {}, details: "the long per-command knowledge", run() { @@ -215,6 +239,7 @@ describe("buildHelpEntry", () => { const without = defineMetabaseCommand({ meta: { name: "without", description: "short" }, capabilities: {}, + worktree: "any", args: {}, run() { return; @@ -232,6 +257,7 @@ describe("buildHelpEntry", () => { const withSkills = defineMetabaseCommand({ meta: { name: "with", description: "short" }, capabilities: {}, + worktree: "any", args: {}, skills: [{ skill: "mbql", purpose: "author the dataset_query" }], run() { @@ -241,6 +267,7 @@ describe("buildHelpEntry", () => { const without = defineMetabaseCommand({ meta: { name: "without", description: "short" }, capabilities: {}, + worktree: "any", args: {}, run() { return; @@ -274,6 +301,7 @@ describe("buildHelpEntry", () => { inputSchema: null, outputSchema: null, capabilities: null, + worktree: "any", }); }); @@ -297,6 +325,7 @@ describe("buildHelpEntry", () => { inputSchema: null, outputSchema: null, capabilities: null, + worktree: "any", }); }); }); @@ -403,11 +432,18 @@ const ALL_COMMANDS = [ "git-sync wait", "git-sync import", "git-sync export", + "git-sync export-preflight", "git-sync stash", "git-sync branches", "git-sync create-branch", "git-sync add-collection", "git-sync remove-collection", + "worktree list", + "worktree get", + "worktree create", + "worktree delete", + "worktree pin", + "worktree unpin", "setup", "snippet list", "snippet get", @@ -576,6 +612,14 @@ describe("command tree contract", () => { }); }); + it("declares every command that never reaches a server as scope-agnostic", async () => { + for (const entry of await allEntries()) { + if (entry.capabilities === null) { + expect(entry.worktree, `${entry.command} never reaches a server`).toBe("any"); + } + } + }); + it("reports null capabilities for exactly the commands that never touch a Metabase server", async () => { const entries = await allEntries(); const local = entries @@ -588,6 +632,7 @@ describe("command tree contract", () => { "skills path", "upgrade", "uuid", + "worktree unpin", ]); }); }); diff --git a/packages/cli/src/runtime/command-help.ts b/packages/cli/src/runtime/command-help.ts index 43b081ef..1bef3538 100644 --- a/packages/cli/src/runtime/command-help.ts +++ b/packages/cli/src/runtime/command-help.ts @@ -2,6 +2,7 @@ import type { ArgDef, ArgsDef, CommandDef, CommandMeta } from "citty"; import { z } from "zod"; import { Capabilities } from "@metabase/client/version/capabilities"; +import { WorktreePolicy } from "../core/worktree-scope"; import { resolveCitty, toAliasArray } from "./citty"; import { getMetabaseAugment, type MetabaseAugment } from "./command-augment"; @@ -32,6 +33,7 @@ export const CommandHelpEntry = z.object({ inputSchema: z.unknown().nullable(), outputSchema: z.unknown().nullable(), capabilities: Capabilities.nullable(), + worktree: WorktreePolicy, }); export type CommandHelpEntry = z.infer; @@ -55,6 +57,7 @@ const EMPTY_AUGMENT: MetabaseAugment = { inputSchema: null, outputSchema: null, capabilities: null, + worktree: "any", }; export async function buildHelpEntry( @@ -73,6 +76,7 @@ export async function buildHelpEntry( inputSchema: augment.inputSchema ? z.toJSONSchema(augment.inputSchema) : null, outputSchema: augment.outputSchema ? z.toJSONSchema(augment.outputSchema) : null, capabilities: augment.capabilities, + worktree: augment.worktree, }; if (augment.details !== null) { entry.details = augment.details; diff --git a/packages/cli/src/runtime/verb-chain.ts b/packages/cli/src/runtime/verb-chain.ts new file mode 100644 index 00000000..5f35d9af --- /dev/null +++ b/packages/cli/src/runtime/verb-chain.ts @@ -0,0 +1,12 @@ +// Citty hands a leaf command raw args already stripped of the verbs that reached it, so the leaf +// knows only its own name. The entry still holds the whole argv and records the chain here, letting +// a runtime refusal name the command the way the user typed it. +let verbChain: string | null = null; + +export function setVerbChain(chain: string | null): void { + verbChain = chain; +} + +export function readVerbChain(): string | null { + return verbChain; +} diff --git a/packages/client/README.md b/packages/client/README.md index a450b28f..284f4112 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -396,10 +396,15 @@ Every Metabase resource exports a full schema and a compact projection: `Card`/` `Collection`/`CollectionCompact`, `Dashboard`/`DashboardCompact`, `Database`/`DatabaseCompact`, `Document`, `Field`, `FieldValues`, `Library`, `Measure`, `Notification`, `ParameterValues`, `Pulse`, `SearchResult`, `Segment`, `Setting`, `Snippet`, `Table`, `Timeline`, `TimelineEvent`, `Transform`, -`TransformRun`, `TransformJob`, `TransformTag`, `CurrentUser`, `CardQueryResult`, +`TransformRun`, `TransformJob`, `TransformTag`, `Worktree`, `CurrentUser`, `CardQueryResult`, `EidTranslateResult`, `SetupResult`, `SyncTask`, `SyncDirtyItem`, `DashboardTab`, and the nested shapes they compose (`Dashcard`, `CollectionItem`, `PulseChannel`, `NotificationHandler`, …). +Content that git-sync can check out onto a branch carries a `worktree_id`: null in the main app, and +the id of the remote-sync worktree the row belongs to otherwise. It is on `Transform`, `TransformTag`, +`Snippet`, `Collection` and `Card`, and the matching list methods take the server's own `"worktree-id"` +query parameter to list one worktree's rows instead of the main app's. + The full schema is `.loose()`, so server-side additions do not break parsing. The compact projection is `.pick(…).strip()` — the agent-facing contract, and the shape list commands render. Schemas carry what drives query writing and content selection; sync flags, fingerprints, and timestamps are @@ -407,7 +412,8 @@ deliberately absent. A second class of schema describes a single response shape that has no compact pair: `DashboardDetail`, `DatabaseSyncResult`, `CollectionTreeNode`, `FieldSummary`, `SettingValue`, -`TableQueryMetadata`, `SessionProperties`, and `TokenFeatures`. +`TableQueryMetadata`, `SessionProperties`, `TokenFeatures`, and `SyncExportPreflight` with the +`SyncMergeSummary` and `SyncForcePushCasualties` it composes. Request bodies (`CreateInput`, `UpdateInput`) and the domain vocabulary enums (`FieldBaseType`, `SearchModel`, `CollectionItemModel`, …) live in the same modules and are reached diff --git a/packages/client/src/domain/card.ts b/packages/client/src/domain/card.ts index 5843323c..212d2870 100644 --- a/packages/client/src/domain/card.ts +++ b/packages/client/src/domain/card.ts @@ -54,6 +54,7 @@ export const Card = z database_id: z.number().int().nullable(), table_id: z.number().int().nullable(), collection_id: z.number().int().nullable(), + worktree_id: z.number().int().positive().nullable().optional(), dashboard_id: z.number().int().nullable().optional(), entity_id: z.string().nullable(), creator_id: z.number().int(), diff --git a/packages/client/src/domain/collection.ts b/packages/client/src/domain/collection.ts index 42d4517c..756e14cf 100644 --- a/packages/client/src/domain/collection.ts +++ b/packages/client/src/domain/collection.ts @@ -73,6 +73,7 @@ export const Collection = z // hydrate it to a boolean, but raw-row responses (e.g. publish's target_collection) can // carry the column verbatim. is_remote_synced: z.boolean().nullable().optional(), + worktree_id: z.number().int().positive().nullable().optional(), }) .loose(); export type Collection = z.infer; @@ -88,6 +89,7 @@ export const CollectionCompact = Collection.pick({ authority_level: true, is_personal: true, is_remote_synced: true, + worktree_id: true, }).strip(); export type CollectionCompact = z.infer; @@ -151,6 +153,7 @@ export const CollectionCreateInput = z parent_id: z.number().int().positive().nullable().optional(), namespace: CollectionNamespace.nullable().optional(), authority_level: CollectionAuthorityLevel.nullable().optional(), + worktree_id: z.number().int().positive().nullable().optional(), }) .loose(); export type CollectionCreateInput = z.infer; diff --git a/packages/client/src/domain/git-sync.ts b/packages/client/src/domain/git-sync.ts index 9df41fa1..12d2bd14 100644 --- a/packages/client/src/domain/git-sync.ts +++ b/packages/client/src/domain/git-sync.ts @@ -47,6 +47,7 @@ export const SyncTask = z cancelled: z.boolean().nullable().optional(), error_message: z.string().nullable().optional(), conflicts: z.array(z.string()).nullable().optional(), + worktree_id: z.number().int().positive().nullable().optional(), }) .loose(); export type SyncTask = z.infer; @@ -91,9 +92,37 @@ export const SyncRemoteChanges = z.object({ remote_version: z.string().nullable(), local_version: z.string().nullable(), cached: z.boolean(), + branch_missing: z.boolean().optional(), }); export type SyncRemoteChanges = z.infer; +export const SyncMergeSummary = z.object({ + added: z.number().int(), + updated: z.number().int(), + removed: z.number().int(), +}); +export type SyncMergeSummary = z.infer; + +// Remote content a force push would discard, as human-readable entity labels: `deleted` is what +// would go away entirely, `overwritten` what would lose its remote-side edits. +export const SyncForcePushCasualties = z.object({ + deleted: z.array(z.string()), + overwritten: z.array(z.string()), +}); +export type SyncForcePushCasualties = z.infer; + +// `reason` is "history-rewritten" when the remote was force-pushed or rebased, so there is no merge +// base to compare against, and null when the comparison ran normally. +export const SyncExportPreflight = z.object({ + has_changes: z.boolean(), + clean: z.boolean(), + conflicts: z.array(z.string()), + summary: SyncMergeSummary, + force_push_casualties: SyncForcePushCasualties, + reason: z.string().nullable(), +}); +export type SyncExportPreflight = z.infer; + export const SyncBranchCreated = z.object({ status: z.literal("success"), message: z.string(), diff --git a/packages/client/src/domain/snippet.ts b/packages/client/src/domain/snippet.ts index 9b15f781..0904ffd5 100644 --- a/packages/client/src/domain/snippet.ts +++ b/packages/client/src/domain/snippet.ts @@ -8,6 +8,7 @@ export const Snippet = z content: z.string(), archived: z.boolean(), collection_id: z.number().int().nullable(), + worktree_id: z.number().int().positive().nullable().optional(), creator_id: z.number().int(), entity_id: z.string().nullable(), template_tags: z.record(z.string(), z.unknown()).nullable(), @@ -23,6 +24,7 @@ export const SnippetCompact = Snippet.pick({ description: true, archived: true, collection_id: true, + worktree_id: true, }).strip(); export type SnippetCompact = z.infer; @@ -36,6 +38,7 @@ export const SnippetCreateInput = z content: z.string(), description: z.string().nullable().optional(), collection_id: z.number().int().positive().nullable().optional(), + worktree_id: z.number().int().positive().nullable().optional(), }) .loose(); export type SnippetCreateInput = z.infer; diff --git a/packages/client/src/domain/transform-tag.ts b/packages/client/src/domain/transform-tag.ts index edb89942..496f2efc 100644 --- a/packages/client/src/domain/transform-tag.ts +++ b/packages/client/src/domain/transform-tag.ts @@ -6,6 +6,7 @@ export const TransformTag = z name: z.string(), entity_id: z.string().nullable(), built_in_type: z.string().nullable(), + worktree_id: z.number().int().positive().nullable().optional(), }) .loose(); export type TransformTag = z.infer; @@ -14,15 +15,21 @@ export const TransformTagCompact = TransformTag.pick({ id: true, name: true, built_in_type: true, + worktree_id: true, }).strip(); export type TransformTagCompact = z.infer; export const TransformTagCreateInput = z .object({ name: z.string().min(1), + worktree_id: z.number().int().positive().nullable().optional(), }) .loose(); export type TransformTagCreateInput = z.infer; -export const TransformTagUpdateInput = TransformTagCreateInput; -export type TransformTagUpdateInput = TransformTagCreateInput; +export const TransformTagUpdateInput = z + .object({ + name: z.string().min(1), + }) + .loose(); +export type TransformTagUpdateInput = z.infer; diff --git a/packages/client/src/domain/transform.ts b/packages/client/src/domain/transform.ts index 22eba827..edd4bfc9 100644 --- a/packages/client/src/domain/transform.ts +++ b/packages/client/src/domain/transform.ts @@ -191,6 +191,7 @@ export const Transform = z updated_at: z.string(), creator_id: z.number().int(), collection_id: z.number().int().nullable(), + worktree_id: z.number().int().positive().nullable().optional(), run_trigger: TransformRunTrigger.nullable().optional(), last_run: TransformLastRun.nullable().optional(), tag_ids: z.array(z.number().int()).optional(), @@ -204,6 +205,7 @@ export const TransformCompact = Transform.pick({ description: true, source_type: true, target_db_id: true, + worktree_id: true, }) .strip() .extend({ target: TransformTargetCompact }); @@ -218,6 +220,7 @@ export const TransformCreateInput = z run_trigger: TransformRunTrigger.optional(), tag_ids: z.array(z.number().int().positive()).optional(), collection_id: z.number().int().positive().nullable().optional(), + worktree_id: z.number().int().positive().nullable().optional(), owner_user_id: z.number().int().positive().nullable().optional(), owner_email: z.string().nullable().optional(), }) diff --git a/packages/client/src/domain/worktree.ts b/packages/client/src/domain/worktree.ts new file mode 100644 index 00000000..a07e9eb4 --- /dev/null +++ b/packages/client/src/domain/worktree.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +// The server hydrates `creator` from its own user summary, which carries more than a worktree +// listing needs; only the id is depended on here. +const WorktreeCreator = z + .object({ + id: z.number().int().positive(), + email: z.email().optional(), + common_name: z.string().optional(), + }) + .loose(); + +export const Worktree = z + .object({ + id: z.number().int().positive(), + branch: z.string(), + creator_id: z.number().int().positive().nullable().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), + creator: WorktreeCreator.nullable().optional(), + }) + .loose(); +export type Worktree = z.infer; + +export const WorktreeCompact = Worktree.pick({ + id: true, + branch: true, + creator_id: true, + created_at: true, +}).strip(); +export type WorktreeCompact = z.infer; + +export const WorktreeCreateInput = z.object({ + branch: z.string().min(1), +}); +export type WorktreeCreateInput = z.infer; diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 7a46af25..90a5f6f3 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -43,6 +43,7 @@ import { TransformTagCreateInput, TransformTagUpdateInput, TransformUpdateInput, + WorktreeCreateInput, } from "./index"; import type { ClientCredentials, @@ -106,6 +107,7 @@ const writePath = { TransformTagCreateInput: pin(TransformTagCreateInput), TransformTagUpdateInput: pin(TransformTagUpdateInput), TransformUpdateInput: pin(TransformUpdateInput), + WorktreeCreateInput: pin(WorktreeCreateInput), }; const INPUT_SUFFIX = "Input"; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 356dcb0a..9a6dc6f3 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -158,8 +158,11 @@ export { SyncBranchCreated, SyncDirtyItem, SyncDirtyItemCompact, + SyncExportPreflight, SyncExportResult, + SyncForcePushCasualties, SyncImportResult, + SyncMergeSummary, SyncRemoteChanges, SyncSettingsUpdateResult, SyncStashResult, @@ -274,3 +277,4 @@ export { } from "./domain/transform-tag"; export { UploadResult, UploadUpdateAction, UploadUpdateResult } from "./domain/upload"; export { CurrentUser, CurrentUserCompact } from "./domain/user"; +export { Worktree, WorktreeCompact, WorktreeCreateInput } from "./domain/worktree"; diff --git a/packages/client/src/resources/card.test.ts b/packages/client/src/resources/card.test.ts index bca10fb7..490e1c81 100644 --- a/packages/client/src/resources/card.test.ts +++ b/packages/client/src/resources/card.test.ts @@ -65,6 +65,21 @@ describe("card resource wire requests", () => { ]); }); + it("sends the worktree scope as a worktree-id list query parameter", async () => { + const { mb, capture } = clientOver([jsonResponse([{ ...CARD, worktree_id: 3 }])]); + + await mb.card.list({ "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/card?worktree-id=3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + it("omits an unset list parameter from the query string", async () => { const { mb, capture } = clientOver([jsonResponse([CARD])]); diff --git a/packages/client/src/resources/card.ts b/packages/client/src/resources/card.ts index cbc85be3..d47f5738 100644 --- a/packages/client/src/resources/card.ts +++ b/packages/client/src/resources/card.ts @@ -18,6 +18,7 @@ const CardApiList = z.array(Card); export interface CardListParams { f?: CardListFilter | undefined; model_id?: string | undefined; + "worktree-id"?: number | undefined; } export interface CardQueryParams { @@ -31,14 +32,17 @@ export interface CardExportParams { } export function cardResource(transport: Transport) { - /** List cards. `f` picks a server-side preset; `model_id` scopes the presets that need an id. */ + /** + * List cards. `f` picks a server-side preset, `model_id` scopes the presets that need an id, and + * `worktree-id` lists only the cards checked out into that remote-sync worktree. + */ async function list( params: CardListParams = {}, options: RequestOptions = {}, ): Promise> { const data = await transport.requestParsed(CardApiList, "/api/card", { ...options, - query: { f: params.f, model_id: params.model_id }, + query: { f: params.f, model_id: params.model_id, "worktree-id": params["worktree-id"] }, }); return { data, total: null }; } diff --git a/packages/client/src/resources/collection.test.ts b/packages/client/src/resources/collection.test.ts index 5e019eb8..f331d0c0 100644 --- a/packages/client/src/resources/collection.test.ts +++ b/packages/client/src/resources/collection.test.ts @@ -121,6 +121,21 @@ describe("collection resource wire requests", () => { ]); }); + it("sends the worktree scope alongside the filter preset as query parameters", async () => { + const { mb, capture } = clientOver([jsonResponse([{ ...COLLECTION, worktree_id: 3 }])]); + + await mb.collection.list({ filter: "archived", "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection?archived=true&worktree-id=3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + it("sends the get request for a numeric id", async () => { const { mb, capture } = clientOver([jsonResponse(COLLECTION)]); @@ -235,6 +250,22 @@ describe("collection resource wire requests", () => { ]); }); + it("sends the worktree scope as a worktree-id items query parameter", async () => { + const { mb, capture } = clientOver([jsonResponse({ data: [COLLECTION_ITEM], total: 1 })]); + + const pages = mb.collection.itemPages("root", { "worktree-id": 3 }); + await pages[Symbol.asyncIterator]().next(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection/root/items?worktree-id=3&limit=50&offset=0", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + it("sends the tree request", async () => { const { mb, capture } = clientOver([jsonResponse([TREE_NODE])]); @@ -249,6 +280,21 @@ describe("collection resource wire requests", () => { }, ]); }); + + it("sends the worktree scope as a worktree-id tree query parameter", async () => { + const { mb, capture } = clientOver([jsonResponse([TREE_NODE])]); + + await mb.collection.tree({ "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection/tree?worktree-id=3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); }); describe("collection resource results", () => { diff --git a/packages/client/src/resources/collection.ts b/packages/client/src/resources/collection.ts index 2dafa780..0a5db7a0 100644 --- a/packages/client/src/resources/collection.ts +++ b/packages/client/src/resources/collection.ts @@ -33,12 +33,18 @@ const DEFAULT_LIST_FILTER = "all"; export interface CollectionListParams { filter?: CollectionListFilter | undefined; + "worktree-id"?: number | undefined; } export interface CollectionItemListParams { models?: CollectionItemFilterModel[] | undefined; archived?: boolean | undefined; pinned_state?: CollectionPinnedState | undefined; + "worktree-id"?: number | undefined; +} + +export interface CollectionTreeParams { + "worktree-id"?: number | undefined; } // The walk's own settings, minus the query the method builds from `CollectionItemListParams`. @@ -69,14 +75,18 @@ export async function listCollectionsWithLibrary( } export function collectionResource(transport: Transport) { - /** List collections. `filter` picks a server-side preset: everything, archived, or personal. */ + /** + * List collections. `filter` picks a server-side preset — everything, archived, or personal — and + * `worktree-id` lists only the collections checked out into that remote-sync worktree. + */ async function list( params: CollectionListParams = {}, options: RequestOptions = {}, ): Promise> { + const preset = COLLECTION_LIST_QUERY[params.filter ?? DEFAULT_LIST_FILTER]; const data = await transport.requestParsed(CollectionApiList, "/api/collection", { ...options, - query: COLLECTION_LIST_QUERY[params.filter ?? DEFAULT_LIST_FILTER], + query: { ...preset, "worktree-id": params["worktree-id"] }, }); return { data, total: null }; } @@ -128,7 +138,8 @@ export function collectionResource(transport: Transport) { /** * Walk the items inside a collection one page at a time. This endpoint pages on the server, so - * the caller consumes pages rather than a single list and decides how far to pull. + * the caller consumes pages rather than a single list and decides how far to pull. `worktree-id` + * walks the `root` alias inside a remote-sync worktree rather than the main app. */ function itemPages( ref: CollectionId, @@ -140,6 +151,7 @@ export function collectionResource(transport: Transport) { models: params.models, archived: params.archived, pinned_state: params.pinned_state, + "worktree-id": params["worktree-id"], }, ...(options.offset !== undefined && { offset: options.offset }), ...(options.max !== undefined && { max: options.max }), @@ -148,10 +160,17 @@ export function collectionResource(transport: Transport) { }); } - /** Fetch the collection hierarchy as a forest of nested nodes. */ - async function tree(options: RequestOptions = {}): Promise> { + /** + * Fetch the collection hierarchy as a forest of nested nodes. `worktree-id` fetches the hierarchy + * a remote-sync worktree checked out rather than the main app's. + */ + async function tree( + params: CollectionTreeParams = {}, + options: RequestOptions = {}, + ): Promise> { const data = await transport.requestParsed(CollectionTreeApiList, "/api/collection/tree", { ...options, + query: { "worktree-id": params["worktree-id"] }, }); return { data, total: null }; } diff --git a/packages/client/src/resources/git-sync.test.ts b/packages/client/src/resources/git-sync.test.ts index 9447b203..2228704e 100644 --- a/packages/client/src/resources/git-sync.test.ts +++ b/packages/client/src/resources/git-sync.test.ts @@ -20,6 +20,23 @@ const RUNNING_TASK = { const SETTLED_TASK = { ...RUNNING_TASK, status: "successful", progress: 1 }; +const WORKTREE = { + id: 3, + branch: "feat/pricing", + creator_id: 1, + created_at: "2026-05-21T00:00:00Z", + updated_at: "2026-05-21T00:00:00Z", +}; + +const EXPORT_PREFLIGHT = { + has_changes: true, + clean: false, + conflicts: ["Transform Daily orders"], + summary: { added: 2, updated: 1, removed: 0 }, + force_push_casualties: { deleted: ["Card Revenue"], overwritten: ["Transform Daily orders"] }, + reason: null, +}; + const DIRTY_ITEM = { id: 4, name: "Orders", @@ -100,7 +117,22 @@ describe("git-sync resource wire requests", () => { expect(await mb.gitSync.currentTask()).toBeNull(); }); - it("sends the cancel request as a POST without a body", async () => { + it("sends the current-task request in a worktree scope as a worktree-id query", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...RUNNING_TASK, worktree_id: 3 })]); + + await mb.gitSync.currentTask({ "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task?worktree-id=3", + method: "GET", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the cancel request as a POST carrying the scope it cancels in", async () => { const { mb, capture } = clientOver([jsonResponse({ ...RUNNING_TASK, cancelled: true })]); await mb.gitSync.cancelTask(); @@ -109,8 +141,25 @@ describe("git-sync resource wire requests", () => { { url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task/cancel", method: "POST", - headers: JSON_READ_HEADERS, - body: null, + headers: JSON_WRITE_HEADERS, + body: "{}", + }, + ]); + }); + + it("names the worktree to cancel in as a worktree_id body field", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ ...RUNNING_TASK, worktree_id: 3, cancelled: true }), + ]); + + await mb.gitSync.cancelTask({ worktree_id: 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task/cancel", + method: "POST", + headers: JSON_WRITE_HEADERS, + body: '{"worktree_id":3}', }, ]); }); @@ -130,6 +179,21 @@ describe("git-sync resource wire requests", () => { ]); }); + it("sends the is-dirty request for a worktree as a worktree-id query", async () => { + const { mb, capture } = clientOver([jsonResponse({ is_dirty: false })]); + + await mb.gitSync.isDirty({ "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/is-dirty?worktree-id=3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + it("unwraps the is-dirty envelope to the flag itself", async () => { const { mb } = clientOver([jsonResponse({ is_dirty: true })]); @@ -151,6 +215,21 @@ describe("git-sync resource wire requests", () => { ]); }); + it("sends the dirty listing request for a worktree as a worktree-id query", async () => { + const { mb, capture } = clientOver([jsonResponse({ dirty: [DIRTY_ITEM] })]); + + await mb.gitSync.dirty({ "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/dirty?worktree-id=3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + it("reports no total for the dirty listing, which the server does not count", async () => { const { mb } = clientOver([jsonResponse({ dirty: [DIRTY_ITEM] })]); @@ -201,19 +280,100 @@ describe("git-sync resource wire requests", () => { ]); }); + it("sends both the force-refresh flag and the worktree scope as query parameters", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ + has_changes: false, + remote_version: null, + local_version: null, + cached: false, + branch_missing: true, + }), + ]); + + await mb.gitSync.hasRemoteChanges({ "force-refresh": true, "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/has-remote-changes?force-refresh=true&worktree-id=3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reads the branch_missing flag a server sends for a branch the remote has dropped", async () => { + const { mb } = clientOver([ + jsonResponse({ + has_changes: false, + remote_version: null, + local_version: "abc123", + cached: false, + branch_missing: true, + }), + ]); + + expect(await mb.gitSync.hasRemoteChanges()).toEqual({ + has_changes: false, + remote_version: null, + local_version: "abc123", + cached: false, + branch_missing: true, + }); + }); + + it("sends the export-preflight branch and worktree scope as query parameters", async () => { + const { mb, capture } = clientOver([jsonResponse(EXPORT_PREFLIGHT)]); + + await mb.gitSync.exportPreflight({ branch: "feat/pricing", "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/export-preflight?branch=feat%2Fpricing&worktree-id=3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("answers the export preflight with its conflicts, counts and force-push casualties", async () => { + const { mb } = clientOver([jsonResponse(EXPORT_PREFLIGHT)]); + + expect(await mb.gitSync.exportPreflight({ branch: "feat/pricing" })).toEqual(EXPORT_PREFLIGHT); + }); + it("sends the import request with the branch and force fields it was given", async () => { const { mb, capture } = clientOver([ jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), ]); - await mb.gitSync.import({ branch: "main", force: true }); + await mb.gitSync.import({ branch: "main", expected_branch: "main", force: true }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/import", + method: "POST", + headers: JSON_WRITE_HEADERS, + body: '{"branch":"main","expected_branch":"main","force":true}', + }, + ]); + }); + + it("sends the merge flag and the worktree the import targets in the body", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), + ]); + + await mb.gitSync.import({ expected_branch: "feat/pricing", merge: true, worktree_id: 3 }); expect(capture.calls).toEqual([ { url: "https://mb.example.com/metabase/api/ee/remote-sync/import", method: "POST", headers: JSON_WRITE_HEADERS, - body: '{"branch":"main","force":true}', + body: '{"expected_branch":"feat/pricing","merge":true,"worktree_id":3}', }, ]); }); @@ -223,7 +383,10 @@ describe("git-sync resource wire requests", () => { jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), ]); - expect(await mb.gitSync.import()).toEqual({ message: "Import queued", task_id: 12 }); + expect(await mb.gitSync.import({ expected_branch: "main" })).toEqual({ + message: "Import queued", + task_id: 12, + }); }); it("polls the current task after the import POST when a wait schedule is given", async () => { @@ -233,14 +396,14 @@ describe("git-sync resource wire requests", () => { jsonResponse(SETTLED_TASK), ]); - await mb.gitSync.import({ wait: IMMEDIATE_POLL }); + await mb.gitSync.import({ expected_branch: "main", wait: IMMEDIATE_POLL }); expect(capture.calls).toEqual([ { url: "https://mb.example.com/metabase/api/ee/remote-sync/import", method: "POST", headers: JSON_WRITE_HEADERS, - body: "{}", + body: '{"expected_branch":"main"}', }, { url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task", @@ -257,13 +420,41 @@ describe("git-sync resource wire requests", () => { ]); }); + it("polls the worktree's own task after an import into that worktree", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), + jsonResponse({ ...SETTLED_TASK, worktree_id: 3 }), + ]); + + await mb.gitSync.import({ + expected_branch: "feat/pricing", + worktree_id: 3, + wait: IMMEDIATE_POLL, + }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/import", + method: "POST", + headers: JSON_WRITE_HEADERS, + body: '{"expected_branch":"feat/pricing","worktree_id":3}', + }, + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task?worktree-id=3", + method: "GET", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + it("reports the settled task alongside the import that started it", async () => { const { mb } = clientOver([ jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), jsonResponse(SETTLED_TASK), ]); - expect(await mb.gitSync.import({ wait: IMMEDIATE_POLL })).toEqual({ + expect(await mb.gitSync.import({ expected_branch: "main", wait: IMMEDIATE_POLL })).toEqual({ message: "Import queued", task_id: 12, final: SETTLED_TASK, @@ -275,14 +466,14 @@ describe("git-sync resource wire requests", () => { jsonResponse({ status: "success", task_id: null, message: "Already up to date" }), ]); - await mb.gitSync.import({ wait: IMMEDIATE_POLL }); + await mb.gitSync.import({ expected_branch: "main", wait: IMMEDIATE_POLL }); expect(capture.calls).toEqual([ { url: "https://mb.example.com/metabase/api/ee/remote-sync/import", method: "POST", headers: JSON_WRITE_HEADERS, - body: "{}", + body: '{"expected_branch":"main"}', }, ]); }); @@ -302,13 +493,28 @@ describe("git-sync resource wire requests", () => { ]); }); + it("sends the merge flag and the worktree the export pushes from in the body", async () => { + const { mb, capture } = clientOver([jsonResponse({ message: "Export queued", task_id: 8 })]); + + await mb.gitSync.export({ branch: "feat/pricing", merge: true, worktree_id: 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/export", + method: "POST", + headers: JSON_WRITE_HEADERS, + body: '{"branch":"feat/pricing","merge":true,"worktree_id":3}', + }, + ]); + }); + it("reports the settled task alongside the export that started it", async () => { const { mb } = clientOver([ jsonResponse({ message: "Export queued", task_id: 8 }), jsonResponse(SETTLED_TASK), ]); - expect(await mb.gitSync.export({ wait: IMMEDIATE_POLL })).toEqual({ + expect(await mb.gitSync.export({ branch: "main", wait: IMMEDIATE_POLL })).toEqual({ message: "Export queued", task_id: 8, final: SETTLED_TASK, @@ -381,6 +587,89 @@ describe("git-sync resource wire requests", () => { ]); }); + it("sends create-branch with checkout false to leave the instance where it is", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ status: "success", message: "Created feat/x" }), + ]); + + await mb.gitSync.createBranch({ name: "feat/x", checkout: false }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/create-branch", + method: "POST", + headers: JSON_WRITE_HEADERS, + body: '{"name":"feat/x","checkout":false}', + }, + ]); + }); + + it("sends the worktree listing request", async () => { + const { mb, capture } = clientOver([jsonResponse([WORKTREE])]); + + await mb.gitSync.worktrees(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/worktree", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reports no total for the worktree listing, which the server does not count", async () => { + const { mb } = clientOver([jsonResponse([WORKTREE])]); + + expect(await mb.gitSync.worktrees()).toEqual({ data: [WORKTREE], total: null }); + }); + + it("sends the single-worktree request under the worktree's id", async () => { + const { mb, capture } = clientOver([jsonResponse(WORKTREE)]); + + await mb.gitSync.getWorktree(3); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/worktree/3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the worktree create request with only the branch in the body", async () => { + const { mb, capture } = clientOver([jsonResponse(WORKTREE)]); + + await mb.gitSync.createWorktree({ branch: "feat/pricing" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/worktree", + method: "POST", + headers: JSON_WRITE_HEADERS, + body: '{"branch":"feat/pricing"}', + }, + ]); + }); + + it("sends the worktree delete request and expects no JSON back", async () => { + const { mb, capture } = clientOver([noContent()]); + + await mb.gitSync.deleteWorktree(3); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/worktree/3", + method: "DELETE", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + it("sends the collection sync flag as a settings PUT keyed by collection id", async () => { const { mb, capture } = clientOver([jsonResponse({ success: true, task_id: 3 })]); @@ -557,6 +846,21 @@ describe("git-sync resource wire requests", () => { ]); }); + it("polls the worktree's own current task when the wait is scoped to one", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...SETTLED_TASK, worktree_id: 3 })]); + + await mb.gitSync.waitForTask(IMMEDIATE_POLL, { "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task?worktree-id=3", + method: "GET", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + it("stops waiting at once when the server reports no task", async () => { const { mb } = clientOver([noContent()]); diff --git a/packages/client/src/resources/git-sync.ts b/packages/client/src/resources/git-sync.ts index 140138c8..adb388bf 100644 --- a/packages/client/src/resources/git-sync.ts +++ b/packages/client/src/resources/git-sync.ts @@ -5,6 +5,7 @@ import { isSyncTaskTerminal, SyncBranchCreated, SyncDirtyItem, + SyncExportPreflight, type SyncExportResult, type SyncImportResult, SyncRemoteChanges, @@ -12,6 +13,7 @@ import { type SyncStashResult, SyncTask, } from "../domain/git-sync"; +import { Worktree, type WorktreeCreateInput } from "../domain/worktree"; import { HttpError } from "../http/errors"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; @@ -53,6 +55,10 @@ const SyncStashStarted = z.object({ task_id: z.number().int().positive(), }); +// `GET /api/ee/remote-sync/worktree` answers a bare array rather than a `{ data, total }` envelope, +// so the list carries no server-reported count. +const WorktreeApiList = z.array(Worktree); + const RemoteSyncSetting = z.string().nullable(); const FORBIDDEN_STATUS = 403; @@ -67,25 +73,53 @@ function isRemoteUnreadable(error: unknown): boolean { return error.status === FORBIDDEN_STATUS || error.status === UNREGISTERED_STATUS; } +// A write names its scope as `worktree_id`; the task poll that follows it reads the same scope back +// under the query parameter's name. +function scopeOf(worktreeId: number | undefined): SyncScopeQuery { + return { "worktree-id": worktreeId }; +} + // Presence of `wait` is the choice to block: the schedule is the caller's, the terminal condition is // the server's. export interface SyncWaitParams { wait?: PollOptions | undefined; } -export interface SyncRemoteChangesParams { +// A remote-sync operation runs either against the main app or inside one worktree. Absent means the +// main app; the server takes the scope as a `worktree-id` query parameter on reads and a +// `worktree_id` body field on writes, and this follows those names verbatim. +export interface SyncScopeQuery { + "worktree-id"?: number | undefined; +} + +export interface SyncRemoteChangesParams extends SyncScopeQuery { "force-refresh"?: boolean | undefined; } +export interface SyncExportPreflightParams extends SyncScopeQuery { + branch: string; +} + +export interface SyncCancelTaskParams { + worktree_id?: number | undefined; +} + +// `expected_branch` is the branch the caller believes the scope is on: the server rejects the +// import outright when it disagrees, rather than pulling a branch the caller did not mean. export interface SyncImportParams extends SyncWaitParams { branch?: string | undefined; + expected_branch: string; force?: boolean | undefined; + merge?: boolean | undefined; + worktree_id?: number | undefined; } export interface SyncExportParams extends SyncWaitParams { - branch?: string | undefined; + branch: string; message?: string | undefined; force?: boolean | undefined; + merge?: boolean | undefined; + worktree_id?: number | undefined; } export interface SyncStashParams extends SyncWaitParams { @@ -95,41 +129,73 @@ export interface SyncStashParams extends SyncWaitParams { export interface SyncCreateBranchParams { name: string; + checkout?: boolean | undefined; } export function gitSyncResource(transport: Transport) { - /** Get the running or most recently finished sync task, or null when the server has none. */ - async function currentTask(options: RequestOptions = {}): Promise { - return fetchOptionalParsed(transport, "/api/ee/remote-sync/current-task", SyncTask, options); + /** + * Get the running or most recently finished sync task in the scope, or null when the server has + * none. `worktree-id` asks about a worktree's tasks instead of the main app's. + */ + async function currentTask( + params: SyncScopeQuery = {}, + options: RequestOptions = {}, + ): Promise { + return fetchOptionalParsed(transport, "/api/ee/remote-sync/current-task", SyncTask, { + ...options, + query: { "worktree-id": params["worktree-id"] }, + }); } - /** Request cancellation of the running sync task, and answer it in the state that left it. */ - async function cancelTask(options: RequestOptions = {}): Promise { + /** + * Request cancellation of the running sync task, and answer it in the state that left it. + * `worktree_id` cancels a worktree's task instead of the main app's. + */ + async function cancelTask( + params: SyncCancelTaskParams = {}, + options: RequestOptions = {}, + ): Promise { return transport.requestParsed(SyncTask, "/api/ee/remote-sync/current-task/cancel", { ...options, method: "POST", + body: { worktree_id: params.worktree_id }, }); } - /** Whether Metabase holds content changes the remote has not been told about. */ - async function isDirty(options: RequestOptions = {}): Promise { + /** + * Whether the scope holds content changes the remote has not been told about. `worktree-id` asks + * about a worktree instead of the main app. + */ + async function isDirty( + params: SyncScopeQuery = {}, + options: RequestOptions = {}, + ): Promise { const flag = await transport.requestParsed(SyncDirtyFlag, "/api/ee/remote-sync/is-dirty", { ...options, + query: { "worktree-id": params["worktree-id"] }, }); return flag.is_dirty; } - /** List the objects whose local state differs from the remote. */ - async function dirty(options: RequestOptions = {}): Promise> { + /** + * List the objects whose local state differs from the remote. `worktree-id` asks about a worktree + * instead of the main app. + */ + async function dirty( + params: SyncScopeQuery = {}, + options: RequestOptions = {}, + ): Promise> { const response = await transport.requestParsed(SyncDirtyList, "/api/ee/remote-sync/dirty", { ...options, + query: { "worktree-id": params["worktree-id"] }, }); return { data: response.dirty, total: null }; } /** - * Compare the tracked remote branch against what Metabase last imported. The server caches the - * comparison; `force-refresh` re-reads the remote instead. + * Compare the branch the scope tracks against what Metabase last imported. The server caches the + * comparison; `force-refresh` re-reads the remote instead, and `worktree-id` checks a worktree's + * own branch rather than the main app's. */ async function hasRemoteChanges( params: SyncRemoteChangesParams = {}, @@ -137,51 +203,86 @@ export function gitSyncResource(transport: Transport) { ): Promise { return transport.requestParsed(SyncRemoteChanges, "/api/ee/remote-sync/has-remote-changes", { ...options, - query: { "force-refresh": params["force-refresh"] }, + query: { + "force-refresh": params["force-refresh"], + "worktree-id": params["worktree-id"], + }, + }); + } + + /** + * Dry-run preview of what pushing the scope's current state would do against the live remote + * branch, writing nothing: whether the remote has advanced, whether a three-way merge would + * apply cleanly, the entities that conflict, the counts a merge would fold in, and the remote + * content a force push would discard instead. + */ + async function exportPreflight( + params: SyncExportPreflightParams, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(SyncExportPreflight, "/api/ee/remote-sync/export-preflight", { + ...options, + query: { branch: params.branch, "worktree-id": params["worktree-id"] }, }); } /** - * Import content from the remote into Metabase. The endpoint queues a task and returns at once; + * Import content from the remote into the scope. The endpoint queues a task and returns at once; * pass `wait` to poll that task until it reaches a terminal status. A server already up to date - * answers no task id, and there is then nothing to poll. + * answers no task id, and there is then nothing to poll. Inside a worktree the worktree's own + * branch is the target and `branch` is ignored. */ async function importFromRemote( - params: SyncImportParams = {}, + params: SyncImportParams, options: RequestOptions = {}, ): Promise { const started = await transport.requestParsed(SyncImportStarted, "/api/ee/remote-sync/import", { ...options, method: "POST", - body: { branch: params.branch, force: params.force }, + body: { + branch: params.branch, + expected_branch: params.expected_branch, + force: params.force, + merge: params.merge, + worktree_id: params.worktree_id, + }, }); const message = started.message ?? null; if (params.wait === undefined || started.task_id === null) { return { message, task_id: started.task_id }; } - return { message, task_id: started.task_id, final: await settle(params.wait, options) }; + const scope = scopeOf(params.worktree_id); + return { message, task_id: started.task_id, final: await settle(params.wait, scope, options) }; } /** - * Export Metabase's content to the remote. The endpoint queues a task and returns at once; pass - * `wait` to poll that task until it reaches a terminal status. + * Export the scope's content to the remote. The endpoint queues a task and returns at once; pass + * `wait` to poll that task until it reaches a terminal status. `branch` must be the branch the + * scope is already on — the worktree's own inside a worktree, the tracked one in the main app. */ async function exportToRemote( - params: SyncExportParams = {}, + params: SyncExportParams, options: RequestOptions = {}, ): Promise { const started = await transport.requestParsed(SyncExportStarted, "/api/ee/remote-sync/export", { ...options, method: "POST", - body: { branch: params.branch, message: params.message, force: params.force }, + body: { + branch: params.branch, + message: params.message, + force: params.force, + merge: params.merge, + worktree_id: params.worktree_id, + }, }); if (params.wait === undefined) { return { message: started.message, task_id: started.task_id }; } + const scope = scopeOf(params.worktree_id); return { message: started.message, task_id: started.task_id, - final: await settle(params.wait, options), + final: await settle(params.wait, scope, options), }; } @@ -205,7 +306,7 @@ export function gitSyncResource(transport: Transport) { status: started.status, message: started.message, task_id: started.task_id, - final: await settle(params.wait, options), + final: await settle(params.wait, {}, options), }; } @@ -217,7 +318,11 @@ export function gitSyncResource(transport: Transport) { return { data: response.items, total: null }; } - /** Create a branch on the remote and point git-sync at it. */ + /** + * Create a branch on the remote from the branch git-sync is on. The instance checks the new + * branch out by default; `checkout: false` creates it and leaves the instance where it is, which + * is what checking a branch out into a worktree wants. + */ async function createBranch( params: SyncCreateBranchParams, options: RequestOptions = {}, @@ -225,7 +330,44 @@ export function gitSyncResource(transport: Transport) { return transport.requestParsed(SyncBranchCreated, "/api/ee/remote-sync/create-branch", { ...options, method: "POST", - body: { name: params.name }, + body: { name: params.name, checkout: params.checkout }, + }); + } + + /** List the remote-sync worktrees the caller can read. Worktrees are superuser-only. */ + async function worktrees(options: RequestOptions = {}): Promise> { + const data = await transport.requestParsed(WorktreeApiList, "/api/ee/remote-sync/worktree", { + ...options, + }); + return { data, total: null }; + } + + /** Get a single remote-sync worktree by id. */ + async function getWorktree(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(Worktree, `/api/ee/remote-sync/worktree/${id}`, { ...options }); + } + + /** + * Create a remote-sync worktree for a branch. The branch is not created here — it is expected to + * already exist on the remote — and its content is materialized by a subsequent import. + */ + async function createWorktree( + params: WorktreeCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Worktree, "/api/ee/remote-sync/worktree", { + ...options, + method: "POST", + body: { branch: params.branch }, + }); + } + + /** Delete a remote-sync worktree along with every piece of content it checked out. */ + async function deleteWorktree(id: number, options: RequestOptions = {}): Promise { + await transport.requestRaw(`/api/ee/remote-sync/worktree/${id}`, { + ...options, + method: "DELETE", + expectContentType: "binary", }); } @@ -282,19 +424,24 @@ export function gitSyncResource(transport: Transport) { } /** - * Poll the current task until it reaches a terminal status. A server with no task at all + * Poll the scope's current task until it reaches a terminal status. A server with no task at all * answers null, which is terminal in the same sense: nothing further will happen. */ async function waitForTask( wait: PollOptions, + params: SyncScopeQuery = {}, options: RequestOptions = {}, ): Promise { - return settle(wait, options); + return settle(wait, params, options); } - async function settle(wait: PollOptions, options: RequestOptions): Promise { + async function settle( + wait: PollOptions, + scope: SyncScopeQuery, + options: RequestOptions, + ): Promise { return pollUntil( - async (signal) => currentTask({ ...options, signal }), + async (signal) => currentTask(scope, { ...options, signal }), (task) => task === null || isSyncTaskTerminal(task.status), wait, ); @@ -311,6 +458,11 @@ export function gitSyncResource(transport: Transport) { stash, branches, createBranch, + exportPreflight, + worktrees, + getWorktree, + createWorktree, + deleteWorktree, setCollectionSynced, syncedCollections, remoteUrl, diff --git a/packages/client/src/resources/library.test.ts b/packages/client/src/resources/library.test.ts index 9f8d9721..835e013b 100644 --- a/packages/client/src/resources/library.test.ts +++ b/packages/client/src/resources/library.test.ts @@ -115,6 +115,20 @@ describe("library resource wire requests", () => { expect(capture.calls).toEqual([READ_ROOT_CALL, READ_COLLECTIONS_CALL]); }); + it("sends the worktree scope as a worktree-id query parameter on the Library read", async () => { + const { mb, capture } = clientOver([ + jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(LIBRARY_COLLECTIONS), + ]); + + await mb.library.get({ "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { ...READ_ROOT_CALL, url: `${ROOT_URL}?worktree-id=3` }, + READ_COLLECTIONS_CALL, + ]); + }); + it("sends the bodyless create POST and refetches when no Library exists", async () => { const { mb, capture } = clientOver([ jsonResponse(ABSENT_LIBRARY), diff --git a/packages/client/src/resources/library.ts b/packages/client/src/resources/library.ts index a78bad02..cc4e057f 100644 --- a/packages/client/src/resources/library.ts +++ b/packages/client/src/resources/library.ts @@ -39,11 +39,22 @@ export interface LibraryPublishParams extends LibraryTableSelectors { collection_id: number; } +export interface LibraryGetParams { + "worktree-id"?: number | undefined; +} + export function libraryResource(transport: Transport) { - /** Get the Library root and its child collections, or `null` on an instance that has none. */ - async function get(options: RequestOptions = {}): Promise { + /** + * Get the Library root and its child collections, or `null` on an instance that has none. + * `worktree-id` gets the Library a remote-sync worktree checked out rather than the main app's. + */ + async function get( + params: LibraryGetParams = {}, + options: RequestOptions = {}, + ): Promise { const result = await transport.requestParsed(LibraryOrAbsent, LIBRARY_ROOT_PATH, { ...options, + query: { "worktree-id": params["worktree-id"] }, }); if (!("effective_children" in result)) { return null; @@ -90,12 +101,12 @@ export function libraryResource(transport: Transport) { * back from a refetch, which together make this idempotent. */ async function create(options: RequestOptions = {}): Promise { - const existing = await get(options); + const existing = await get({}, options); if (existing !== null) { return existing; } await transport.requestRaw(LIBRARY_ROOT_PATH, { ...options, method: "POST" }); - const created = await get(options); + const created = await get({}, options); if (created === null) { throw new Error(`Library was not created after POST ${LIBRARY_ROOT_PATH}`); } diff --git a/packages/client/src/resources/optional-parsed.ts b/packages/client/src/resources/optional-parsed.ts index dc901114..933a9182 100644 --- a/packages/client/src/resources/optional-parsed.ts +++ b/packages/client/src/resources/optional-parsed.ts @@ -1,10 +1,14 @@ import type { ZodType } from "zod"; -import type { RequestOptions, Transport } from "../http/transport"; +import type { QueryValue, RequestOptions, Transport } from "../http/transport"; import { parseJsonOrPlain } from "../json"; const NO_CONTENT_STATUS = 204; +export interface OptionalParsedOptions extends RequestOptions { + query?: Record; +} + // Endpoints that answer 204 for "the value is unset" rather than 404, and answer the value itself // as either JSON or bare text depending on the server version. `null` is the unset value, not an // error. @@ -12,7 +16,7 @@ export async function fetchOptionalParsed( client: Transport, path: string, schema: ZodType, - options: RequestOptions = {}, + options: OptionalParsedOptions = {}, ): Promise { const response = await client.requestRaw(path, { ...options, diff --git a/packages/client/src/resources/search.test.ts b/packages/client/src/resources/search.test.ts index e9429726..01be4215 100644 --- a/packages/client/src/resources/search.test.ts +++ b/packages/client/src/resources/search.test.ts @@ -57,6 +57,21 @@ describe("search resource wire requests", () => { ]); }); + it("sends the worktree scope as a worktree-id search query parameter", async () => { + const { mb, capture } = clientOver([jsonResponse({ data: [SEARCH_RESULT], total: 1 })]); + + await mb.search.query({ q: "orders", "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/search?q=orders&worktree-id=3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + it("omits every unset search parameter from the query string", async () => { const { mb, capture } = clientOver([jsonResponse({ data: [], total: 0 })]); diff --git a/packages/client/src/resources/search.ts b/packages/client/src/resources/search.ts index 706383fe..166a616a 100644 --- a/packages/client/src/resources/search.ts +++ b/packages/client/src/resources/search.ts @@ -26,14 +26,16 @@ export interface SearchParams { offset?: number | undefined; table_db_id?: number | undefined; verified?: boolean | undefined; + "worktree-id"?: number | undefined; } export function searchResource(transport: Transport) { /** * Search over the instance's content, ranked against `q`. `models` narrows which kinds of entity * may match, `archived` swaps the active set for the archived one, `table_db_id` restricts to - * items on one database, `verified` to verified content, and `limit`/`offset` are the window the - * server applies before ranking hydration. + * items on one database, `verified` to verified content, `worktree-id` searches inside one + * remote-sync worktree rather than the main app, and `limit`/`offset` are the window the server + * applies before ranking hydration. */ async function query( params: SearchParams = {}, @@ -49,6 +51,7 @@ export function searchResource(transport: Transport) { offset: params.offset, table_db_id: params.table_db_id, verified: params.verified, + "worktree-id": params["worktree-id"], }, }); return { data: response.data, total: response.total }; diff --git a/packages/client/src/resources/snippet.test.ts b/packages/client/src/resources/snippet.test.ts index d923256a..5d6e8428 100644 --- a/packages/client/src/resources/snippet.test.ts +++ b/packages/client/src/resources/snippet.test.ts @@ -76,6 +76,21 @@ describe("snippet resource wire requests", () => { ]); }); + it("sends the worktree scope as a worktree-id list query parameter", async () => { + const { mb, capture } = clientOver([jsonResponse([{ ...SNIPPET, worktree_id: 3 }])]); + + await mb.snippet.list({ "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/native-query-snippet?worktree-id=3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + it("wraps the bare list array in a ListResult that claims no server count", async () => { const { mb } = clientOver([jsonResponse([SNIPPET])]); diff --git a/packages/client/src/resources/snippet.ts b/packages/client/src/resources/snippet.ts index 4414e752..fd4c03e5 100644 --- a/packages/client/src/resources/snippet.ts +++ b/packages/client/src/resources/snippet.ts @@ -10,17 +10,21 @@ const SnippetApiList = z.array(Snippet); export interface SnippetListParams { archived?: boolean | undefined; + "worktree-id"?: number | undefined; } export function snippetResource(transport: Transport) { - /** List native query snippets. `archived` swaps the listing to archived snippets. */ + /** + * List native query snippets. `archived` swaps the listing to archived snippets, and + * `worktree-id` lists only the snippets checked out into that remote-sync worktree. + */ async function list( params: SnippetListParams = {}, options: RequestOptions = {}, ): Promise> { const data = await transport.requestParsed(SnippetApiList, "/api/native-query-snippet", { ...options, - query: { archived: params.archived }, + query: { archived: params.archived, "worktree-id": params["worktree-id"] }, }); return { data, total: null }; } diff --git a/packages/client/src/resources/transform-tag.test.ts b/packages/client/src/resources/transform-tag.test.ts index e5e74ad4..b5aefe2d 100644 --- a/packages/client/src/resources/transform-tag.test.ts +++ b/packages/client/src/resources/transform-tag.test.ts @@ -65,6 +65,21 @@ describe("transform-tag resource wire requests", () => { ]); }); + it("sends the worktree scope as a worktree-id list query parameter", async () => { + const { mb, capture } = clientOver([jsonResponse([{ ...TAG, worktree_id: 3 }])]); + + await mb.transformTag.list({ "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-tag?worktree-id=3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + it("reports no total for the listing, which the server does not count", async () => { const { mb } = clientOver([jsonResponse([TAG])]); diff --git a/packages/client/src/resources/transform-tag.ts b/packages/client/src/resources/transform-tag.ts index f3cc64e2..41ba9224 100644 --- a/packages/client/src/resources/transform-tag.ts +++ b/packages/client/src/resources/transform-tag.ts @@ -12,12 +12,23 @@ import type { ListResult } from "../list"; // count a caller reads off `ListResult` is the array's own length and the server reports none. const TransformTagApiList = z.array(TransformTag); +export interface TransformTagListParams { + "worktree-id"?: number | undefined; +} + // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformTagResource(transport: Transport) { - /** List every transform tag the caller can see, built-in tags included. */ - async function list(options: RequestOptions = {}): Promise> { + /** + * List every transform tag the caller can see, built-in tags included. `worktree-id` lists only + * the tags checked out into that remote-sync worktree; without it the listing is the main app's. + */ + async function list( + params: TransformTagListParams = {}, + options: RequestOptions = {}, + ): Promise> { const data = await transport.requestParsed(TransformTagApiList, "/api/transform-tag", { ...options, + query: { "worktree-id": params["worktree-id"] }, }); return { data, total: null }; } diff --git a/packages/client/src/resources/transform.test.ts b/packages/client/src/resources/transform.test.ts index fde759c7..a4cdba83 100644 --- a/packages/client/src/resources/transform.test.ts +++ b/packages/client/src/resources/transform.test.ts @@ -120,6 +120,21 @@ describe("transform resource wire requests", () => { ]); }); + it("sends the worktree scope as a worktree-id list query parameter", async () => { + const { mb, capture } = clientOver([jsonResponse([{ ...TRANSFORM, worktree_id: 3 }])]); + + await mb.transform.list({ "worktree-id": 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform?worktree-id=3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + it("reports no total for the listing, which the server does not count", async () => { const { mb } = clientOver([jsonResponse([TRANSFORM])]); diff --git a/packages/client/src/resources/transform.ts b/packages/client/src/resources/transform.ts index 66415ff5..5f16e722 100644 --- a/packages/client/src/resources/transform.ts +++ b/packages/client/src/resources/transform.ts @@ -36,6 +36,10 @@ export interface TransformRunParams { syncTarget?: boolean | undefined; } +export interface TransformListParams { + "worktree-id"?: number | undefined; +} + export interface TransformRunPageParams { "transform-ids"?: number | undefined; } @@ -45,9 +49,18 @@ export type TransformRunPageOptions = Omit; // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformResource(transport: Transport) { - /** List every transform the caller can see. */ - async function list(options: RequestOptions = {}): Promise> { - const data = await transport.requestParsed(TransformApiList, "/api/transform", { ...options }); + /** + * List every transform the caller can see. `worktree-id` lists only the transforms checked out + * into that remote-sync worktree; without it the listing is the main app's. + */ + async function list( + params: TransformListParams = {}, + options: RequestOptions = {}, + ): Promise> { + const data = await transport.requestParsed(TransformApiList, "/api/transform", { + ...options, + query: { "worktree-id": params["worktree-id"] }, + }); return { data, total: null }; } diff --git a/tests/e2e/git-sync.e2e.test.ts b/tests/e2e/git-sync.e2e.test.ts index f791082a..7eb14d24 100644 --- a/tests/e2e/git-sync.e2e.test.ts +++ b/tests/e2e/git-sync.e2e.test.ts @@ -10,7 +10,7 @@ import { SyncStatus } from "../../packages/cli/src/commands/git-sync/status"; import { WaitResult } from "../../packages/cli/src/commands/git-sync/wait"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { cliErrorCategory, cliErrorMessage } from "./cli-error"; +import { cliErrorMessage } from "./cli-error"; import { requireServer } from "./server-gate"; // The remote-sync API has breaking server-side differences through v59 (the git source layer @@ -198,6 +198,7 @@ describe.skipIf(skipReason !== null)("git-sync e2e against EE git-sync endpoints expect(result.exitCode, result.stderr).toBe(0); expect(parseJson(result.stdout, SyncStatus)).toEqual({ branch: null, + worktree: null, is_dirty: false, current_task: null, synced_collections: [], @@ -215,26 +216,32 @@ describe.skipIf(skipReason !== null)("git-sync e2e against EE git-sync endpoints expect(parseJson(result.stdout, WaitResult)).toEqual({ status: "idle" }); }); - it("import without git-sync configured surfaces an HttpError", async () => { + it("import refuses before any request when remote-sync-branch is unset", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["git-sync", "import", "--no-wait", "--json"], configHome, env: authEnv(), }); - expect(result.exitCode).toBe(1); - expect(cliErrorCategory(result.stderr)).toBe("http"); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toBe( + "remote-sync-branch is not set; configure git-sync first", + ); + expect(result.stdout).toBe(""); }); - it("export without git-sync configured surfaces an HttpError", async () => { + it("export refuses before any request when remote-sync-branch is unset", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["git-sync", "export", "--no-wait", "--json"], configHome, env: authEnv(), }); - expect(result.exitCode).toBe(1); - expect(cliErrorCategory(result.stderr)).toBe("http"); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toBe( + "remote-sync-branch is not set; configure git-sync first", + ); + expect(result.stdout).toBe(""); }); it("has-remote-changes without git-sync configured surfaces the server's 400 message", async () => { @@ -307,3 +314,41 @@ describe.skipIf(skipReason !== null)("git-sync e2e against EE git-sync endpoints expect(parseJson(result.stdout, SyncSettingsUpdateResult)).toEqual({ success: true }); }); }); + +// `export-preflight` reaches the remote-sync API only from v63; below that the command's own +// preflight refuses first, so the branch-resolution path this asserts is not reachable. +const preflightSkipReason = requireServer( + "git-sync › export-preflight against EE remote-sync endpoints", + { minVersion: 63, tokenFeature: "remote_sync" }, +); + +describe.skipIf(preflightSkipReason !== null)( + "git-sync export-preflight against EE remote-sync endpoints", + () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + it("refuses before any request when remote-sync-branch is unset", async () => { + const configHome = await mkTempConfigHome(); + tempDirs.push(configHome); + const result = await runCli({ + args: ["git-sync", "export-preflight", "--json"], + configHome, + env: { MB_URL: bootstrap.baseUrl, MB_API_KEY: bootstrap.adminApiKey }, + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toBe( + "remote-sync-branch is not set; configure git-sync first", + ); + expect(result.stdout).toBe(""); + }); + }, +); diff --git a/tests/e2e/help-json.e2e.test.ts b/tests/e2e/help-json.e2e.test.ts index 81636e5a..dffdf41b 100644 --- a/tests/e2e/help-json.e2e.test.ts +++ b/tests/e2e/help-json.e2e.test.ts @@ -50,6 +50,22 @@ describe("--help --json e2e", () => { expect(index).toEqual(await buildHelpIndex(card, ["card"])); }); + it("exposes each command's worktree policy so an agent can see what a scope refuses", async () => { + const scoped = await runCli({ + args: ["transform", "list", "--help", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + const mainOnly = await runCli({ + args: ["transform", "run", "--help", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(scoped.exitCode, scoped.stderr).toBe(0); + expect(mainOnly.exitCode, mainOnly.stderr).toBe(0); + expect(parseJson(scoped.stdout, CommandHelpEntry).worktree).toBe("scoped"); + expect(parseJson(mainOnly.stdout, CommandHelpEntry).worktree).toBe("main-only"); + }); + it("emits the full entry with output schema and examples for a leaf command", async () => { const result = await runCli({ args: ["card", "query", "--help", "--json"], diff --git a/tests/e2e/run-cli.e2e.test.ts b/tests/e2e/run-cli.e2e.test.ts index cc5c5c93..742944ef 100644 --- a/tests/e2e/run-cli.e2e.test.ts +++ b/tests/e2e/run-cli.e2e.test.ts @@ -63,6 +63,7 @@ describe("runCli env isolation e2e", () => { tokenFeatures: null, lastProbedAt: null, lastFailure: null, + worktree: null, }); }); diff --git a/tests/e2e/worktree.e2e.test.ts b/tests/e2e/worktree.e2e.test.ts new file mode 100644 index 00000000..7c1e8e11 --- /dev/null +++ b/tests/e2e/worktree.e2e.test.ts @@ -0,0 +1,576 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import type { z } from "zod"; + +import { TransformCompact } from "@metabase/client/domain/transform"; +import { WorktreeCompact } from "@metabase/client/domain/worktree"; +import { parseJson } from "@metabase/client/json"; + +import { LoginResult } from "../../packages/cli/src/commands/auth/login"; +import { SyncStatus } from "../../packages/cli/src/commands/git-sync/status"; +import { TransformListEnvelope } from "../../packages/cli/src/commands/transform/list"; +import { WorktreeCreateResult } from "../../packages/cli/src/commands/worktree/create"; +import { WorktreeDeleteResult } from "../../packages/cli/src/commands/worktree/delete"; +import { WorktreeListEnvelope } from "../../packages/cli/src/commands/worktree/list"; +import { WorktreePinResult } from "../../packages/cli/src/commands/worktree/pin"; +import { WorktreeUnpinResult } from "../../packages/cli/src/commands/worktree/unpin"; +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cliErrorMessage } from "./cli-error"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { SEEDED } from "./seed/seeded"; +import { requireServer } from "./server-gate"; + +const WORKTREE_BRANCH = "e2e/worktree-transforms"; +const OTHER_BRANCH = "e2e/worktree-other"; +const PINNED_PROFILE = "agent"; +const TRANSFORM_NAME = "e2e_worktree_transform"; + +interface NativeQuery { + type: "native"; + database: number; + native: { query: string }; +} + +interface TransformTarget { + type: "table"; + database: number; + schema: string; + name: string; +} + +interface TransformBody { + name: string; + source: { type: "query"; query: NativeQuery }; + target: TransformTarget; +} + +function transformBody(name: string): TransformBody { + return { + name, + source: { + type: "query", + query: { + type: "native", + database: SEEDED.warehouseDbId, + native: { query: "SELECT 1 AS one" }, + }, + }, + target: { type: "table", database: SEEDED.warehouseDbId, schema: "public", name }, + }; +} + +// The worktree endpoints and the `worktree-id` scope parameter on the content endpoints ship in +// Metabase 64, behind the same `remote_sync` premium feature as the rest of git-sync. +const skipReason = requireServer("worktree › worktree lifecycle against EE remote-sync endpoints", { + minVersion: 64, + tokenFeature: "remote_sync", +}); + +describe("worktree arg validation e2e (no Metabase contact required)", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + it("get with a blank ref fails fast with ConfigError before any network call", async () => { + const result = await runCli({ + args: ["worktree", "get", " ", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toBe("invalid worktree ref: value must not be blank"); + expect(result.stdout).toBe(""); + }); + + it("get with a zero id ref fails with ConfigError", async () => { + const result = await runCli({ + args: ["worktree", "get", "0", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toBe("invalid worktree ref: 0 (must be ≥ 1)"); + expect(result.stdout).toBe(""); + }); + + it("delete with a blank ref fails fast with ConfigError before any network call", async () => { + const result = await runCli({ + args: ["worktree", "delete", " ", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toBe("invalid worktree ref: value must not be blank"); + expect(result.stdout).toBe(""); + }); + + it("unpin reports an unpinned profile without contacting a server", async () => { + const result = await runCli({ + args: ["worktree", "unpin", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, WorktreeUnpinResult)).toEqual({ + profile: "default", + unpinned: false, + }); + }); +}); + +describe.skipIf(skipReason !== null)("worktree lifecycle against EE remote-sync endpoints", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { MB_URL: bootstrap.baseUrl, MB_API_KEY: bootstrap.adminApiKey }; + } + + // The e2e stack has no git remote, so every worktree here is created with the branch listing and + // the initial pull skipped: `POST /worktree` records the row without reaching the remote. + async function createWorktree( + configHome: string, + branch: string, + ): Promise> { + const result = await runCli({ + args: ["worktree", "create", branch, "--no-pull", "--no-create-branch", "--json"], + configHome, + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + return parseJson(result.stdout, WorktreeCreateResult); + } + + async function createTransform( + configHome: string, + name: string, + scopeArgs: readonly string[], + ): Promise { + const result = await runCli({ + args: [ + "transform", + "create", + "--body", + JSON.stringify(transformBody(name)), + ...scopeArgs, + "--json", + ], + configHome, + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + return parseJson(result.stdout, TransformCompact); + } + + async function loginPinnedProfile(configHome: string): Promise { + const login = await runCli({ + args: [ + "auth", + "login", + "--profile", + PINNED_PROFILE, + "--url", + bootstrap.baseUrl, + "--api-key", + bootstrap.adminApiKey, + "--json", + ], + configHome, + }); + expect(login.exitCode, login.stderr).toBe(0); + expect(parseJson(login.stdout, LoginResult).profile).toBe(PINNED_PROFILE); + } + + it("create with --no-pull --no-create-branch registers the row and reaches no remote", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["worktree", "create", WORKTREE_BRANCH, "--no-pull", "--no-create-branch", "--json"], + configHome, + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, WorktreeCreateResult)).toEqual({ + worktree: { + id: expect.any(Number), + branch: WORKTREE_BRANCH, + creator_id: expect.any(Number), + created_at: expect.any(String), + }, + branch_created: false, + pull: null, + }); + }); + + it("create refuses a second worktree for a branch that already has one", async () => { + const configHome = await makeIsolatedConfigHome(); + await createWorktree(configHome, WORKTREE_BRANCH); + + const duplicate = await runCli({ + args: ["worktree", "create", WORKTREE_BRANCH, "--no-pull", "--no-create-branch", "--json"], + configHome, + env: authEnv(), + }); + + expect(duplicate.exitCode).toBe(1); + expect(cliErrorMessage(duplicate.stderr)).toBe( + `A worktree for branch '${WORKTREE_BRANCH}' already exists.`, + ); + }); + + it("create without --no-create-branch surfaces the branch listing failure on an unconfigured server", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["worktree", "create", WORKTREE_BRANCH, "--no-pull", "--json"], + configHome, + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Failed to clone git repository"); + }); + + it("list returns every worktree as a compact envelope", async () => { + const configHome = await makeIsolatedConfigHome(); + const created = await createWorktree(configHome, WORKTREE_BRANCH); + + const list = await runCli({ + args: ["worktree", "list", "--json"], + configHome, + env: authEnv(), + }); + + expect(list.exitCode, list.stderr).toBe(0); + expect(parseJson(list.stdout, WorktreeListEnvelope)).toEqual({ + data: [ + { + id: created.worktree.id, + branch: WORKTREE_BRANCH, + creator_id: expect.any(Number), + created_at: expect.any(String), + }, + ], + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + }); + }); + + it("get resolves the same worktree from an id and from a branch name", async () => { + const configHome = await makeIsolatedConfigHome(); + const created = await createWorktree(configHome, WORKTREE_BRANCH); + const expected = { + id: created.worktree.id, + branch: WORKTREE_BRANCH, + creator_id: expect.any(Number), + created_at: expect.any(String), + }; + + const byId = await runCli({ + args: ["worktree", "get", String(created.worktree.id), "--json"], + configHome, + env: authEnv(), + }); + const byBranch = await runCli({ + args: ["worktree", "get", WORKTREE_BRANCH, "--json"], + configHome, + env: authEnv(), + }); + + expect(byId.exitCode, byId.stderr).toBe(0); + expect(byBranch.exitCode, byBranch.stderr).toBe(0); + expect(parseJson(byId.stdout, WorktreeCompact)).toEqual(expected); + expect(parseJson(byBranch.stdout, WorktreeCompact)).toEqual(expected); + }); + + it("get reports an unknown branch as a ConfigError naming the listing command", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["worktree", "get", OTHER_BRANCH, "--json"], + configHome, + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toBe( + `worktree "${OTHER_BRANCH}" not found; run \`mb worktree list\``, + ); + expect(result.stdout).toBe(""); + }); + + it("a transform created under --worktree is listed in the scope and not in the main app", async () => { + const configHome = await makeIsolatedConfigHome(); + const created = await createWorktree(configHome, WORKTREE_BRANCH); + const worktreeId = created.worktree.id; + const transform = await createTransform(configHome, TRANSFORM_NAME, [ + "--worktree", + String(worktreeId), + ]); + + const scoped = await runCli({ + args: ["transform", "list", "--worktree", String(worktreeId), "--json"], + configHome, + env: authEnv(), + }); + const mainApp = await runCli({ + args: ["transform", "list", "--json"], + configHome, + env: authEnv(), + }); + + expect(scoped.exitCode, scoped.stderr).toBe(0); + expect(parseJson(scoped.stdout, TransformListEnvelope)).toEqual({ + data: [ + { + id: transform.id, + name: TRANSFORM_NAME, + description: null, + source_type: "native", + target: { + type: "table", + database: SEEDED.warehouseDbId, + schema: "public", + name: TRANSFORM_NAME, + }, + target_db_id: SEEDED.warehouseDbId, + worktree_id: worktreeId, + }, + ], + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + }); + + expect(mainApp.exitCode, mainApp.stderr).toBe(0); + expect(parseJson(mainApp.stdout, TransformListEnvelope)).toEqual({ + data: [], + returned: 0, + offset: 0, + total: 0, + has_more: false, + next_offset: null, + }); + }); + + it("transform get under MB_WORKTREE refuses a transform that lives in the main app", async () => { + const configHome = await makeIsolatedConfigHome(); + const created = await createWorktree(configHome, WORKTREE_BRANCH); + const worktreeId = created.worktree.id; + const mainTransform = await createTransform(configHome, TRANSFORM_NAME, []); + + const result = await runCli({ + args: ["transform", "get", String(mainTransform.id), "--json"], + configHome, + env: { ...authEnv(), MB_WORKTREE: String(worktreeId) }, + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toBe( + `transform ${mainTransform.id} is not in worktree ${worktreeId} (${WORKTREE_BRANCH}); ` + + "refusing to touch main-app content", + ); + expect(result.stdout).toBe(""); + }); + + it("a pinned profile refuses transform run and names the full verb chain", async () => { + const configHome = await makeIsolatedConfigHome(); + await loginPinnedProfile(configHome); + const created = await createWorktree(configHome, WORKTREE_BRANCH); + const worktreeId = created.worktree.id; + const mainTransform = await createTransform(configHome, TRANSFORM_NAME, []); + + const pin = await runCli({ + args: ["worktree", "pin", String(worktreeId), "--profile", PINNED_PROFILE, "--json"], + configHome, + }); + expect(pin.exitCode, pin.stderr).toBe(0); + expect(parseJson(pin.stdout, WorktreePinResult)).toEqual({ + profile: PINNED_PROFILE, + worktree: { id: worktreeId, branch: WORKTREE_BRANCH }, + }); + + const run = await runCli({ + args: ["transform", "run", String(mainTransform.id), "--profile", PINNED_PROFILE, "--json"], + configHome, + }); + + expect(run.exitCode).toBe(2); + expect(cliErrorMessage(run.stderr)).toBe( + `transform run is not available inside a worktree (scope: worktree ${worktreeId} ` + + `(${WORKTREE_BRANCH}) from the profile pin); it changes main-app content. ` + + "Unpin the profile (`mb worktree unpin`) or drop MB_WORKTREE to run it against the main app.", + ); + expect(run.stdout).toBe(""); + }); + + it("a pinned profile refuses a --worktree that names a different worktree", async () => { + const configHome = await makeIsolatedConfigHome(); + await loginPinnedProfile(configHome); + const pinned = await createWorktree(configHome, WORKTREE_BRANCH); + const other = await createWorktree(configHome, OTHER_BRANCH); + + const pin = await runCli({ + args: ["worktree", "pin", String(pinned.worktree.id), "--profile", PINNED_PROFILE, "--json"], + configHome, + }); + expect(pin.exitCode, pin.stderr).toBe(0); + + const list = await runCli({ + args: [ + "transform", + "list", + "--worktree", + String(other.worktree.id), + "--profile", + PINNED_PROFILE, + "--json", + ], + configHome, + }); + + expect(list.exitCode).toBe(2); + expect(cliErrorMessage(list.stderr)).toBe( + `profile "${PINNED_PROFILE}" is pinned to worktree ${pinned.worktree.id} ` + + `(${WORKTREE_BRANCH}); refusing --worktree ${other.worktree.id}`, + ); + expect(list.stdout).toBe(""); + }); + + it("git-sync status under a pin reports the worktree and no synced collections", async () => { + const configHome = await makeIsolatedConfigHome(); + await loginPinnedProfile(configHome); + const created = await createWorktree(configHome, WORKTREE_BRANCH); + const worktreeId = created.worktree.id; + + const pin = await runCli({ + args: ["worktree", "pin", String(worktreeId), "--profile", PINNED_PROFILE, "--json"], + configHome, + }); + expect(pin.exitCode, pin.stderr).toBe(0); + + const status = await runCli({ + args: ["git-sync", "status", "--profile", PINNED_PROFILE, "--json"], + configHome, + }); + + expect(status.exitCode, status.stderr).toBe(0); + expect(parseJson(status.stdout, SyncStatus)).toEqual({ + branch: WORKTREE_BRANCH, + worktree: { id: worktreeId, branch: WORKTREE_BRANCH }, + is_dirty: false, + current_task: null, + synced_collections: [], + }); + }); + + it("delete drops an empty worktree and releases the pin that named it", async () => { + const configHome = await makeIsolatedConfigHome(); + await loginPinnedProfile(configHome); + const created = await createWorktree(configHome, WORKTREE_BRANCH); + const worktreeId = created.worktree.id; + + const pin = await runCli({ + args: ["worktree", "pin", String(worktreeId), "--profile", PINNED_PROFILE, "--json"], + configHome, + }); + expect(pin.exitCode, pin.stderr).toBe(0); + + const deleted = await runCli({ + args: ["worktree", "delete", String(worktreeId), "--profile", PINNED_PROFILE, "--json"], + configHome, + }); + + expect(deleted.exitCode, deleted.stderr).toBe(0); + expect(parseJson(deleted.stdout, WorktreeDeleteResult)).toEqual({ + id: worktreeId, + branch: WORKTREE_BRANCH, + deleted: true, + unpinned: true, + }); + }); + + it("delete refuses a worktree holding unpushed changes until --force", async () => { + const configHome = await makeIsolatedConfigHome(); + const created = await createWorktree(configHome, WORKTREE_BRANCH); + const worktreeId = created.worktree.id; + await createTransform(configHome, TRANSFORM_NAME, ["--worktree", String(worktreeId)]); + + const refused = await runCli({ + args: ["worktree", "delete", String(worktreeId), "--json"], + configHome, + env: authEnv(), + }); + expect(refused.exitCode).toBe(2); + expect(cliErrorMessage(refused.stderr)).toBe( + `worktree ${worktreeId} (${WORKTREE_BRANCH}) has unpushed changes; ` + + "push them with `mb git-sync export` or pass --force to discard", + ); + + const forced = await runCli({ + args: ["worktree", "delete", String(worktreeId), "--force", "--json"], + configHome, + env: authEnv(), + }); + expect(forced.exitCode, forced.stderr).toBe(0); + expect(parseJson(forced.stdout, WorktreeDeleteResult)).toEqual({ + id: worktreeId, + branch: WORKTREE_BRANCH, + deleted: true, + unpinned: false, + }); + }); + + it("unpin releases a pin once and reports nothing to release on the second call", async () => { + const configHome = await makeIsolatedConfigHome(); + await loginPinnedProfile(configHome); + const created = await createWorktree(configHome, WORKTREE_BRANCH); + + const pin = await runCli({ + args: ["worktree", "pin", String(created.worktree.id), "--profile", PINNED_PROFILE, "--json"], + configHome, + }); + expect(pin.exitCode, pin.stderr).toBe(0); + + const first = await runCli({ + args: ["worktree", "unpin", "--profile", PINNED_PROFILE, "--json"], + configHome, + }); + const second = await runCli({ + args: ["worktree", "unpin", "--profile", PINNED_PROFILE, "--json"], + configHome, + }); + + expect(first.exitCode, first.stderr).toBe(0); + expect(parseJson(first.stdout, WorktreeUnpinResult)).toEqual({ + profile: PINNED_PROFILE, + unpinned: true, + }); + expect(second.exitCode, second.stderr).toBe(0); + expect(parseJson(second.stdout, WorktreeUnpinResult)).toEqual({ + profile: PINNED_PROFILE, + unpinned: false, + }); + }); +});