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:
naudachu
2026-08-10 15:52:11 +05:00
parent 47f53a7edc
commit d4c43464e5
4 changed files with 515 additions and 9 deletions
+43 -2
View File
@@ -89,7 +89,9 @@ not one per issue. Filters AND together; `--state` defaults to `open`;
`--limit` to 100. Keys and filters are mutually exclusive.
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed
local edits are lost. `--cached` skips issues already on disk.
local edits are lost, with one exception: [checkbox
state](#checkboxes-are-the-one-exception). `--cached` skips issues already on
disk.
**Closed issues stay out of the store.** In filter mode they are enumerated
but not written: `--state all` still shows the whole picture, only `--state
@@ -119,6 +121,39 @@ Two traps this handles for you:
After a pull, draw the graph with `/tea:issue`'s `issue_tree.py` — offline, no
extra requests.
### Checkboxes are the one exception
A checkbox is state, not prose, and it is the one thing a pull does **not**
overwrite. For a checkbox line whose **text** matches a line in the local copy,
`[x]` wins from whichever side has it — tick it in the web UI, tick it locally,
tick it in both, the tick survives.
| part of the body | what a pull does to it |
|---|---|
| prose, headings, everything not a checkbox | overwritten from the server, whole, as before |
| a checkbox whose text is in the local copy | `[x]` from **either** side wins |
| a checkbox whose text is not in the local copy | taken from the server as it stands, ticked or not |
| any issue the store has never seen | written exactly as the server sent it |
This is not drift tracking — [Drift](#drift) stands. A tick is **monotone**: an
item only travels `[ ]``[x]`, so joining the two sides is a set union, not a
conflict to resolve. No base version is kept and nothing is compared against
one; one rule for one line type replaces the whole mechanism.
**The price, and it is real: a box unticked in the web UI comes back on the next
pull.** Unticking is not monotone, so the union cannot see it. Untick locally,
then `push.py --update` — the body goes up whole and the server follows.
Matching is on the item's text after the domain parser has stripped it and
rejoined wrapped lines with single spaces, so rewrapping a long item keeps its
tick. Rewording one does not: different text is a different item. The same text
twice in a body is read as a set — one ticked local copy ticks every server line
with that text.
The parsing is `/tea:issue`'s (`issue.checkboxes` / `issue.set_checkbox`),
imported, never reimplemented here. The rule itself is
`map.merge_checkbox_state`: pure, and testable without a Gitea anywhere.
## Pushing
```bash
@@ -209,7 +244,8 @@ never check out, create, or write anything.
| domain | Gitea | note |
|---|---|---|
| `id` (slug) | — | local only; the tracker never sees it |
| title, body | `title`, `body` | verbatim, both directions |
| title | `title` | verbatim, both directions |
| body | `body` | verbatim up; verbatim down except checkbox state, which is unioned |
| `state` | `state` | same vocabulary |
| `labels` | `labels[]` | names both ways; ids only on write |
| `assignees` | `assignees[]` | logins |
@@ -235,6 +271,11 @@ nothing reconciles, nothing warns that a synced issue changed upstream.
`synced:` tells you how old your copy is; `remote-updated:` what the server
said at that moment. Re-pull when it matters.
Checkbox state is not an exception to this. The union a pull applies reads only
the two bodies in front of it — there is no base version, no history, and no
way for it to report that anything diverged. One rule for one line type,
precisely so the mechanism this section rules out is not needed.
## Rich payloads for everything else
Comments and issues are wrapped by the scripts above. For **other** entities
+53 -4
View File
@@ -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))
+12 -3
View File
@@ -43,7 +43,11 @@ Other flags:
--repo owner/repo default: auto-detect from the CWD git remote
Pulling overwrites the local body: it is a fetch, not a merge. Local edits you
have not pushed are lost. Draw the graph afterwards with the domain's own
have not pushed are lost — with exactly one exception, checkbox state. A `[x]`
on either side wins for any item whose text matches, because a tick is monotone
and unioning the two sides is not conflict resolution (gmap.merge_checkbox_state
has the rule and its price). `--cached` skips an issue before any of that: it is
not read and not merged. Draw the graph afterwards with the domain's own
issue_tree.py — it needs no network.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
@@ -187,13 +191,18 @@ def main():
store_ids.add(id)
number_of_id[number] = id
if args.cached and stored:
skipped.append(id) # untouched, and not one request spent on it
skipped.append(id) # untouched, unread, and not one request spent
else:
extra = _gitea.native_deps(login, base, number) if args.deps else []
# The copy already on disk, as it was when this run started. It
# contributes its ticked checkboxes and nothing else; None when
# the store has never seen this issue.
prev = issues.get(id)
iss, unresolved = gmap.from_api(payload, id, repo,
id_for_number=number_of_id,
extra_numbers=extra,
synced=_gitea.now_iso())
synced=_gitea.now_iso(),
local_body=prev.body if prev else None)
issue.save(root, iss)
sync_comments(login, base, root, id, number, payload.get("comments") or 0)
remote_map[gmap.remote_key(repo, number)] = id