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:
@@ -3,10 +3,15 @@
|
||||
pull.py — Gitea issues -> the local store.
|
||||
|
||||
Writes flat markdown the domain layer owns and prints a compact index; the raw
|
||||
API payload never reaches the conversation. An issue already in the store keeps
|
||||
its slug even when its title changes on the server — identity is the local id,
|
||||
matched through tmp/issues/.remote.json (and recoverable from the `gitea:`
|
||||
fields if that file is lost).
|
||||
API payload never reaches the conversation.
|
||||
|
||||
**This is how you get a pushed issue back.** `push.py` deletes the local file
|
||||
once Gitea has confirmed it, so pulling is not a refresh of a copy you kept —
|
||||
it is how the copy comes to exist. It lands under the SAME slug it had before,
|
||||
even after a rename in the web UI and even on a machine that has never seen the
|
||||
issue: the slug travels in the body as `<!-- tea:id … -->`, and
|
||||
tmp/issues/.remote.json indexes it by number. See `id_for` for the order those
|
||||
are consulted in. The marker itself is stripped out of what is written to disk.
|
||||
|
||||
Two ways to name what to pull:
|
||||
|
||||
@@ -66,17 +71,37 @@ import map as gmap # noqa: E402
|
||||
|
||||
|
||||
def id_for(payload, store_ids, remote_map, repo, root):
|
||||
"""Existing slug for this remote issue, or a fresh unique one. A retitled
|
||||
issue keeps the slug it was first pulled under — the map is by number."""
|
||||
"""The slug this remote issue belongs under. Three sources, in order.
|
||||
|
||||
1. **`.remote.json`, keyed by number.** The local ledger, and the only one
|
||||
that knows about a file sitting on disk right now, so it wins. A
|
||||
retitled issue keeps the slug it was first pulled under.
|
||||
2. **The `<!-- tea:id … -->` marker in the body** (`gmap.id_in_body`). What
|
||||
makes push -> delete -> pull a round trip rather than a rename: the
|
||||
ledger can be lost (a fresh clone, another machine, a deleted
|
||||
`.remote.json`) and the tracker still remembers what this issue is called
|
||||
here — even after the title was changed in the web UI.
|
||||
3. **The title, slugified.** Issues filed in the web UI have no marker and
|
||||
have never had a local name; this is where they get one.
|
||||
|
||||
A marker is only taken at its word when the slug is free. If a file of that
|
||||
name is already in the store, or the ledger has it under another number, the
|
||||
marker is a collision and not an identity — the name is uniquified
|
||||
(`marked-2`) rather than allowed to overwrite somebody else's issue."""
|
||||
got = remote_map.get(gmap.remote_key(repo, payload["number"]))
|
||||
if got:
|
||||
return got
|
||||
return issue.unique_id(root, issue.slugify(payload.get("title", "")), taken=store_ids)
|
||||
marked = gmap.id_in_body(payload.get("body") or "")
|
||||
if marked and marked not in store_ids and marked not in set(remote_map.values()):
|
||||
return marked
|
||||
return issue.unique_id(root, marked or issue.slugify(payload.get("title", "")),
|
||||
taken=store_ids)
|
||||
|
||||
|
||||
def comments_path(root, id):
|
||||
"""Where an issue's comment thread lives — beside it, under the same slug."""
|
||||
return os.path.join(root, "%s.comments.md" % id)
|
||||
"""Where an issue's comment thread lives — beside it, under the same slug.
|
||||
Named in `_gitea` because push.py has to delete the same file."""
|
||||
return _gitea.comments_path(root, id)
|
||||
|
||||
|
||||
def sync_comments(login, base, root, id, number, count):
|
||||
|
||||
Reference in New Issue
Block a user