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:
+93
-17
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user