Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"plugins": [
{
"name": "kbagent",
"version": "0.52.1",
"version": "0.53.0",
"source": "./plugins/kbagent",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"category": "development"
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,17 @@ kbagent component list [--project NAME] [--type TYPE] [--query QUERY]
kbagent component detail --component-id ID [--project NAME]
kbagent config new --component-id ID [--name NAME] [--project NAME] [--output-dir DIR] [--push --no-files --description D --configuration JSON|@file|- --configuration-file PATH --no-validate --branch ID --dry-run]

# sync: GitOps -- configs as local files. init/pull/push/diff are filesystem-local (no serve REST surface).
kbagent sync init --project ALIAS [--directory DIR] [--git-branching] [--adopt-existing]
kbagent sync pull --project ALIAS [--all-projects] [--force] [--dry-run] [--with-samples] [--no-storage] [--no-jobs] [--job-limit N] [--branch ID]
# `sync pull --force` is conflict-aware (since 0.53.0): locally-modified config whose remote is UNCHANGED is preserved (delta stays pushable, never silently re-stamped); a true merge conflict (local AND remote both changed since last pull) aborts (exit 1, SYNC_CONFLICT, --json lists details.conflicts); local-untouched + remote-changed takes remote. Discard local edits on purpose by deleting the file/dir then pulling.
kbagent sync status [--directory DIR]
kbagent sync diff --project ALIAS [--all-projects] [--directory DIR] [--branch ID]
kbagent sync push --project ALIAS [--all-projects] [--dry-run] [--force] [--allow-plaintext-on-encrypt-failure] [--branch ID] [--no-name-drift-warnings]
kbagent sync branch-link --project ALIAS (--branch-id ID | --branch-name NAME) [--directory DIR]
kbagent sync branch-unlink [--directory DIR]
kbagent sync branch-status [--directory DIR]

kbagent dev-portal identity add --alias A --username U [--password P | --password-stdin]
[--role-hint vendor|admin] [--vendor V] [--portal-url URL]
kbagent dev-portal identity list
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kbagent",
"version": "0.52.1",
"version": "0.53.0",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"author": {
"name": "Keboola",
Expand Down
8 changes: 8 additions & 0 deletions plugins/kbagent/agents/keboola-expert.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,14 @@ success, not a failure.
misleading "bucket not found" until the prod table is branch-local. Run
`kbagent storage clone-table --project P --table-id T --branch <ID>`
first (one-way default->branch). See `gotchas.md`.
- **`sync pull --force` is conflict-aware, not a blind overwrite** (0.53.0+):
a locally-modified config whose remote is UNCHANGED is preserved (its pending
delta stays pushable); a true merge conflict (local AND remote both changed
since last pull) ABORTS the pull with exit 1 / `SYNC_CONFLICT` instead of
silently discarding work (pre-0.53.0 it silently corrupted the baseline and
stranded the edits). Safe to force-pull an unrelated config while you have
un-pushed edits elsewhere. To discard a local edit on purpose, delete the
file/dir then pull. See `gotchas.md`.

- **`storage truncate-table` is row-only; schema and dependents are
preserved** (0.32.0+): the underlying call is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ Requires the project to be added with its **master ('owner') Storage API token**

## Sync (GitOps)
- `sync init --project ALIAS [--directory DIR] [--git-branching] [--adopt-existing]` -- initialize sync working directory; `--adopt-existing` (since v0.22.0) adopts a `.keboola/manifest.json` already written by the kbc Go CLI without overwriting (idempotent; validates `project_id` against the alias token)
- `sync pull --project ALIAS [--all-projects] [--force] [--dry-run] [--with-samples] [--no-storage] [--no-jobs] [--job-limit N] [--branch ID]` -- download configs to local files. For large projects (>100 configs), automatically fetches jobs per-config when the grouped API limit is insufficient. `--branch` (0.47.0+) per-invocation dev-branch override, beats every other branch source.
- `sync pull --project ALIAS [--all-projects] [--force] [--dry-run] [--with-samples] [--no-storage] [--no-jobs] [--job-limit N] [--branch ID]` -- download configs to local files. For large projects (>100 configs), automatically fetches jobs per-config when the grouped API limit is insufficient. `--force` is conflict-aware (since 0.53.0): a locally-modified config whose remote is unchanged is **preserved** (pending delta stays pushable, never silently re-stamped); a true merge conflict (local AND remote both changed since last pull) **aborts** the pull (exit 1, `SYNC_CONFLICT`; `--json` lists `details.conflicts`); local-untouched + remote-changed takes remote. To discard local edits on purpose, delete the file/dir and pull. `--branch` (0.47.0+) per-invocation dev-branch override, beats every other branch source.
- `sync push --project ALIAS [--all-projects] [--dry-run] [--force] [--allow-plaintext-on-encrypt-failure] [--branch ID] [--no-name-drift-warnings]` -- push local changes (auto-encrypts secrets, fails if encryption fails). Fresh-CREATE writeback updates placeholder manifest entries in place (since 0.47.0) and propagates any `KBC.configuration.*` metadata via `set_config_metadata`. Fresh-CREATE variable binding (since 0.47.2): when a `keboola.variables` config + its values row are created alongside a transformation in the same push, the transformation's `variables_id` / `variables_values_id` placeholders are rebound to the assigned ULIDs and the row's `values` are hoisted even without a `_keboola` block, so `job run` succeeds with no post-push `config variables-set` step (unresolvable/ambiguous links surface a `variable_link` entry in `errors[]`, never a broken link). `--branch` (0.47.0+) per-invocation override; when no `<branch_name>/` subtree exists on disk (since 0.47.2) the local default tree (`main/`) is promoted to the target branch (API writes still target the branch id); `--no-name-drift-warnings` (0.47.0+) drops the cosmetic warnings array.
- `sync diff --project ALIAS [--all-projects] [--branch ID]` -- 3-way diff (local vs base vs remote), detects conflicts. `--branch` (0.47.0+) per-invocation dev-branch override.
- `sync status [--directory DIR]` -- show locally modified/added/deleted configs
Expand Down
21 changes: 21 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -2426,3 +2426,24 @@ Enter just hung until Ctrl-C. The helper now branches on
`sys.stdin.isatty()`: TTY uses `getpass.getpass()` (hidden, line-based,
Enter confirms); pipe (`echo $PASS | kbagent dev-portal identity add
--password-stdin`) still reads to EOF.

## `sync pull --force` preserves un-pushed edits and aborts on conflict (since v0.53.0)

`--force` is **conflict-aware**, not a blind overwrite. Pre-0.53.0 a force-pull
run while you had un-pushed local edits to a config whose remote was unchanged
**silently corrupted the sync baseline**: it re-stamped the manifest `pull_hash`
from the *edited* on-disk file, so `sync diff` / `sync push` then reported "in
sync" and shipped nothing while the remote still held the old config -- the edits
were stranded with no signal. Fixed: `--force` now branches on the 3-way state.

- Local edited, remote unchanged -> **preserved** (pending delta stays pushable).
- Local edited AND remote also changed (true conflict) -> **aborts** with exit 1
and error code `SYNC_CONFLICT`; the `--json` envelope carries
`details.conflicts: [{scope, component_id, config_id, config_name, path, row_id?}]`.
Resolve via `sync diff` then `sync push`-or-discard, then pull again.
- Local untouched, remote changed -> takes remote (unchanged behavior).

Consequence: `--force` no longer discards non-conflicting local edits. To
intentionally drop a local edit, delete the file (or the config dir) and pull.
Applies at config and row granularity. `--all-projects` reports a per-project
conflict as that project's error without aborting the rest of the batch.
23 changes: 22 additions & 1 deletion plugins/kbagent/skills/kbagent/references/sync-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,11 +342,32 @@ Stored in `.keboola/branch-mapping.json`:
## Key behaviors

- **Pull is idempotent**: re-running pull when nothing changed writes zero files
- **Pull protects local edits**: modified files are skipped (use `--force` to overwrite)
- **Pull protects local edits**: locally-modified files are skipped by default
- **`--force` is conflict-aware (since 0.53.0)**: see below -- it no longer blindly overwrites
- **Push only sends local changes**: remote_modified and conflict changes are skipped
- **Encrypted values**: nonce differences are ignored in diff (no false positives)
- **New configs**: push auto-assigns IDs from the API, updates manifest
- **Storage metadata is read-only**: not tracked in manifest, excluded from diff/push
- **Jobs are per-config**: `_jobs.jsonl` shows recent N jobs (default 5) with status + timing
- **Data samples auto-trim**: tables with >30 columns export only first 30 (API sync limit)
- **Encrypted columns masked**: columns starting with `#` show `***ENCRYPTED***` in samples

## `sync pull --force` is conflict-aware (since 0.53.0)

`--force` no longer blindly overwrites locally-modified configs. It branches on
the 3-way diff state per config (and per row):

- **Local edited, remote UNCHANGED** -> the file and its sync baseline are
**preserved**. The pending delta stays visible to `sync diff` / `sync push`.
`--force` does **not** discard the edit and does **not** silently re-stamp the
baseline (that was the pre-0.53.0 data-loss bug).
- **Local edited AND remote also changed** since the last pull (a true merge
conflict) -> the pull **aborts before writing anything** with exit code 1 and
error code `SYNC_CONFLICT`, listing every conflicting config/row. Resolve with
`sync diff`, then `sync push` your edits (or discard them), then pull again.
- **Local untouched, remote changed** -> `--force` takes remote as before.

> Safe to run `sync pull --force` to refresh an unrelated config even while you
> have un-pushed edits elsewhere: non-conflicting edits survive; a real conflict
> stops you loudly instead of losing work. To intentionally drop a local edit,
> delete the file (or the config directory) and pull.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-agent-cli"
version = "0.52.1"
version = "0.53.0"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
3 changes: 3 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.53.0": [
'Fix (`sync pull --force`, silent baseline corruption -> data loss): a config with un-pushed local edits is no longer silently de-synced when a force-pull runs while the remote is unchanged. Repro (reported on v0.51.1, project 5785): pull a config, edit its `_config.yml` (`sync diff` correctly shows `1 to update`), then run `sync pull --force` -- typically to resolve an *unrelated* config\'s conflict. Pre-0.53.0, `--force` skipped the "locally modified" guard in `SyncService.pull()`, so for a config whose remote had not changed the `remote_unchanged` short-circuit re-stamped the manifest `pull_hash` from the *edited on-disk file*. Afterwards `sync diff` and `sync push` both reported "in sync" and a real `push` shipped nothing, while the live remote still held the old config -- the local edits were stranded with no visible signal. Root cause was an interaction of two individually-reasonable decisions: `--force` bypassing the overwrite guard, and the `diff` `local_override_hashes` optimization that skips re-reading a file whose hash matches `pull_hash` (so the edited content was never even compared). The fix splits `--force` behaviour by 3-way diff state, per the maintainer decision: (b) local edited + remote UNCHANGED -> the file AND its 3-way base (`pull_hash` + `pull_config_hash`) are preserved, so the pending delta stays visible to `sync push` (no data loss, no silent revert); (a) local edited + remote ALSO changed since the last pull (a true merge conflict) -> the pull aborts before writing anything with the new `SyncConflictError` (exit 1, error code `SYNC_CONFLICT`), listing every conflicting config/row so the user resolves it (`sync diff`, then `push` or discard, then pull again). A no-conflict force-pull (remote changed, local untouched) still takes remote as before. Applies at config and row granularity. Note: `--force` no longer discards un-pushed non-conflicting edits -- that was the dangerous behaviour; to intentionally drop local edits, delete the file (or the config dir) and pull. New: `errors.SyncConflictError` + `ErrorCode.SYNC_CONFLICT`; `SyncService._detect_force_pull_conflicts` / `_is_conflict` (read-only pre-pass that runs before any write); `commands/sync.py` catches the error and prints a red per-config conflict block (human) or a `SYNC_CONFLICT` envelope with a `details.conflicts` array (`--json`). The pull `--force` help now documents the preserve/abort semantics. `--all-projects` surfaces a per-project conflict as a structured entry (`error_code: SYNC_CONFLICT` + the `conflicts` list, matching the single-project envelope) without aborting the batch. Tests: `tests/test_sync_force_pull_baseline.py` (config + row, preserve case b, abort case a, remote-only-changed takes remote, `--all-projects` structured conflict), `tests/test_sync_cli.py` (exit 1 + human/JSON conflict envelope), and `tests/test_e2e.py::TestE2ESyncWorkflow::test_sync_force_pull_conflict_aware` (real Storage: preserve when remote unchanged, then `SYNC_CONFLICT` after a remote mutation).',
],
"0.52.1": [
'Fix (docs/UX, swap-tables wording): completes the swap-tables semantics correction shipped in 0.52.0, which left four co-located surfaces still claiming the swap is dev-branch-only or "rejected on production" -- now false after that fix. The user-facing `ConfigError` raised on a missing `--branch` (exit 5) no longer says "The Storage API rejects this on production"; it now reads "swap-tables requires a branch ... Any branch works, including the default/production branch." The same stale wording was corrected in `commands-reference.md`, the `swap-tables` command docstring (which feeds the auto-generated `SKILL.md` decision table via `make skill-gen`), and the `AGENT_CONTEXT` block (`kbagent context`). A CLI test that mocked and asserted the old phantom "dev branch" error string (it passed only because the mock short-circuited the real service) was fixed to match the real message, and the `swap_tables` `Args` docstring "Dev branch ID" became "any branch accepted, including the default/production branch". No behaviour change: `branch_id` stays mandatory (the swap is branch-scoped). `clone-table` wording is intentionally untouched -- clone legitimately targets a dev branch. Surfaced by the `kbagent-pr-reviewer` self-review passes on keboola/cli#368 and keboola/cli#373.',
"Maintenance: dependency bumps merged since 0.52.0 -- `pip` 26.0.1->26.1 and `python-multipart` 0.0.26->0.0.27 (the latter on the `kbagent serve` multipart path). Build/transitive only; no API or behaviour change.",
Expand Down
6 changes: 6 additions & 0 deletions src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,12 @@

kbagent sync pull --project ALIAS [--all-projects] [--force] [--dry-run] [--with-samples] [--no-storage] [--no-jobs] [--job-limit N] [--branch ID]
Download configs as local files. Idempotent, protects local modifications.
--force (semantics corrected since 0.53.0): re-pull over locally-modified configs.
A config edited locally whose remote is UNCHANGED is PRESERVED (its pending delta stays
pushable -- NOT discarded, NOT silently re-stamped). A true merge conflict (the config
changed BOTH locally and on the remote since the last pull) ABORTS the pull (exit 1,
SYNC_CONFLICT) listing each conflict; resolve via sync diff then push-or-discard, then pull.
To intentionally drop local edits, delete the file/dir and pull. Applies to rows too.
--job-limit controls max recent jobs per config (default 5). For large projects,
automatically falls back to per-config job fetching to ensure all configs get job history.
Auto-detects renamed configs and renames local directories to match (uses git mv in git repos).
Expand Down
36 changes: 34 additions & 2 deletions src/keboola_agent_cli/commands/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import typer

from ..errors import ConfigError, ErrorCode, KeboolaApiError
from ..errors import ConfigError, ErrorCode, KeboolaApiError, SyncConflictError
from ._helpers import check_cli_permission, get_formatter, get_service, map_error_to_exit_code

sync_app = typer.Typer(help="Sync project configurations with local filesystem")
Expand Down Expand Up @@ -248,6 +248,23 @@ def _format_diff_result(formatter: Any, result: dict) -> None:
formatter.console.print(f" {len(remote_only)} new remote-only config(s)")


def _format_conflict_list(formatter: Any, conflicts: list[dict[str, str]]) -> None:
"""Print the per-config force-pull conflict list (human mode only)."""
if not conflicts:
return
n = len(conflicts)
formatter.console.print(
f"\n[bold red]Merge conflict:[/bold red] {n} config(s) changed BOTH "
f"locally and on the remote since the last pull:"
)
for c in conflicts:
label = "row" if c.get("scope") == "row" else "config"
formatter.console.print(
f" [red]![/red] {c.get('component_id')}/{c.get('config_id')} "
f"[dim]({label})[/dim] {c.get('config_name', '')}"
)


def _format_push_result(formatter: Any, result: dict) -> None:
"""Format a single-project push result for human output."""
status = result.get("status", "")
Expand Down Expand Up @@ -409,7 +426,12 @@ def sync_pull(
force: bool = typer.Option(
False,
"--force",
help="Overwrite local files without checking for modifications",
help=(
"Force re-pull. Locally-modified configs whose remote is unchanged "
"are PRESERVED (kept as pending changes for `sync push`); a true "
"merge conflict (local AND remote both changed since the last pull) "
"aborts the pull so you can resolve it."
),
),
dry_run: bool = typer.Option(
False,
Expand Down Expand Up @@ -531,6 +553,16 @@ def sync_pull(
max_samples=max_samples,
branch_override=branch,
)
except SyncConflictError as exc:
if not formatter.json_mode:
_format_conflict_list(formatter, exc.conflicts)
formatter.error(
message=exc.message,
error_code=exc.error_code,
project=project or "",
details={"conflicts": exc.conflicts},
)
raise typer.Exit(code=1) from None
except FileNotFoundError as exc:
formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED)
raise typer.Exit(code=1) from None
Expand Down
Loading
Loading