feat: drop the local copy after a successful push

Gitea becomes the source of truth. Once a push is confirmed, push.py
deletes tmp/issues/<id>.md and <id>.comments.md and prints the number and
URL the issue now lives at; the current state is obtained by pulling
again rather than by reconciling. --update follows the same rule, with no
exception: what is local is what has not left.

This reverses three statements AGENTS.md used to make, and rewriting them
is part of the change:

  - "tmp/issues/ is the store, not a cache of Gitea" — it is both, split
    by origin:. An origin: local file is the only copy of the work; an
    origin: gitea file is a deletable working copy.
  - "Pushing is additive: the file is never deleted" — it is deleted.
  - "origin: local is a durable state" — complete, but not durable:
    pushing ends it.

Slug stability, which the format promises for the life of an issue, can
no longer rest on a file push is about to delete. The slug goes up in the
body as a hidden marker, <!-- tea:id <slug> -->, on the first line:
map.to_payload strips every marker and prepends exactly one, map.from_api
strips every marker on the way down, so the local file never holds one
and a body cannot accumulate them however many round trips it makes. The
marker survives a rename in the web UI, a lost .remote.json, a fresh
clone and another machine — none of which a local index does.

Deletion is the last thing that happens to an issue and only after the
transport returned, the answer carried a positive integer number (and, on
--update, the number that was PATCHed — push.confirmed_number), and
.remote.json was written. A raised transport, a non-2xx, an empty or
mismatched body each leave the file on disk and stop the run.

.remote.json is no longer "only an index over the files": its entries now
deliberately outlive them, so it is the local number -> slug ledger and
rebuild_map merges into it instead of reconstructing it from files that
may be gone. It stays recoverable, from the markers in Gitea rather than
from the files. push.dep_state reads it too, so a blocker whose file an
earlier push dropped still gets its native dependency link.

Also fixes a pre-existing bug the new tests hit: issue.all_ids treated
<id>.comments.md as an issue called "<id>.comments", so a bare push.py in
a store holding pulled threads tried to file a comment thread as a unit
of work. A slug has no dot in it.

tests/test_drop_after_push.py covers the round trip (push -> gone -> pull
-> identical in slug, depends: and body), the marker's algebra, and every
failure path separately. test_push_dependencies.py is updated where it
encoded the old "never deleted" contract. 183 tests, no network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-10 16:38:16 +05:00
parent 257c547e22
commit e629d14585
14 changed files with 1404 additions and 116 deletions
+41 -11
View File
@@ -153,12 +153,27 @@ class PushTestCase(unittest.TestCase):
# -- fixtures ----------------------------------------------------------
def write_issue(self, id, title, body=BODY_NO_DEPS, depends=(), extra=None):
def write_issue(self, id, title, body=BODY_NO_DEPS, depends=(), extra=None,
origin=issue.LOCAL):
iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"],
depends=list(depends), extra=dict(extra or {}))
depends=list(depends), origin=origin,
extra=dict(extra or {}))
issue.save(self.root, iss)
return iss
def repull(self, id, body=BODY_NO_DEPS, depends=()):
"""Put a pushed issue back the way `pull.py` would.
Push deletes the file, so anything that pushes the same issue twice has
to fetch it in between — which is the workflow, not a test artifact.
The slug and the number come from the ledger, exactly as `pull.id_for`
would resolve them."""
number = self.number_of(id)
self.assertIsNotNone(number, "%s was never pushed" % id)
return self.write_issue(id, self.fake.titles[number], body=body,
depends=depends, origin="gitea",
extra={"gitea": "%s#%d" % (REPO, number)})
def two_issues(self):
"""first-thing, and second-thing which depends on it."""
self.write_issue("first-thing", "First thing")
@@ -175,7 +190,14 @@ class PushTestCase(unittest.TestCase):
return out.getvalue(), err.getvalue()
def number_of(self, id):
return gmap.number_of(issue.load(self.root, id))
"""The number an id was pushed under, or None.
Read off `.remote.json` rather than the issue file: a successful push
deletes the file, and the ledger is what is left behind."""
for key, got in _gitea.load_map(self.root).items():
if got == id:
return gmap.parse_remote_key(key)[1]
return None
# --------------------------------------------------------------------------
@@ -293,6 +315,8 @@ class IdempotenceTest(PushTestCase):
self.run_push()
self.assertEqual(len(self.fake.dep_posts()), 1)
self.repull("first-thing")
self.repull("second-thing", body=BODY, depends=["first-thing"])
self.run_push("--update")
self.assertEqual(len(self.fake.dep_posts()), 1, "link re-POSTed")
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
@@ -318,10 +342,9 @@ class UpdateCarriesNewLinksTest(PushTestCase):
self.run_push()
self.assertEqual(self.fake.dep_posts(), [])
iss = issue.load(self.root, "second-thing")
iss.depends = ["first-thing"]
iss.body = BODY
issue.save(self.root, iss)
# The issue comes back from Gitea, and the dependency is added to the
# copy that came back — there is no other copy to add it to.
self.repull("second-thing", body=BODY, depends=["first-thing"])
self.run_push("--update", "second-thing")
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
@@ -356,10 +379,13 @@ class DryRunTest(PushTestCase):
class BodyIsVerbatimTest(PushTestCase):
"""The prose is untouched. The id marker is the one thing push adds, and it
comes straight back off — `strip_id_marker` is the inverse."""
def test_depends_on_prose_is_not_rewritten_to_numbers(self):
"""map.py deliberately never edits the prose. Linking must not start."""
self.two_issues()
before = issue.load(self.root, "second-thing").body
self.run_push()
created = [c for c in self.fake.calls
@@ -369,16 +395,20 @@ class BodyIsVerbatimTest(PushTestCase):
self.assertIn("- first-thing — ставит фундамент", second_body)
self.assertNotIn("#101", second_body)
self.assertEqual(second_body, issue.load(self.root, "second-thing").body)
self.assertEqual(gmap.strip_id_marker(second_body), before)
def test_body_survives_a_second_push_unchanged(self):
self.two_issues()
self.run_push()
before = issue.load(self.root, "second-thing").body
self.run_push()
self.repull("first-thing")
self.repull("second-thing", body=before, depends=["first-thing"])
self.assertEqual(issue.load(self.root, "second-thing").body, before)
self.run_push("--update")
patched = [c for c in self.fake.calls if c[0] == "PATCH"]
self.assertIn(before, [c[2]["body"] for c in patched])
self.assertEqual(before, issue.load(self.root, "second-thing").body)
self.assertIn(before, [gmap.strip_id_marker(c[2]["body"]) for c in patched])
class DepStateTest(PushTestCase):