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:
@@ -262,8 +262,11 @@ class Issue(object):
|
||||
|
||||
@property
|
||||
def is_local(self):
|
||||
"""True while this issue exists nowhere but here — a durable state,
|
||||
not a pending one."""
|
||||
"""True while this issue exists nowhere but here.
|
||||
|
||||
A complete state, not a pending one — and the state in which this file
|
||||
is the only copy of the work. An issue whose `origin` names somewhere
|
||||
else can be fetched from there again; this one cannot."""
|
||||
return self.origin == LOCAL
|
||||
|
||||
# -- taxonomy views ----------------------------------------------------
|
||||
@@ -617,10 +620,19 @@ def path_of(root, id):
|
||||
|
||||
|
||||
def all_ids(root):
|
||||
"""Every issue in the store, by slug.
|
||||
|
||||
An issue file is named by its slug and a slug has no dot in it (SLUG_OK),
|
||||
so `<id>.comments.md` — the thread the sync layer parks beside an issue —
|
||||
is not one, and neither is anything else that grew a second extension.
|
||||
Without that rule `wire-sqlc.comments` reads as an issue called
|
||||
`wire-sqlc.comments`, and a bare `push.py` tries to file the comment thread
|
||||
as a unit of work."""
|
||||
if not os.path.isdir(root):
|
||||
return []
|
||||
return sorted(f[:-3] for f in os.listdir(root)
|
||||
if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-")))
|
||||
if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-"))
|
||||
and "." not in f[:-3])
|
||||
|
||||
|
||||
def load(root, id):
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
issue_new.py — create an issue in the local store. Offline, always.
|
||||
|
||||
The issue is real the moment this writes the file. Nothing is pending, nothing
|
||||
is a draft awaiting a tracker: `origin: local` is a durable state, and pushing
|
||||
it to Gitea later (see /tea:sync) is optional and additive.
|
||||
is a draft awaiting a tracker: `origin: local` is a complete state and pushing
|
||||
it to Gitea later (see /tea:sync) is optional.
|
||||
|
||||
While it says `local`, this file is the ONLY copy of the work — the store, not
|
||||
a cache of anything. That is what a push changes: it hands the issue to the
|
||||
tracker and removes the file.
|
||||
|
||||
issue_new.py --type task --title "Wire sqlc into the appclick repo layer" \
|
||||
--label tech/sql --label comp/appclick
|
||||
|
||||
Reference in New Issue
Block a user