Files
naudachu 83f73c5cea refactor: turn the repo into a two-plugin marketplace
tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.

The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.

tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.

test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:25:28 +05:00

178 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""
issue_evict.py — closed issues leave the store. Offline.
issue_evict.py every closed issue that is not origin: local
issue_evict.py old-thing … only these
issue_evict.py --dry-run print what would go; touch nothing
The store is a working set, not an archive. A closed issue is not a unit of
work any more, and `pull.py` has kept new ones out of filter mode for a while —
but the files already on disk were nobody's job, so the only way to remove one
was `rm` past every script, followed by rebuilding `INDEX.md` by hand. This is
that job.
WHAT IS EVICTED, and it is two conditions, both read off the file:
state: closed the work is done
origin: <tracker> the work is somewhere else too
TWO CONDITIONS, AND THE SECOND ONE IS THE WHOLE SAFETY ARGUMENT. `origin:
local` means this file IS the issue — there is no other copy and deleting it
deletes the work. It is therefore never evicted, in any state, not even when
named explicitly on the command line: a closed local issue is reported and
kept. The only files that go are ones whose own metadata says the work can be
fetched back (`pull.py <n>`), which is the same trade `push.py` makes when it
drops a file the tracker has just confirmed.
That parallel is exact except for where the confirmation comes from. Push has
to ask Gitea, because it is Gitea that just changed. Eviction asks the file,
because `state:` and `origin:` are domain fields and the answer is already in
the store — which is why this command lives in the domain layer and needs no
network, no login, and no `tea`. See `skills/sync/scripts/evict.py` for the
variant that refreshes `state:` from the tracker first; it makes the deletion
decision by calling `run()` below, so there is exactly one implementation of
"what may be evicted" and it is this one.
NOT A ONE-OFF MIGRATION. `pull.py <n>` fetches an issue in any state — a number
is an address, not a query — so a closed issue pulled after an eviction lands on
disk again. That is the tracker being asked a direct question, not a regression,
and the answer is to evict again when you are done with it.
`.remote.json` is deliberately NOT pruned. It is the local number -> slug
ledger, its entries outlive the files they name (that is what makes `pull.py
<n>` land on the same slug after a push deleted the file), and an evicted issue
is in exactly that state. `INDEX.md` is rebuilt, because it *is* a view of the
directory.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue # noqa: E402
import issue_index # noqa: E402
CLOSED = "closed"
# Why an issue was kept, in the receipt. `LOCAL_REASON` is the one that matters:
# it is printed whether or not the issue was named, because "this closed thing
# is still here" needs an answer every time.
LOCAL_REASON = "origin: %s — this file IS the issue" % issue.LOCAL
def classify(issues, ids=None):
"""Split the store into (evict, protected, still_open).
Pure — it reads the loaded issues and decides; nothing here touches disk.
evict closed, and lives in a tracker too: safe to remove
protected closed, but `origin: local`: the only copy of the work
still_open not closed
`ids` restricts the question to those issues; without it the whole store is
considered. A protected issue is returned as such even when it was named
explicitly — naming a file does not make deleting it safe.
"""
chosen = list(ids) if ids else sorted(issues)
evict, protected, still_open = [], [], []
for id in chosen:
iss = issues[id]
if iss.state != CLOSED:
still_open.append(id)
elif iss.is_local:
protected.append(id)
else:
evict.append(id)
return evict, protected, still_open
def remove(root, id):
"""Delete everything the store holds under one slug; return the paths.
Deliberately dumb, and for the same reason `push.drop_local` is: it takes an
id, not a decision. Whether an issue may go is settled by `classify` before
this is reached, so the dangerous half of the operation has no branches in
it at all. There is exactly one call site.
"""
gone = []
for p in issue.slug_files(root, id):
os.remove(p)
gone.append(p)
return gone
def run(root, issues, ids=None, dry_run=False, out=None):
"""Classify, report, remove, rebuild the index. Returns (gone, kept).
The one implementation of eviction, called both by `main` below and by the
sync layer's `evict.py` — which does nothing to this decision except hand
over issues whose `state:` it has just refreshed from the tracker.
`gone` is {id: [paths]} and is empty on a dry run; `kept` is
[(id, why)] for everything considered and not removed.
"""
out = out or sys.stdout
evict, protected, still_open = classify(issues, ids)
gone, kept = {}, []
for id in evict:
paths = issue.slug_files(root, id) if dry_run else remove(root, id)
if not dry_run:
gone[id] = paths
out.write("%-11s %s\n" % ("would evict" if dry_run else "evicted", id))
for p in paths:
out.write(" %s\n" % p)
for id in protected:
kept.append((id, LOCAL_REASON))
out.write("%-11s %s closed, %s\n" % ("kept", id, LOCAL_REASON))
# An open issue is the normal case and says nothing worth a line — unless
# the operator named it, in which case they are owed the reason.
for id in still_open:
kept.append((id, "state: %s" % issues[id].state))
if ids:
out.write("%-11s %s state: %s\n" % ("kept", id, issues[id].state))
if dry_run:
out.write("%d issue(s) would be evicted, %d kept — nothing was touched\n"
% (len(evict), len(kept)))
return gone, kept
out.write("%d issue(s) evicted, %d kept\n" % (len(gone), len(kept)))
# Only when something actually went: the index is a view of the directory,
# and rewriting it after a run that changed nothing is a write nobody asked
# for.
if gone:
path, n = issue_index.build(root)
out.write("index: %s%d issue(s)\n" % (path, n))
return gone, kept
def main(argv=None):
ap = argparse.ArgumentParser(
description="Evict closed issues from the local store (offline)")
ap.add_argument("ids", nargs="*",
help="issue ids (default: every closed issue in the store)")
ap.add_argument("--dry-run", action="store_true",
help="print what would be removed; touch nothing")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args(argv)
root = args.out
if not issue.store_exists(root):
sys.exit("issue_evict.py: store %s does not exist — nothing to evict" % root)
issues = issue.load_all(root)
missing = [i for i in args.ids if i not in issues]
if missing:
sys.exit("issue_evict.py: no such issue(s) in the store: %s"
% ", ".join(missing))
run(root, issues, args.ids, args.dry_run)
return 0
if __name__ == "__main__":
sys.exit(main())