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
+93 -17
View File
@@ -39,7 +39,7 @@ the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`.
|---|---|
| `remote.py [--state] [--label] [--milestone] [-q TEXT]` | discovery: one line per Gitea issue to stdout, writes nothing |
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty |
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, stamps `gitea:` on success |
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
| `labels.py [--dry-run] [--fix]` | bootstrap the canonical `type/*` + `severity/*` set in a repo; exact names left alone, lookalikes reported, drift fixed only with `--fix` |
| `map.py`, `_gitea.py` | the two layers the commands import — not commands |
@@ -56,8 +56,8 @@ directory. Pass `--out` to override; a relative one stays relative to cwd. Only
## Identity mapping
The local id is a slug; Gitea's is a number. The pair is recorded in the issue
file itself:
The local id is a slug; Gitea's is a number. While a working copy exists, the
pair is in the file:
```
origin: gitea
@@ -66,12 +66,20 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42
synced: 2026-08-09T18:40:00Z
```
`tmp/issues/.remote.json` indexes those fields for fast lookup. It is a cache
over the files, not a second source of truth — delete it and the next command
rebuilds it.
But the file is deleted on push, so the pair also lives in two places that
outlast it: `tmp/issues/.remote.json` (number → slug) and the `<!-- tea:id … -->`
marker in the issue body on the Gitea side. See [How the slug comes
back](#how-the-slug-comes-back).
A retitled issue keeps its slug: the map is keyed by number, so a pull updates
the existing file instead of creating a second one.
`.remote.json` used to be described as an index over the files. It is not one
any more — the files are a subset of what it knows, and its entries deliberately
outlive them. It is the local **ledger**, and `_gitea.rebuild_map` merges into it
rather than reconstructing it, so a rebuild can never drop a pushed issue.
Nothing prunes it: "no file" no longer means "no such issue". Delete it anyway
and nothing is lost — the next pull reads the slug off the marker and writes the
entry back.
A retitled issue keeps its slug: neither record is keyed by the title.
## Pulling
@@ -88,6 +96,12 @@ carries the issue bodies, so a milestone costs **one request per 50 issues**,
not one per issue. Filters AND together; `--state` defaults to `open`;
`--limit` to 100. Keys and filters are mutually exclusive.
**A pull is how a pushed issue comes back.** Push deleted the file, so this is
not refreshing 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 Gitea and even on a
machine that has never seen the issue; see [How the slug comes
back](#how-the-slug-comes-back).
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed
local edits are lost, with one exception: [checkbox
state](#checkboxes-are-the-one-exception). `--cached` skips issues already on
@@ -163,9 +177,64 @@ python3 <skill-base-dir>/scripts/push.py wire-sqlc-appclick
python3 <skill-base-dir>/scripts/push.py --update wire-sqlc-appclick # PATCH
```
**Pushing is additive: the local file is never deleted.** It gains `gitea:`,
`url:`, `synced:`, and `origin:` flips to `gitea`. One issue, visible in two
places — not two kinds of file.
**A successful push DELETES the local file**`tmp/issues/<id>.md` and
`<id>.comments.md` — and prints the number and URL the issue now lives at:
```
created wire-sqlc-appclick #42 https://git.noodles.cam/claude-skills/tea/issues/42
dropped /repo/tmp/issues/wire-sqlc-appclick.md
pull.py 42 to work on it again
```
Once the tracker has the issue, the tracker *is* the issue. What is left in the
store is what has not left this machine. There is no second copy, so there is
nothing to reconcile and no "is mine the fresh one?" to answer — see
[Drift](#drift).
**`--update` deletes too. One rule, no exception.** A PATCH is a push; an issue
that has just been sent is no more local than one that was just created. Edit an
issue by pulling it, changing it, pushing it — the copy is gone again after.
### What has to be true before anything is deleted
In order, and the delete is last:
1. the transport returned — `tea` ran and exited 0 (a non-2xx exits the run), and
2. the answer is an object carrying a positive integer `number`, and on
`--update` **the same number that was PATCHed** (`push.confirmed_number`), and
3. `.remote.json` has been written with number → slug.
Network down, a 422, an empty body, an answer for a different issue: the file is
still there and the run stops with the path in the error. An `origin: local`
issue that was not sent — including a local-only dependency that push only read
to warn about — is never touched. `--dry-run` deletes nothing and sends nothing.
### How the slug comes back
The slug is the issue's identity and the format promises it is stable for life,
so it cannot live only in a file that push is about to delete. Two records, and
the durable one is not local:
| where | survives | how |
|---|---|---|
| `<!-- tea:id wire-sqlc-appclick -->` | a rename in the web UI, a lost `.remote.json`, a fresh clone, another machine | first line of the **tracker-side** body; an HTML comment, so Gitea renders nothing |
| `tmp/issues/.remote.json` | the file being deleted | number → slug, written before the delete |
`pull.py` consults the ledger first (it is the one that knows about files on
disk right now), then the marker, then falls back to slugifying the title for an
issue filed in the web UI that has never had a local name. A marker is only
taken at its word when that slug is free — it never overwrites an issue already
in the store.
**The marker never appears in the local file.** `map.to_payload` puts exactly
one at the top on the way up, `map.from_api` strips every one on the way down.
Strip-all-then-prepend-one is the whole mechanism, which is why a body cannot
accumulate them however many round trips it makes, and why a body that somehow
gained two is cleaned on the next pull.
`depends:` survives the same round trip through Gitea's native links (below):
push writes them, `pull.py --deps` reads them back, and the ledger turns the
numbers into the slugs they had here.
Before anything is sent, `/tea:issue`'s validator runs (exactly one `type/*`,
at most one `severity/*`, English title with no type prefix, `## Summary` /
@@ -243,9 +312,9 @@ never check out, create, or write anything.
| domain | Gitea | note |
|---|---|---|
| `id` (slug) | — | local only; the tracker never sees it |
| `id` (slug) | `<!-- tea:id … -->` | first line of the tracker-side body; stripped out of the local copy |
| title | `title` | verbatim, both directions |
| body | `body` | verbatim up; verbatim down except checkbox state, which is unioned |
| body | `body` | verbatim up except the marker; verbatim down except the marker and checkbox state, which is unioned |
| `state` | `state` | same vocabulary |
| `labels` | `labels[]` | names both ways; ids only on write |
| `assignees` | `assignees[]` | logins |
@@ -266,10 +335,17 @@ Comments are **pull-only** in the store: `<id>.comments.md` is written by
## Drift
There is none tracked. The store is not a mirror: nothing watches Gitea,
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.
There is none tracked, and since push started deleting what it sends there is
very little left to track. A published issue has **one** copy — Gitea's —
except while somebody is working on it, and that window closes at the next
push. Nothing watches Gitea, nothing reconciles, nothing warns that a synced
issue changed upstream. `synced:` tells you how old your working copy is;
`remote-updated:` what the server said at that moment. Re-pull when it matters,
and push when you are done so there is nothing to be stale.
The old question — "I edited this locally, does the server have it, whose text
is newer?" — is answered by the store's contents rather than by a mechanism: a
file that is here has not been pushed.
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