feat: merge checkbox state on pull instead of overwriting it
A tick was lost in both directions: pull wrote the server body as-is, push sent the local body as-is, last writer won. Tick it in the web UI and the first `push.py --update` dropped it; tick it locally and the first pull dropped it. The usual answer is drift tracking and a three-way merge, which this repo rejected on purpose. It is not needed. A tick is monotone — an item only travels `[ ]` -> `[x]` — so unioning the two sides is a set union, not conflict resolution. One rule for one line type replaces the whole mechanism, and the store stays "not a mirror". `map.merge_checkbox_state` is pure and does the work; `from_api` takes the local body as an optional argument; `pull.py` hands it the copy already on disk. Checkbox parsing is imported from `skills/issue` (`checkboxes` / `set_checkbox`), never redefined here — the domain layer is untouched. The same item text more than once is read as a set: one ticked local item ticks every server line with that text. Pairing duplicates up by order is the alternative, and it can still drop a tick — which is the bug being fixed. The price is documented, not hidden: unticking is not monotone, so a box unticked in the web UI comes back on the next pull. Untick locally, then push. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,10 @@ What crosses the boundary, and what does not:
|
||||
domain Gitea note
|
||||
----------------------------------------------------------------------
|
||||
id (slug) — local only; the tracker never sees it
|
||||
title, body title, body verbatim, both ways
|
||||
title title verbatim, both ways
|
||||
body body verbatim up, verbatim down except
|
||||
checkbox state — see
|
||||
merge_checkbox_state
|
||||
state state open/closed, same vocabulary
|
||||
labels labels[] names both ways; ids only on write
|
||||
assignees assignees[] logins
|
||||
@@ -104,13 +107,59 @@ def numbers_in_body(body):
|
||||
return [int(r[1:]) for r in issue.body_dep_refs(body) if r.startswith("#")]
|
||||
|
||||
|
||||
def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None):
|
||||
def merge_checkbox_state(remote_body, local_body):
|
||||
"""The remote body with every tick the local copy already had put back.
|
||||
|
||||
The one exception to "a pull overwrites the body", and it is deliberately
|
||||
the narrowest one that works. A tick is **monotone** — an item only ever
|
||||
travels `[ ]` -> `[x]` — so the two sides are joined by a set union, not
|
||||
reconciled: no base version, no drift tracking, no conflict to resolve. The
|
||||
set is a set of item TEXTS, and an item comes out ticked when either side
|
||||
has it ticked. Everything else in the body is still the remote's word.
|
||||
|
||||
Matching is on `Checkbox.text`, which the domain parser has already
|
||||
stripped and rejoined with single spaces, so rewrapping a long item does
|
||||
not cost it its tick. It is otherwise literal: reword an item and it is a
|
||||
different item — the tick stays with the wording it was put on.
|
||||
|
||||
**The same text more than once** is read as the rule says, as a set: one
|
||||
ticked local item ticks every remote item with that text. The alternative —
|
||||
pairing duplicates up by order — is the reading that can still drop a tick
|
||||
(local `[ ]` then `[x]`, remote a single line: the ticked one pairs with
|
||||
nothing), and dropping a tick is the bug this exists to fix. Two items
|
||||
whose text is identical are the same item to whoever reads them.
|
||||
|
||||
Pure: no store, no tracker, no I/O. A `local_body` of None or "" — a first
|
||||
pull, an empty store — returns the remote body untouched.
|
||||
|
||||
The price, accepted explicitly: UNticking is not monotone, so a box
|
||||
unticked in the web UI comes back on the next pull. Untick locally, push.
|
||||
"""
|
||||
ticked = {c.text for c in issue.checkboxes(local_body) if c.checked}
|
||||
if not ticked:
|
||||
return remote_body
|
||||
body = remote_body
|
||||
# set_checkbox trades one character for one character, so line numbers read
|
||||
# off `remote_body` stay valid against the partially rewritten `body`.
|
||||
for c in issue.checkboxes(remote_body):
|
||||
if not c.checked and c.text in ticked:
|
||||
body = issue.set_checkbox(body, c.line, True)
|
||||
return body
|
||||
|
||||
|
||||
def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None,
|
||||
local_body=None):
|
||||
"""Build a domain Issue from a Gitea issue payload.
|
||||
|
||||
id_for_number maps a Gitea number to a local slug — dependencies whose
|
||||
target has not been pulled yet are dropped from `depends:` (the body still
|
||||
names them, so nothing is lost) rather than invented."""
|
||||
body = (payload.get("body") or "").strip()
|
||||
names them, so nothing is lost) rather than invented.
|
||||
|
||||
`local_body` is the body of the copy already in the store, when there is
|
||||
one. It contributes exactly one thing: its ticked checkboxes survive the
|
||||
overwrite (merge_checkbox_state). Pass None and the remote body is taken
|
||||
whole, which is what a first pull does."""
|
||||
body = merge_checkbox_state((payload.get("body") or "").strip(), local_body)
|
||||
id_for_number = id_for_number or {}
|
||||
|
||||
numbers = list(numbers_in_body(body))
|
||||
|
||||
Reference in New Issue
Block a user