Skip to content

sync git-branching: branch_id type confusion + walker scope guard block GitOps workflow #267

Description

@padak

TL;DR

Two chained bugs in kbagent sync make GitOps workflows unusable on gitBranching.enabled: true workspaces:

  • Bug Akbagent sync pull mis-routes dev-branch configs into main/ and corrupts .keboola/manifest.json by appending a duplicate branches[] entry on every invocation. Root cause: Keboola branch IDs are persisted as str in branch-mapping.json but compared as int everywhere else, so every cross-type == lookup silently fails.

  • Bug Bkbagent sync diff and kbagent sync push --dry-run silently report "no changes" when the user scaffolds a brand-new local config dir under a non-default branch path. The "scaffold locally then push" flow advertised by the docs is unreachable on git-branching workspaces.

The two bugs are independent at the symptom level but Bug A blocks a complete fix for Bug B (the path-resolution helper that Bug B's walker delegates to is itself broken by Bug A). They should be fixed together in one PR.

Reported externally against v0.27.0; reproduced in code review on v0.30.3 (current main). Both bugs are still latent.


Shared environment

  • Manifest schema v3, gitBranching.enabled: true, defaultBranch: main.
  • Active git feature branch linked via kbagent sync branch-link --branch <DEV_BRANCH_ID> to a non-default Keboola dev branch.
  • Manifest pre-state:
    "branches": [
      { "id": 38516, "path": "main",         "metadata": {} },
      { "id": 38721, "path": "branch-38721", "metadata": {} }
    ]

Bug A — sync pull mis-routes to main/ and inflates branches[]

Symptom

Single warning line on stderr:

WARNING Branch ID 38721 not found in manifest, falling back to default path

— even though {"id": 38721, "path": "branch-38721"} is plainly present in manifest.branches[]. The lookup fails, the fallback writes to main/, and a separate code path then re-registers the "missing" branch under a mangled path.

Repro

kbagent --json sync pull --project <alias>
  1. The warning above is emitted.
  2. JSON response indicates a successful pull, but branch_dir: "main":
    { "status": "ok",
      "data": {
        "status": "pulled",
        "branch_id": "38721",
        "branch_dir": "main",
        "configs_pulled": 1,
        "details": [
          { "action": "updated",
            "component_id": "<component-id>",
            "path": "application/<component-id>/<config-name>" } ] } }
  3. Files appear on disk at main/application/<component-id>/<config-name>/ instead of branch-38721/....
  4. manifest.branches[] grows by one entry per pull:
    { "id": 38721, "path": "branch-38721-38721", "metadata": {} }
    Each subsequent sync pull appends another duplicate (branch-38721-38721-38721, etc.).
  5. manifest.configurations[] records "branchId": 38721 with the correct relative path for the dev branch — so the manifest now misdescribes where files actually live.

Root cause — branch_id is str in branch-mapping.json, int everywhere else

branch_link converts the Keboola branch ID to a string before persisting it. services/sync_service.py lines 1957, 1967, 1971, 1981, 1985:

kbc_branch_id = str(branch_info["id"])   # all five branches of branch_link

That string lands on BranchMappingEntry.keboola_id, whose annotation in sync/branch_mapping.py:19 is already str | None — the bug is locked in by the type annotation.

_resolve_branch_id (services/sync_service.py:2085) is annotated -> int | None but actually returns the string straight from the mapping (line 2111: return entry.keboola_id). The annotation lies.

pull() (line 280) then calls _ensure_branch_registered(manifest, "<id-as-string>", client) and _find_branch_path(manifest, "<id-as-string>"). Both compare:

if branch.id == branch_id:

…where branch.id is int (from the Pydantic ManifestBranch.id: int at sync/manifest.py:67) and branch_id is str. Python returns False for cross-type == between int and str, so:

  • _ensure_branch_registered (line 2548) thinks the branch is unregistered → falls through to the registration block → path-collision logic (line 2586) sees branch-38721 is taken → suffixes to branch-38721-38721manifest.branches.append(...). Pydantic coerces the string id back to int on append, so the entry lands as {id: 38721, path: "branch-38721-38721"}. The same (id, path) mutation runs on every pull.
  • _find_branch_path (line 2593) hits the same False comparison → falls back to manifest.branches[0].path → returns "main". Files get routed to main/.
  • The branch-name lookup inside _ensure_branch_registered (line 2575: if b.get("id") == branch_id) also fails for the same reason — Storage API returns id as int, our branch_id is str — so the human-readable name from the API never gets used and we always fall through to f"branch-{branch_id}". This explains why naming.branch = "{branch_name}" produced branch-38721 instead of a slug.

So one type bug produces three visible symptoms: misrouted files, manifest growth, and missing branch slug.

Existing test that locks the bug in

tests/test_sync_service.py:1613:

assert entry.keboola_id == "99999"

This assertion encodes the wrong contract. After the fix it must change to == 99999 (int).


Bug B — sync diff / sync push --dry-run does not detect untracked local configs in non-default branch

Symptom

kbagent --json sync diff --project <alias>
{ "status": "ok",
  "data": {
    "changes": [], "remote_only": [],
    "summary": { "added": 0, "modified": 0, "remote_modified": 0,
                 "conflict": 0, "deleted": 0, "unchanged": 0,
                 "remote_only": 0 } } }
kbagent --json sync push --project <alias> --dry-run
{ "status": "ok",
  "data": { "status": "no_changes",
            "created": 0, "updated": 0, "deleted": 0, "errors": [] } }

Per skills/kbagent/references/sync-workflow.md| ADDED | New local config | Push creates it | — an untracked local config dir is supposed to be surfaced as diff state "added" and POSTed by push. The "scaffold locally then push" workflow is silently broken.

Repro

  1. kbagent sync init --git-branching against an empty (or near-empty) project — manifest's configurations: [] is empty.
  2. kbagent sync pull --project <alias> (no-op against an empty project, but auto-registers default branch).
  3. On a feature git branch, kbagent sync branch-link --branch <DEV_BRANCH_ID>.
  4. Manually create a config directory matching default naming under the dev-branch path:
    branch-<id>/application/<component-id>/<config-name>/
      _config.yml
      code.py
      pyproject.toml
    
  5. Run kbagent sync diff and kbagent sync push --dry-run → both return empty. No way to push the new config to Keboola via sync.

Root cause — walker piggybacks on manifest.configurations

services/sync_service.py:_find_untracked_configs (line 2612) builds the set of branch IDs to scan exclusively from already-tracked configurations:

for cfg in manifest.configurations:
    branch_path = self._find_branch_path(manifest, cfg.branch_id)
    tracked_paths.add(str(project_root / branch_path / cfg.path))
    active_branch_ids.add(cfg.branch_id)
...
for branch in manifest.branches:
    # Only scan branches that have tracked configs — skip inactive
    # branch directories to avoid phantom "added" configs.
    if branch.id not in active_branch_ids:
        continue

When manifest.configurations: [], active_branch_ids is empty → the continue skips every branch → no directory is ever walked → added: [].

The guard ("skip inactive branch directories to avoid phantom adds") is legitimate for orphaned dev-branch dirs left over from old work, but its scope is too narrow: it also blocks the documented first-time scaffold flow. There is no way to bootstrap a config locally through sync push against a clean configurations: [] manifest, contradicting the sync-workflow.md docs.

Why this fix depends on Bug A

Even after _find_untracked_configs is widened, services/sync_service.py:906 (in diff()) computes the path for newly-found untracked configs as:

branch_path = self._find_branch_path(manifest, branch_id)

…where branch_id is the value returned by _resolve_branch_id, which today returns a str due to Bug A. _find_branch_path then fails the branch.id (int) == branch_id (str) comparison and falls back to "main", so even with a wider walker scope, untracked configs found in branch-38721/ would be reported with path rooted at main/. Bug A must be fixed first or simultaneously.


Proposed fix (single PR)

Bug A — make branch IDs int end-to-end

  1. services/sync_service.py lines 1957, 1967, 1971, 1981, 1985: drop the str(...) wrapper:
    kbc_branch_id = int(branch_info["id"])
  2. sync/branch_mapping.py line 19: change annotation keboola_id: str | Nonekeboola_id: int | None. Update to_dict() / from_dict() accordingly. from_dict should coerce entry.get("id") to int (with None handling) so any legacy branch-mapping.json files written by older kbagent versions auto-migrate on first read — no breaking change for users who already linked branches.
  3. tests/test_sync_service.py line 1613: update the asserted type to int (== 99999).
  4. (Defense in depth) Make _ensure_branch_registered idempotent on (id, path) even after the type fix so any future regression here doesn't silently mutate the manifest.

Bug B — widen walker scope without re-opening the phantom-add hole

Replace the active_branch_ids guard in _find_untracked_configs with a wider source set:

  • All branch IDs in manifest.configurations[] (today's set — keeps protection against orphaned dirs that were once tracked then deleted).
  • The branch ID currently resolved by _resolve_branch_id() for this pull / diff / push call — the user is on a linked git branch and explicitly asked to operate against it.
  • The defaultBranch ID from manifest.git_branching (always allowed — push to main is a legitimate first-time flow too).

Pass the resolved branch_id into _find_untracked_configs(...) from diff() and push() so the walker has the explicit signal. Phantom-add protection is preserved for dirs from random other branches no one is currently working on.

Regression tests

  • Bug A: pre-populated manifest with branches = [{id: 38516, path: "main"}, {id: 38721, path: "branch-38721"}], mocked branch-mapping.json with keboola_id set, run pull twice; assert len(manifest.branches) == 2 after both pulls and branch_dir == "branch-38721" in the response.
  • Bug B: manifest.configurations: [], freshly scaffolded dir under a non-default branches[].path, expect diff to report added: 1 and push --dry-run to report created: 1.

Asks for the maintainer

  1. Confirm the intended contract for BranchMappingEntry.keboola_id is int (and only the persisted JSON is string-of-int) or that it should be string-typed throughout. The fix above assumes the former.
  2. Decide on legacy-branch-mapping.json migration strategy — silent coerce on load (the simplest), or explicit "manifest schema upgrade" message.
  3. Confirm whether ADDED → push creates it is an intended supported flow on git-branching workspaces today. The docs (sync-workflow.md) imply yes; current behavior says no. If the answer becomes "no, must use tool call create_config first", the docs need a clear caveat and a hint on sync push --dry-run like:
    { "warning": "Found 1 local directory matching naming convention but not in manifest; create via 'kbagent tool call create_config' first." }
  4. The current Bug A fall-through emits a WARNING and continues with a wrong-branch write — consider promoting to an error with non-zero exit even after the type fix, since "I don't recognize this branch" is not a recoverable state for sync pull.

Workaround (until fix)

For Bug A: after every sync pull, manually move main/<config-path>/* to branch-<id>/<config-path>/ and hand-edit .keboola/manifest.json to remove the {"id": <id>, "path": "branch-<id>-<id>..."} entries from branches[].

For Bug B: bootstrap the config via the MCP tool, then let sync track it:

kbagent --json tool call create_config --project <alias> --branch <DEV_BRANCH_ID> --input @stub.json
kbagent sync pull --project <alias> --force
# Now sync push detects MODIFIED on edits as expected.

This works but defeats the GitOps "scaffold locally, then push" flow that the docs advertise.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions