diff --git a/skills/sync/SKILL.md b/skills/sync/SKILL.md index b50c524..7241925 100644 --- a/skills/sync/SKILL.md +++ b/skills/sync/SKILL.md @@ -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 diff --git a/skills/sync/scripts/map.py b/skills/sync/scripts/map.py index e89b549..b745cca 100644 --- a/skills/sync/scripts/map.py +++ b/skills/sync/scripts/map.py @@ -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)) diff --git a/skills/sync/scripts/pull.py b/skills/sync/scripts/pull.py index 9d4efd8..e0cc1d4 100644 --- a/skills/sync/scripts/pull.py +++ b/skills/sync/scripts/pull.py @@ -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 diff --git a/tests/test_checkbox_merge.py b/tests/test_checkbox_merge.py new file mode 100644 index 0000000..93cadd1 --- /dev/null +++ b/tests/test_checkbox_merge.py @@ -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()