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. `--limit` to 100. Keys and filters are mutually exclusive.
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed **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 **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 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 After a pull, draw the graph with `/tea:issue`'s `issue_tree.py` — offline, no
extra requests. 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 ## Pushing
```bash ```bash
@@ -209,7 +244,8 @@ never check out, create, or write anything.
| domain | Gitea | note | | domain | Gitea | note |
|---|---|---| |---|---|---|
| `id` (slug) | — | local only; the tracker never sees it | | `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 | | `state` | `state` | same vocabulary |
| `labels` | `labels[]` | names both ways; ids only on write | | `labels` | `labels[]` | names both ways; ids only on write |
| `assignees` | `assignees[]` | logins | | `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 `synced:` tells you how old your copy is; `remote-updated:` what the server
said at that moment. Re-pull when it matters. 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 ## Rich payloads for everything else
Comments and issues are wrapped by the scripts above. For **other** entities 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 domain Gitea note
---------------------------------------------------------------------- ----------------------------------------------------------------------
id (slug) — local only; the tracker never sees it 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 state state open/closed, same vocabulary
labels labels[] names both ways; ids only on write labels labels[] names both ways; ids only on write
assignees assignees[] logins 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("#")] 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. """Build a domain Issue from a Gitea issue payload.
id_for_number maps a Gitea number to a local slug — dependencies whose 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 target has not been pulled yet are dropped from `depends:` (the body still
names them, so nothing is lost) rather than invented.""" names them, so nothing is lost) rather than invented.
body = (payload.get("body") or "").strip()
`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 {} id_for_number = id_for_number or {}
numbers = list(numbers_in_body(body)) 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 --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 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. issue_tree.py — it needs no network.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth). Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
@@ -187,13 +191,18 @@ def main():
store_ids.add(id) store_ids.add(id)
number_of_id[number] = id number_of_id[number] = id
if args.cached and stored: 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: else:
extra = _gitea.native_deps(login, base, number) if args.deps 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, iss, unresolved = gmap.from_api(payload, id, repo,
id_for_number=number_of_id, id_for_number=number_of_id,
extra_numbers=extra, extra_numbers=extra,
synced=_gitea.now_iso()) synced=_gitea.now_iso(),
local_body=prev.body if prev else None)
issue.save(root, iss) issue.save(root, iss)
sync_comments(login, base, root, id, number, payload.get("comments") or 0) sync_comments(login, base, root, id, number, payload.get("comments") or 0)
remote_map[gmap.remote_key(repo, number)] = id remote_map[gmap.remote_key(repo, number)] = id
+407
View File
@@ -0,0 +1,407 @@
#!/usr/bin/env python3
"""
Checkbox state survives a pull; everything else in the body does not.
Two levels, on purpose. `map.merge_checkbox_state` is pure, so most of the rule
is pinned down with plain strings and no store anywhere. The pull tests then
prove the rule is actually wired into the write path, with the transport
stubbed at the one seam `test_push_dependencies.py` uses — `_gitea.api`, the
single function that shells out to `tea`. Nothing here touches a network, and
no test may ever be made to.
`skills/*/scripts/` are not packages; they go on sys.path by hand.
"""
import contextlib
import io
import os
import re
import shutil
import sys
import tempfile
import unittest
import urllib.parse
from unittest import mock
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
os.path.join(_ROOT, "skills", "issue", "scripts")):
if _p not in sys.path:
sys.path.insert(0, _p)
import _gitea # noqa: E402
import issue # noqa: E402
import map as gmap # noqa: E402
import pull # noqa: E402
REPO = "claude-skills/tea"
BASE = "repos/%s" % REPO
def body(*criteria, **kw):
"""A body in the canonical shape, with the given `## Acceptance criteria`."""
summary = kw.get("summary", "Прозаическое описание.")
return ("## Summary\n%s\n\n## Spec\nnone\n\n## Acceptance criteria\n%s\n"
% (summary, "\n".join(criteria))).strip()
# --------------------------------------------------------------------------
# the rule itself — pure, no store, no tracker
# --------------------------------------------------------------------------
class MergeCheckboxStateTest(unittest.TestCase):
"""`[x]` wins from whichever side has it, for a matching item text."""
def test_a_local_tick_survives_the_overwrite(self):
got = gmap.merge_checkbox_state(body("- [ ] первое", "- [ ] второе"),
body("- [x] первое", "- [ ] второе"))
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
def test_a_remote_tick_is_kept(self):
got = gmap.merge_checkbox_state(body("- [x] первое", "- [ ] второе"),
body("- [ ] первое", "- [ ] второе"))
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
def test_both_sides_ticked_is_still_ticked(self):
one = body("- [x] первое")
self.assertEqual(gmap.merge_checkbox_state(one, one), one)
def test_the_union_is_taken_item_by_item(self):
got = gmap.merge_checkbox_state(
body("- [x] первое", "- [ ] второе", "- [ ] третье"),
body("- [ ] первое", "- [x] второе", "- [ ] третье"))
self.assertEqual(got, body("- [x] первое", "- [x] второе", "- [ ] третье"))
def test_an_item_the_local_copy_does_not_have_comes_from_the_server(self):
"""Including its state — both states, in both directions."""
got = gmap.merge_checkbox_state(
body("- [x] новое сверху", "- [ ] новое снизу"),
body("- [x] что-то совсем другое"))
self.assertEqual(got, body("- [x] новое сверху", "- [ ] новое снизу"))
def test_prose_is_not_merged(self):
got = gmap.merge_checkbox_state(
body("- [ ] пункт", summary="Новый текст с сервера."),
body("- [x] пункт", summary="Старый локальный текст."))
self.assertIn("Новый текст с сервера.", got)
self.assertNotIn("Старый локальный текст.", got)
self.assertIn("- [x] пункт", got)
def test_a_heading_the_local_copy_added_is_gone(self):
remote = body("- [x] пункт")
got = gmap.merge_checkbox_state(remote, remote + "\n\n## Notes\nмои заметки\n")
self.assertEqual(got, remote)
def test_no_local_copy_returns_the_server_body_untouched(self):
remote = body("- [ ] пункт")
for local in (None, "", " \n"):
self.assertIs(gmap.merge_checkbox_state(remote, local), remote,
"local_body=%r rewrote the body" % local)
def test_nothing_ticked_locally_returns_the_same_object(self):
remote = body("- [ ] пункт")
self.assertIs(gmap.merge_checkbox_state(remote, body("- [ ] пункт")), remote)
def test_no_matching_item_returns_the_same_object(self):
remote = body("- [ ] пункт")
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] другой")), remote)
def test_a_body_with_no_checkboxes_at_all(self):
remote = "## Summary\nодна проза\n"
self.assertIs(gmap.merge_checkbox_state(remote, "- [x] пункт"), remote)
self.assertEqual(gmap.merge_checkbox_state("- [ ] пункт", remote), "- [ ] пункт")
def test_exactly_one_character_changes(self):
"""Ticking a box must not produce a diff wider than the state."""
remote = body("- [ ] пункт", "- [ ] второй")
got = gmap.merge_checkbox_state(remote, body("- [x] пункт", "- [ ] второй"))
diff = [i for i, (a, b) in enumerate(zip(remote, got)) if a != b]
self.assertEqual(len(remote), len(got))
self.assertEqual(len(diff), 1)
self.assertEqual((remote[diff[0]], got[diff[0]]), (" ", "x"))
def test_a_rewrapped_item_keeps_its_tick(self):
"""`Checkbox.text` joins continuation lines with one space, which is
the whole reason matching survives a reflow."""
remote = body("- [ ] длинный пункт, который сервер\n"
" перенёс на две строки")
got = gmap.merge_checkbox_state(
remote, body("- [x] длинный пункт, который сервер перенёс на две строки"))
self.assertIn("- [x] длинный пункт", got)
def test_a_reworded_item_does_not_keep_its_tick(self):
"""Different text is a different item. The tick stays with the wording
it was put on — this is a match, not a guess."""
got = gmap.merge_checkbox_state(body("- [ ] пункт про sqlc"),
body("- [x] пункт про SQLC"))
self.assertEqual(got, body("- [ ] пункт про sqlc"))
def test_the_marker_style_does_not_have_to_match(self):
"""`-`, `*` and `1.` are all checkbox markers to the domain parser, so
the item is the same item however the two sides chose to render it."""
got = gmap.merge_checkbox_state(body("1. [ ] пункт"), body("* [x] пункт"))
self.assertEqual(got, body("1. [x] пункт"))
def test_a_moved_item_keeps_its_tick(self):
"""Matching is on text alone; the section is not part of the key. An
item promoted out of `## Acceptance criteria` is the same item."""
remote = "## Issues\n- [ ] пункт\n"
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
self.assertEqual(got, "## Issues\n- [x] пункт\n")
def test_duplicate_text_is_read_as_a_set(self):
"""The documented reading: one ticked local item ticks every remote
line with that text. Pairing duplicates by order is the alternative,
and it is the one that can still drop a tick."""
got = gmap.merge_checkbox_state(body("- [ ] пункт", "- [ ] пункт"),
body("- [ ] пункт", "- [x] пункт"))
self.assertEqual(got, body("- [x] пункт", "- [x] пункт"))
def test_duplicate_text_never_loses_the_second_tick(self):
"""Two local lines, one remote: order-pairing would drop this tick."""
got = gmap.merge_checkbox_state(body("- [ ] пункт"),
body("- [ ] пункт", "- [x] пункт"))
self.assertEqual(got, body("- [x] пункт"))
def test_an_example_inside_a_code_fence_is_not_ticked(self):
"""The domain parser skips fences whole, and so does the merge: a
`- [ ]` in a fence is markup being shown, not a box anyone may tick."""
remote = "## Spec\n```md\n- [ ] пункт\n```\n\n## Acceptance criteria\n- [ ] пункт\n"
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
self.assertEqual(got, remote.replace("## Acceptance criteria\n- [ ] пункт",
"## Acceptance criteria\n- [x] пункт"))
self.assertIn("```md\n- [ ] пункт\n```", got)
def test_an_existing_capital_X_is_left_alone(self):
remote = body("- [X] пункт")
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] пункт")), remote)
def test_a_capital_X_locally_still_counts_as_ticked(self):
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [X] пункт"))
self.assertEqual(got, body("- [x] пункт"))
def test_unticking_in_the_web_does_not_survive(self):
"""The accepted price, pinned so nobody 'fixes' it by accident:
unticking is not monotone, so a box unticked upstream comes back.
Untick locally and push."""
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
self.assertEqual(got, body("- [x] пункт"))
def test_parsing_is_the_domain_layer_s(self):
"""The acceptance criterion, asserted rather than eyeballed: the rule
calls into skills/issue and defines no checkbox syntax of its own."""
with mock.patch.object(issue, "checkboxes", wraps=issue.checkboxes) as cb, \
mock.patch.object(issue, "set_checkbox", wraps=issue.set_checkbox) as sc:
gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
self.assertTrue(cb.called)
self.assertTrue(sc.called)
def test_no_checkbox_markup_is_spelled_out_in_the_sync_layer(self):
"""The same criterion from the other side: the bracket markup itself
appears nowhere under skills/sync/scripts. Knowing what `[ ]` looks
like is the domain's job, and there is only one copy of it."""
sync = os.path.join(_ROOT, "skills", "sync", "scripts")
for name in sorted(f for f in os.listdir(sync) if f.endswith(".py")):
with open(os.path.join(sync, name)) as f:
code = f.read().split('"""')[0::2] # docstrings dropped
for chunk in code:
for markup in ("[ xX]", "[xX]", "- [ ]", "- [x]"):
self.assertNotIn(markup, chunk,
"%s spells out %r" % (name, markup))
class FromApiTest(unittest.TestCase):
"""The seam between the rule and the translation."""
PAYLOAD = {"number": 42, "title": "T", "html_url": "u",
"body": body("- [ ] пункт")}
def test_local_body_is_optional_and_defaults_to_no_merge(self):
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO)
self.assertEqual(iss.body, body("- [ ] пункт"))
def test_local_body_contributes_its_ticks(self):
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO,
local_body=body("- [x] пункт"))
self.assertEqual(iss.body, body("- [x] пункт"))
def test_an_empty_remote_body_does_not_crash(self):
iss, _ = gmap.from_api({"number": 42, "title": "T", "body": None},
"an-issue", REPO, local_body=body("- [x] пункт"))
self.assertEqual(iss.body, "")
# --------------------------------------------------------------------------
# pull.py — the rule wired into the write path
# --------------------------------------------------------------------------
class FakeGitea(object):
"""A `tea api` that answers issues from memory and remembers the calls."""
def __init__(self):
self.calls = []
self.issues = {}
def add(self, number, title, text, **kw):
p = {"number": number, "title": title, "body": text, "state": "open",
"comments": 0, "html_url": "https://git.example/%s/issues/%d" % (REPO, number),
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
"updated_at": "2026-08-10T00:00:00Z"}
p.update(kw)
self.issues[number] = p
return p
def api(self, login, endpoint, method="GET", payload=None, **kw):
self.calls.append((method, endpoint))
path, _, query = endpoint.partition("?")
params = dict(urllib.parse.parse_qsl(query))
m = re.match(r"^%s/issues/(\d+)$" % re.escape(BASE), path)
if m and method == "GET":
return self.issues.get(int(m.group(1)))
if path == "%s/issues" % BASE and method == "GET":
if int(params.get("page", 1)) > 1:
return []
return [self.issues[n] for n in sorted(self.issues)]
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
class PullTestCase(unittest.TestCase):
"""A temp store and a fake transport."""
def setUp(self):
self.root = tempfile.mkdtemp(prefix="tea-store-")
self.fake = FakeGitea()
for p in (mock.patch.object(_gitea, "api", self.fake.api),
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
p.start()
self.addCleanup(p.stop)
self.addCleanup(shutil.rmtree, self.root, True)
def write_local(self, id, text, number=42):
issue.save(self.root, issue.Issue(
id=id, title="An issue", body=text, labels=["type/task"],
origin="gitea", extra={"gitea": "%s#%d" % (REPO, number),
"url": "https://git.example/x", "synced": "old"}))
def run_pull(self, *argv):
out, err = io.StringIO(), io.StringIO()
args = ["pull.py", "--repo", REPO, "--out", self.root] + list(argv)
with mock.patch.object(sys, "argv", args), \
contextlib.redirect_stdout(out), \
contextlib.redirect_stderr(err):
pull.main()
return out.getvalue(), err.getvalue()
def stored_body(self, id):
return issue.load(self.root, id).body
def raw(self, id):
"""The file on disk, byte for byte — metadata included."""
with open(issue.path_of(self.root, id)) as f:
return f.read()
class PullMergesTicksTest(PullTestCase):
def test_a_tick_made_locally_survives_the_pull(self):
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [x] первое", "- [ ] второе"))
def test_a_tick_made_in_the_web_lands_locally(self):
self.write_local("an-issue", body("- [ ] первое", "- [ ] второе"))
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [x] первое", "- [ ] второе"))
def test_the_rest_of_the_body_is_still_overwritten(self):
self.write_local("an-issue", body("- [x] первое",
summary="Локальная правка прозы."))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] новое с сервера",
summary="Серверная проза."))
self.run_pull("42")
got = self.stored_body("an-issue")
self.assertIn("Серверная проза.", got)
self.assertNotIn("Локальная правка прозы.", got)
self.assertIn("- [x] первое", got)
self.assertIn("- [ ] новое с сервера", got)
def test_an_item_the_server_added_arrives_ticked_if_the_server_ticked_it(self):
self.write_local("an-issue", body("- [ ] первое"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [x] новое с сервера"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [ ] первое", "- [x] новое с сервера"))
def test_filter_mode_merges_too(self):
self.write_local("an-issue", body("- [x] первое"))
self.fake.add(42, "An issue", body("- [ ] первое"))
self.run_pull("--label", "type/task")
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
def test_a_retitled_issue_keeps_its_slug_and_its_ticks(self):
"""The merge hangs off the local id, which is resolved from the number
— a title change must not orphan the ticks."""
self.write_local("an-issue", body("- [x] первое"))
self.fake.add(42, "Completely different title", body("- [ ] первое"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
self.assertEqual(issue.load(self.root, "an-issue").title,
"Completely different title")
class PullIntoAnEmptyStoreTest(PullTestCase):
def test_no_local_file_writes_the_server_body_unchanged(self):
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [x] первое", "- [ ] второе"))
def test_a_store_that_does_not_exist_yet_is_created_and_not_merged(self):
shutil.rmtree(self.root)
self.fake.add(42, "An issue", body("- [ ] первое"))
_out, err = self.run_pull("42")
self.assertIn("created store", err)
self.assertEqual(self.stored_body("an-issue"), body("- [ ] первое"))
class CachedIsUnchangedTest(PullTestCase):
def test_a_skipped_issue_is_neither_read_nor_merged(self):
self.write_local("an-issue", body("- [x] первое"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
before = self.raw("an-issue")
with mock.patch.object(gmap, "merge_checkbox_state") as merge:
out, _ = self.run_pull("42", "--cached")
merge.assert_not_called()
self.assertIn("(cached)", out)
self.assertEqual(self.raw("an-issue"), before)
def test_without_cached_the_same_issue_is_merged(self):
self.write_local("an-issue", body("- [x] первое"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [x] первое", "- [ ] второе"))
class RoundTripTest(PullTestCase):
"""Pull twice with no change in between: the second is a no-op."""
def test_a_repeat_pull_does_not_churn_the_file(self):
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
self.run_pull("42")
first = self.raw("an-issue")
self.run_pull("42")
self.assertEqual(self.raw("an-issue"), first)
if __name__ == "__main__":
unittest.main()