- 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 and a preflight preview), 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 …`.
- allowed-tools
- Read, Write, Edit, Bash, AskUserQuestion
- requires
- ["remoteSync"]
# git-sync (representations ↔ instance)
Metabase content (cards, dashboards, transforms, snippets, collections, …) can live in a git repo as YAML and round-trip in and out of a Metabase instance via the `git-sync` verbs. The instance is configured with a `remote-sync-*` settings block (URL, branch, token, type read-only/read-write); the CLI drives the sync tasks against `/api/ee/remote-sync/*`. Only collections flagged for sync serialize; everything else is local-only. Table and field metadata round-trips too, for Library-published tables in a flagged collection — see "Published table metadata" below.
The repo and the instance are two ends of the same state; what "push my changes" means depends on where the changes were made. If they were made **in the instance** (CLI verbs against a read-write instance — cards edited, transforms created, tables published, metadata written), "push my changes" / "save this to the repo" / "open a PR with these changes" means driving the instance's export to a branch (`stash` or `create-branch` + `export`) and opening the PR from that branch — not reconstructing the changes as files. If the workflow is **repo-first** (content YAML edited in the repo, the instance imports it), plain `git` against the repo is the way — but only in the serialized layout already there: files in paths or formats the serializer doesn't own are invisible to Metabase and never apply on import.
This skill covers the import/export workflow. Flag conventions and auth setup live in `core` (`mb skills get core`). To author content YAML by hand: the per-resource clause and settings shapes mirror the API form — query bodies follow the `mbql` skill, `visualization_settings` follow the `visualization` skill — except the portable YAML uses **name-based** references (e.g. `[Sample Database, PUBLIC, ORDERS, TOTAL]`, and entity-ids for cross-entity FKs) where the API form uses numeric ids. For the on-disk folder layout, model new files on what the synced repo already contains.
## Precondition: read state before mutating
Always run `status` (or the individual checks below) before `import` or `export`. Importing on a dirty instance silently rejects unless you pass `--force`.
<!-- requires: remoteSyncMerge -->
Exporting after the remote moved on ends in a `conflict` task unless the export merges or forces.
<!-- /requires -->
```bash
mb git-sync status --profile <n> --json # → branch, dirty, current task
mb git-sync is-dirty --profile <n> --json # → {is_dirty: bool}; instance has unexported changes
mb git-sync dirty --profile <n> --json # → list the dirty objects
mb git-sync current-task --profile <n> --json # → in-flight task (or idle)
```
<!-- requires: remoteSyncRemoteChanges -->
```bash
mb git-sync has-remote-changes --profile <n> --json # → {has_changes: bool, remote_version, local_version, cached}; remote has unimported commits
```
<!-- /requires -->
**Clean up before exporting.** If you've created entities you intend to delete (a failed transform you're going to retry, a card you authored to test a body shape, a draft dashboard) — do the deletes _before_ the first `git-sync export`. Once committed, the cleanup needs a second commit, and the failed entity stays visible in `git log` forever. For transforms, prefer `transform update <id>` over delete + create (see the `transform` skill).
## Import (remote → instance)
```bash
mb git-sync import --branch <branch> --profile <n>
# Default flags: --wait, polling --interval 2000 --timeout 600000
```
Pulls the configured branch and applies it to the instance. Polls until the task reaches a terminal state (`succeeded` / `failed`).
| Flag | Purpose |
| ----------------- | ------------------------------------------------------------------------------------ |
| `--branch <name>` | 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. |
| `--timeout <ms>` | Polling deadline. Default 600 000. |
| `--interval <ms>` | Polling cadence. Default 2 000. |
Workflow:
1. Read state (above) — confirm `is_dirty: false` (or `--force` is intended).
2. `git-sync import --branch <branch>` — runs to terminal status by default.
<!-- requires: remoteSyncRemoteChanges -->
Between the two, confirm `has-remote-changes` reports `has_changes: true` — there's actually something to import.
<!-- /requires -->
<!-- requires: remoteSyncMerge -->
### Pull without discarding local work
`mb git-sync import --merge` keeps un-pushed local changes and folds the remote's in by a three-way merge, where a plain import refuses on a dirty instance and `--force` discards the local work. Entities changed on both sides, or no merge base (a remote whose history was rewritten, or an instance that has never synced), end the task in `conflict` without touching local state; the conflicting entities are listed in the task's `conflicts`. It does not combine with `--force`.
<!-- /requires -->
## Export (instance → remote)
```bash
mb git-sync export -m "commit message" --branch <branch> --profile <n>
```
Pushes Metabase-side changes back to the configured remote. `-m` is the commit message; without it the server picks a default. Defaults to `--wait`.
| Flag | Purpose |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| `--branch <name>` | Defaults to the tracked `remote-sync-branch`. To push elsewhere, `stash` or `create-branch` first. |
| `-m, --message <s>` | Commit message. |
| `--force` | Force-push / overwrite remote. Confirm with the user. |
| `--no-wait` | Don't poll. |
Workflow:
1. **Branch guard** (below) — confirm the instance isn't tracking `main`/`master`, or that the user has explicitly accepted exporting to it.
2. Read state (above) — confirm `is-dirty` reports there's something to export.
3. `git-sync export -m "..."` — pushes and polls.
4. (Optional) `git-sync status` — verify `is_dirty: false` after.
<!-- requires: remoteSyncBranchGuard -->
`--branch` only asserts the branch you believe is tracked: the server rejects any other with a 409 naming the current one. Left out, the CLI reads the tracked branch first and refuses when none is tracked or the caller is not an admin.
<!-- /requires -->
<!-- requires: remoteSyncMerge -->
### Merge when the remote moved on
`mb git-sync export --merge -m "..."` folds the remote's changes in by a three-way merge, pushes the result and reconciles it into Metabase, where a plain export ends in a `conflict` task. Entities changed on both sides, or a remote whose history was rewritten, still end it in `conflict` and write nothing. It does not combine with `--force`.
<!-- /requires -->
<!-- requires: remoteSyncExportPreflight, remoteSyncMerge -->
### Preview the push first
```bash
mb git-sync export-preflight --profile <n> --json # → {has_changes, clean, conflicts, summary, force_push_casualties, reason}
```
A dry run against the live remote branch, writing nothing. Read it between the state check and the export, and decide from the fields:
| Answer | Meaning | Move |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `has_changes: false` | The remote has not moved past the last sync, or nothing has been synced yet — and then `force_push_casualties` is empty whatever the remote holds. | `export -m "..."` applies as-is. Don't read empty casualties on a never-synced instance as leave to `--force`. |
| `has_changes: true`, `reason: null` | The remote moved on. `clean` says whether a three-way merge would apply; `conflicts` names the entities changed on both sides; `summary` counts what a merge would fold in. | `clean: true`: `export --merge -m "..."`. `clean: false`: the conflicts need the user's call between the three options under "After a `conflict` task". A plain `export` ends in a `conflict` task, and so does `stash`: its new branch starts at the moved remote tip. |
| `reason: "history-rewritten"` | The remote was force-pushed or rebased, so there is no merge base. | Only `export --force` can push, and only with the user's explicit go-ahead; `force_push_casualties` is exactly what it would delete or overwrite. |
`force_push_casualties` is reported on every answer: it is the remote content a force push would discard instead of merging, so read it before ever passing `--force`. It is empty when nothing has been synced yet, yet `export --force` then replaces the remote's managed directories wholesale: check the remote before forcing a first export.
Read the preflight before every export: an export that finds the divergence itself ends in `conflict`, and after that these fields can't be trusted (below).
### After a `conflict` task
Never answer a conflict with a retry. A task that ends in `conflict` may make the server count the remote commit it saw as synced; the server version does not say whether it does. If it does, `has-remote-changes` and `export-preflight` report nothing pending, `force_push_casualties` comes back empty, and a retried `export`, `export --merge` or `import --merge` succeeds without bringing the remote's changes into Metabase; a later full or forced export can then overwrite them on the remote. So don't decide from those reads. Take the user's call between:
- **The remote's side:** `import --force`. Discards Metabase's un-pushed work.
- **Metabase's side:** `export --force`. Overwrites the remote, and its casualties can't be previewed after a conflict: show the user the remote's commits since the last export in git first.
- **Both, through a reviewed PR:**
1. Note the tracked branch from `mb git-sync status --json` (`branch`): this is `<original>`.
2. `mb git-sync create-branch <name>`
3. `mb git-sync export -m "..."`
4. `mb git-sync import --force`, which reloads the instance from `<name>`.
5. Open a PR from `<name>` into `<original>` and review its diff before it merges. If the server counted the conflict as synced, `<name>` starts at the remote's tip, so the PR can undo the remote's edits to the conflicting entities without a git conflict: restore any such edit in the PR.
6. Once the PR merges, `mb git-sync import --branch <original>` tracks the original branch again and loads the merged result.
<!-- /requires -->
### Branch guard: don't export to main/master without confirmation
Sync work is conventionally done on a feature branch — exporting to `main` (or `master`) commits team-shared content directly. Before `git-sync export`, check the tracked branch and if it's `main`/`master`, ask the user whether to switch first.
Read the current branch with `mb git-sync status --profile <n> --json | jq -r '.branch'`.
If the branch is `main` or `master`, prompt with `AskUserQuestion`:
> "The instance is tracking `<branch>` — exporting commits straight to it. Switch to a feature branch first?"
>
View on GitHub