fix(sync): conflict-aware pull --force — no more silent baseline corruption (0.53.0)#376
Conversation
…ruption (0.53.0)
`sync pull --force` could silently strand un-pushed local edits. When a config
had local edits and its remote was unchanged, `--force` bypassed the
locally-modified guard, hit the `remote_unchanged` short-circuit, and re-stamped
the manifest `pull_hash` from the *edited* on-disk file. Afterwards `sync diff`
and `sync push` reported "in sync" and shipped nothing while the remote still
held the old config -- data loss with no signal. The silent part was compounded
by the diff `local_override_hashes` optimization, which then never even read the
edited file's content.
Fix splits `--force` by 3-way diff state (config and row granularity):
- local edited, remote UNCHANGED -> preserve file + 3-way base (pending delta
stays visible to `sync push`; no discard, no silent re-stamp)
- local edited, remote ALSO changed -> abort before writing anything
(SyncConflictError, exit 1, code SYNC_CONFLICT) so the user resolves it
- local untouched, remote changed -> take remote (unchanged behavior)
Consequence: `--force` no longer discards non-conflicting local edits. To drop
local edits on purpose, delete the file/dir and pull.
New: errors.SyncConflictError + ErrorCode.SYNC_CONFLICT; read-only conflict
pre-pass SyncService._detect_force_pull_conflicts / _is_conflict (runs before
any write); commands/sync.py prints a red per-config conflict block (human) or a
SYNC_CONFLICT envelope with details.conflicts (--json). --all-projects surfaces
a per-project conflict as that project's error 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) and tests/test_sync_cli.py
(exit 1 + human/JSON envelope). Live-verified read-only against project 1183:
force-pull preserves a locally-edited transformation ("Skipped (1) locally
modified") and `sync diff` still reports modified:1.
Docs: changelog 0.53.0, version bump + make version-sync, CLAUDE.md sync group,
context.py AGENT_CONTEXT, sync-workflow.md, gotchas.md (since v0.53.0),
commands-reference.md, keboola-expert.md.
padak
left a comment
There was a problem hiding this comment.
Review of #376 — fix(sync): conflict-aware pull --force — no more silent baseline corruption (0.53.0)
Generated by
kbagent-pr-reviewersubagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed viamake check, not duplicated here.
Summary
This PR fixes a confirmed data-loss bug in sync pull --force: when a user had un-pushed local edits to a config whose remote was unchanged, a force-pull silently re-stamped the manifest pull_hash from the edited file, stranding the edits while sync diff and sync push reported "in sync". The fix introduces a read-only pre-pass (_detect_force_pull_conflicts / _is_conflict) that branches on the 3-way diff state before writing anything: locally-modified + remote-unchanged configs are preserved, and true merge conflicts (both sides changed) abort the pull with the new SyncConflictError / ErrorCode.SYNC_CONFLICT. Plugin sync, changelog, tests, and documentation surfaces have all been updated. The implementation is sound and the regression tests cover both config and row granularity. Verdict: COMMENT — one non-blocking gap in the --all-projects conflict serialization path and a missing E2E test (per-policy NON-BLOCKING), plus two nits.
Verdict
- Verdict: COMMENT
- Blocking findings: 0
- Non-blocking findings: 2
- Nits: 2
Blocking findings
(none)
Non-blocking findings
[NB-1] src/keboola_agent_cli/services/sync_service.py:2483 — --all-projects conflict surfaces as a plain string, not a structured SYNC_CONFLICT envelope
When sync pull --all-projects --force is used, each per-project pull() call runs in a ThreadPoolExecutor worker. The bare except Exception as exc handler at line 2483 catches SyncConflictError and serialises it as results[alias] = {"error": str(exc)} — a human-readable string — rather than the structured {error: {code: "SYNC_CONFLICT", details: {conflicts: [...]}}} envelope that the single-project path produces. The PR description claims "--all-projects surfaces a per-project conflict as that project's error without aborting the batch", which is functionally true, but AI agents and programmatic consumers parsing --json output have no way to distinguish a SYNC_CONFLICT from a generic error in this path; they cannot enumerate the conflicting configs, cannot determine that a retry-after-resolve strategy applies, and cannot correlate with the details.conflicts schema documented in gotchas.md. A targeted fix would be to detect SyncConflictError in the worker and emit a structured dict (e.g. {"error": str(exc), "error_code": "SYNC_CONFLICT", "conflicts": exc.conflicts}) or, alternatively, add a sentence to gotchas.md and the --all-projects help noting that the --json response for this path does not carry details.conflicts.
[NB-2] tests/test_e2e.py — no E2E test for the sync pull --force conflict-preserve behavior
Per CONTRIBUTING.md "Every new CLI command MUST have a corresponding E2E test in tests/test_e2e.py", and by extension material behavior changes to existing commands warrant E2E coverage. The regression test in tests/test_sync_force_pull_baseline.py is service-layer (no real API call), and the CLI test in tests/test_sync_cli.py uses a mock. A live E2E test that: (1) pulls a project, (2) edits a _config.yml, (3) runs sync pull --force while the remote is unchanged, (4) asserts modified: 1 survives, would guard against regressions in the hash-comparison path that are invisible in unit tests (e.g. a future manifest schema change that stops populating pull_config_hash). Environmental constraints (requires E2E_API_TOKEN + E2E_URL) make this non-blocking for the current PR cycle, consistent with the NON-BLOCKING convention in CONTRIBUTING.md. Recommend deferring one cycle with a tracking note.
Nits
-
[NIT-1]src/keboola_agent_cli/errors.py:290—SyncConflictError.__init__acceptslist[dict]but thedictvalue type is unspecified (dict[str, Any]or a typedTypedDictwould be self-documenting). Current callers always passdict[str, str](no row_id case) ordict[str, str | None](row case has no optional fields in practice). Minor type-annotation improvement. -
[NIT-2]src/keboola_agent_cli/commands/sync.py:252—_format_conflict_listemits the conflict count as part of the Rich markup string but uses a rawn = len(conflicts)local with no early-return guard. Ifconflictsis empty (theoretically unreachable sinceSyncConflictErrorrequires at least one conflict, but the function is called independently), it prints "0 config(s) changed..." which would be confusing. Anif not conflicts: returnguard at the top would make the function safer as a standalone helper.
Verification log
gh pr view 376 --json title,body,files,additions,deletions,baseRefName,headRefName,labels,state→ 16 files, +611/-15, state OPEN, base main, headfix/sync-force-pull-baseline-corruption✓git rev-parse --abbrev-ref HEAD→fix/sync-force-pull-baseline-corruption(worktree on correct branch) ✓gh pr diff 376→ 868 lines, fetched to/tmp/kbagent-pr-376.diff✓- Layer compliance (
typer/clickin services,httpxin commands,formatterin clients) → all empty ✓ (no layer violations) - Convention checks (magic numbers, raw
error_code="..."strings, bareexcept:,print()in src, token exposure) → all empty ✓ grep -n 'sync\.' src/.../permissions.py→sync.init/pull/status/diff/push/branch-link/branch-unlink/branch-statusall registered ✓ (no new sync commands added; existing registrations cover the fix)- Server router check:
ls src/.../server/routers/→ nosync.pyrouter. Confirmed intentional:CLAUDE.mdL450 explicitly documentssync init/pull/push/diff are filesystem-local (no serve REST surface)✓ keboola-expert.md§3 gotcha forsync pull --force(0.53.0+) → present at L307-314 ✓keboola-expert.md§1 Rule 6 VERSION GATE → no new0.53.0entry; the fix is a behavior correction, not a new command, so no version gate entry is strictly required (existingsync push|pull|diff --branch = 0.47.0+remains accurate). The §3 gotcha carries the semantic-change marker. ✓plugins/.../gotchas.md→ new entry tagged(since v0.53.0)at L2430, prose covers preserve/abort/untouched cases and thedetails.conflictsJSON schema ✓plugins/.../commands-reference.md→sync pullline updated with--forcesemantics (since 0.53.0) ✓plugins/.../sync-workflow.md→ "Key behaviors" section updated, new## sync pull --force is conflict-aware (since 0.53.0)subsection added ✓CLAUDE.md## All CLI Commands→ sync group added (was entirely missing before this PR) ✓src/.../commands/context.pyAGENT_CONTEXT→sync pull --forcesemantics description added ✓pyproject.toml→ bumped to0.53.0;plugin.json,marketplace.json,uv.locksynced ✓src/.../changelog.py→0.53.0entry present (detailed root-cause + fix summary) ✓make check→ 3851 passed, 8 skipped, 117 deselected, 14 warnings in 80s (exit 0) ✓make skill-check→ SKILL.md is up-to-date ✓- Service-layer regression tests:
tests/test_sync_force_pull_baseline.pyadded (243 lines), covering config-level preserve (case b), abort (case a), remote-only-changed, and row-level preserve/abort ✓ - CLI-layer tests:
tests/test_sync_cli.pyextended withtest_sync_pull_force_conflict_human(exit 1 + Rich conflict block) andtest_sync_pull_force_conflict_json(SYNC_CONFLICT envelope +details.conflicts) ✓ - E2E: no test for the new conflict-preserve behavior in
tests/test_e2e.py→ NON-BLOCKING [NB-2] - Behavior verification: PR description includes a live sandbox repro against project
padak(id 10539) — force-pulled 38 configs, edited a_config.yml, force-pulled again, confirmedFiles written: 0+Skipped (1) -- locally modified, andsync diffstill reportedmodified: 1. Cannot independently reproduce (no E2E credentials in this environment); unverified live behavior is treated as author-confirmed and consistent with service-layer tests ✓ (with that caveat noted) --all-projectsconflict serialization:pull_allworker catchesSyncConflictErrorvia bareexcept Exception as excat L2483 →results[alias] = {"error": str(exc)}— structurederror_code/conflictsfields not propagated → [NB-1]- Security: no realistic-looking token literals;
SAMPLE_VERIFY_TOKENin new test usestoken_id="tok-001"(fake);_ERROR_CODE_TO_TYPEmapping forSYNC_CONFLICT: "conflict"present ✓
Open questions for the author
(none)
…ull coverage Addresses kbagent-pr-reviewer findings on #376. NB-1: `pull_all()` flattened `SyncConflictError` to `str(exc)`, dropping the `SYNC_CONFLICT` code and the conflicts list, so a `--all-projects --json` consumer (AI agent / script) could not tell a merge conflict from any other error. `pull_all._worker` now catches `SyncConflictError` and stores `{error, error_code, conflicts}`, mirroring the single-project envelope. Unit test in `tests/test_sync_force_pull_baseline.py`. NB-2: adds `tests/test_e2e.py::TestE2ESyncWorkflow::test_sync_force_pull_conflict_aware` -- end-to-end against real Storage: create a config, edit it locally, force-pull with the remote unchanged (assert the edit is preserved and `sync diff` still reports it modified), then mutate the remote and force-pull again (assert exit 1 and `SYNC_CONFLICT`, with the conflict listed). Cleans up the config.
|
Thanks for the review — all four findings addressed:
Changelog updated to note the structured |
Proof the fix worksTwo independent demonstrations — a deterministic red→green regression test (anyone can reproduce it), and a live transcript on the shipped 1. Red → green regression test
Revert just the one-line guard ( With the fix restored:
2. Live transcript on shipped
|
Problem
A user reported (against v0.51.1, project 5785) that
sync pull --forcesilently dropped un-pushed local config edits. Repro:sync pulla config, edit its_config.yml—sync diffcorrectly shows1 to update.sync pull --force(typically to resolve an unrelated config's conflict), with the edited config's remote unchanged.sync diffandsync pushnow report "in sync" and ship nothing — yet the local file still holds the edits and the live remote still holds the old config. The edits are stranded, with no signal.Root cause (confirmed in source + reproduced)
--forceskipped the "locally modified" guard inSyncService.pull(). For a config whose remote was unchanged, theremote_unchangedshort-circuit then re-stamped the manifestpull_hashfrom the edited on-disk file — adopting the un-pushed edits as the new synced baseline. The silent part is compounded by the difflocal_override_hashesoptimization, which skips re-reading a file whose hash matchespull_hash, so the edited content is never even compared.A failing test (
tests/test_sync_force_pull_baseline.py) reproduced the silentmodified: 0deterministically; it is now the regression guard.Fix
pull --forcebecomes conflict-aware, branching on 3-way diff state (config and row granularity), per the maintainer decision:--forcebehaviorSyncConflictError, exit 1, codeSYNC_CONFLICT, lists each conflictA read-only pre-pass (
_detect_force_pull_conflicts/_is_conflict) detects conflicts before anything is written, so the abort is atomic.Behavior change for reviewers
--forceno longer discards non-conflicting local edits — that was the dangerous behavior. To drop local edits on purpose, delete the file (or config dir) and pull.--force's other powers (orphan cleanup, full refresh, taking remote when local is untouched) are unchanged.--all-projectssurfaces a per-project conflict as that project's error without aborting the batch.What's included
errors.SyncConflictError+ErrorCode.SYNC_CONFLICTSyncService._detect_force_pull_conflicts/_is_conflict+ the config/row preserve fix inpull()commands/sync.py: red per-config conflict block (human) /SYNC_CONFLICTenvelope withdetails.conflicts(--json); corrected--forcehelpErrorCode+ behavior change) + changelog + plugin syncCLAUDE.md(sync group was entirely missing),context.py,sync-workflow.md,gotchas.md(since v0.53.0),commands-reference.md,keboola-expert.mdTest plan
tests/test_sync_force_pull_baseline.py— config + row: preserve (case b), abort (case a), remote-only-changed takes remotetests/test_sync_cli.py— exit 1 + human/JSON conflict envelopetycleanpadak(id 10539): pulled 38 configs, addedparameters._kbagent_live_markerto a config's_config.yml, thensync pull --forcereportedFiles written: 0+Skipped (1) -- locally modified, andsync diffafterward still reportedmodified: 1(parameters._kbagent_live_marker added). Pre-fix this would have beenmodified: 0(the silent loss).sync pullis download-only, so the remote project was never mutated.