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:
+43
-2
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user