fix: resolve the issue store from the project, not the plugin

`issue.store_root` and `_gitea.PAYLOAD_ROOT` were anchored on `__file__`, on
the reasoning that where an installation keeps its files is a fact about the
installation. That holds for an installation and not for a store.

Installed, the plugin therefore resolved every project's issues inside its own
directory — and a plugin cache is versioned, so the store moved on each
update:

    ~/.claude/plugins/cache/tea/tea/2.0.0/tmp/issues   5 files, 2 origin: local
    ~/.claude/plugins/cache/tea/tea/2.1.0/tmp/issues   12 files
    ~/.claude/plugins/cache/claude-skills/tea/2.2.0/   empty, the current one

Issues written from one project were invisible from the next, and an `origin:
local` file — which IS the issue, the only copy — was stranded a version bump
at a time. Two of them were.

The store is a fact about the project, exactly as the login pin is. So the
anchor is now an explicit marker an operator creates, `.tea/`, searched for up
from $CLAUDE_PROJECT_DIR and then cwd — the pin's order, so the two cannot
disagree about which project this is. Inferred markers were tried and are worse
than useless here: `.git` is in every clone including this plugin's own, and
the agents-sync hook writes an AGENTS.md next to every AGENTS.md, so the plugin
root always carried one and cwd never got a turn.

With no marker anywhere, `store_root()` is None and every entry point reports
which directories it searched. A store in a plausible-looking directory is the
failure this replaces, so nothing falls back to one.

- `.tea/` holds the store and the transport's scratchpad: `.tea/issues`,
  `.tea/payload`. One marker, one walk, one gitignore line.
- `issue_init.py` creates it, moves an old `tmp/issues` store in rather than
  copying, adds `.tea/` to `.gitignore`, and refuses to pick a winner when both
  sides hold the same file name.
- A linked worktree has no marker — it is gitignored — and reaches the main
  checkout's store by the hop the pin already took.
- `parents`, `gitdir_of` and `main_worktree` move from `pin.py` into the domain
  and `pin.py` imports them. The domain depends on nothing, so it is the layer
  all three callers can borrow from, and the walk stays written once: the
  guard, the transport and the store cannot disagree about a directory.

The suite stopped copying the script layers into its fixtures. That is what hid
this: with the scripts inside the fixture, the installation and the project
were the same directory. They are now deliberately far apart, and a regression
test asserts the plugin tree gains no files when commands run against a project
somewhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-11 13:38:39 +05:00
parent 27e4b6b1da
commit fb5445915f
30 changed files with 1193 additions and 430 deletions
+44 -21
View File
@@ -5,7 +5,7 @@ description: Work with this project's issues as units of work — create, read,
# /tea:issue — issues as units of work
An issue is a markdown file in `tmp/issues/`. This skill covers everything you
An issue is a markdown file in `.tea/issues/`. This skill covers everything you
do **with** an issue: writing one, reading one, checking it against the
canonical format, and walking the dependency graph.
@@ -20,7 +20,7 @@ labels, templates, and language rules.
## Identity: the slug
The file name is the id and the id is a slug — `tmp/issues/wire-sqlc-appclick.md`.
The file name is the id and the id is a slug — `.tea/issues/wire-sqlc-appclick.md`.
It never changes, not when the title changes and not when the issue is pushed
somewhere. Tracker numbers live in a metadata field (`gitea: owner/repo#42`),
never in a file name and never in `depends:`.
@@ -34,31 +34,54 @@ All offline, all in `<skill-base-dir>/scripts/`.
| Script | What it does |
|---|---|
| `issue_new.py --type T --title "…"` | create `tmp/issues/<slug>.md` from the type's template |
| `issue_init.py [--at DIR] [--dry-run]` | make a directory a project: create `.tea/`, migrate an old `tmp/issues` store in, add `.tea/` to `.gitignore`. Idempotent |
| `issue_new.py --type T --title "…"` | create `.tea/issues/<slug>.md` from the type's template |
| `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors |
| `issue_ac.py <id> [--check N\|TEXT]` | list the body's checkboxes; tick or untick one |
| `issue_tree.py [id…]` | draw the dependency graph from `depends:` |
| `issue_evict.py [id…] [--dry-run]` | remove closed issues from the store; **never** an `origin: local` one |
| `issue_index.py` | rebuild `tmp/issues/INDEX.md` |
| `issue_index.py` | rebuild `.tea/issues/INDEX.md` |
| `issue.py` | the domain module the others import — not a command |
```
tmp/issues/INDEX.md table of every issue — read this first
tmp/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
tmp/issues/wire-sqlc.comments.md comment thread (written by /tea:sync only)
tmp/issues/tree-<id>.md saved graph (issue_tree.py --write)
.tea/issues/INDEX.md table of every issue — read this first
.tea/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
.tea/issues/wire-sqlc.comments.md comment thread (written by /tea:sync only)
.tea/issues/tree-<id>.md saved graph (issue_tree.py --write)
```
## Where the store is
`<repo root>/tmp/issues`**not** `tmp/issues` relative to wherever you are
standing. The scripts resolve it by walking up from their own file to the
nearest `.git` or `AGENTS.md`, so they all see one store no matter which
directory you run them from, and a `cd` earlier in the session changes nothing.
`<project root>/.tea/issues`**not** `.tea/issues` relative to wherever you
are standing. The project root is the nearest directory up from where you are
that holds a `.tea/` marker: the scripts walk up from `$CLAUDE_PROJECT_DIR`,
then from the current directory. So they all see one store no matter which
subdirectory you run them from, and a `cd` earlier in the session changes
nothing — while a `cd` into a *different* project correctly gets that project's
issues.
`--out` overrides that and is taken **literally**: an absolute path is used as
given, a relative one stays relative to the current directory. Nothing rewrites
what you typed.
**A project has a store because somebody ran `issue_init.py` in it.** The
marker is never inferred from the tree: `.git` is in every clone including this
plugin's own, and inferring from one is how an installed plugin came to keep
other projects' issues inside its own cache directory.
**With no marker anywhere, every command stops and says so**, naming the
directories it searched. It does not fall back to a plausible directory. If you
see that message, either you are not in the project you think you are, or the
project has not been initialized — run:
```bash
python3 <skill-base-dir>/scripts/issue_init.py
```
**In a git worktree, do not initialize.** `.tea/` is gitignored, so a worktree
never has one; the scripts reach the main checkout's store on their own, the
same way the login pin does. Initializing there gives one project two stores,
and the second one is deleted with the branch.
`--out` overrides all of it and is taken **literally**: an absolute path is
used as given, a relative one stays relative to the current directory. Nothing
rewrites what you typed.
Two things follow, and both are deliberate:
@@ -73,11 +96,11 @@ Metadata is one field per line with inline lists precisely so plain `grep`
works. `INDEX.md` first, then the files:
```bash
grep -l 'labels:.*type/bug' tmp/issues/*.md # all bugs
grep -l 'origin: local' tmp/issues/*.md # never pushed anywhere
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
grep -A3 '## Acceptance criteria' tmp/issues/wire-*.md
grep -c '^- \[ \]' tmp/issues/wire-sqlc-appclick.md # open checkboxes
grep -l 'labels:.*type/bug' .tea/issues/*.md # all bugs
grep -l 'origin: local' .tea/issues/*.md # never pushed anywhere
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md # who depends on it
grep -A3 '## Acceptance criteria' .tea/issues/wire-*.md
grep -c '^- \[ \]' .tea/issues/wire-sqlc-appclick.md # open checkboxes
```
Read whole files only for the issues the task actually needs.
@@ -204,7 +227,7 @@ canonical format is a procedure, not improvisation.
filled in. Re-run `issue_index.py` if the labels changed.
The procedure is identical for `origin: local` and `origin: gitea` — it works
on `tmp/issues/<id>.md`, and this layer does not know the difference. Getting
on `.tea/issues/<id>.md`, and this layer does not know the difference. Getting
the rewritten body into the tracker is a separate decision — `push.py --update`
in `/tea:sync` — and is no part of this.
@@ -10,7 +10,7 @@ sync layer's business — see `/tea:sync`.
## Identity
An issue is one file, `tmp/issues/<id>.md`, and `id` is a slug: lowercase
An issue is one file, `.tea/issues/<id>.md`, and `id` is a slug: lowercase
ASCII, digits, single dashes, derived from the title. **The slug is the
identity.** It is stable for the life of the issue — a retitled issue keeps its
slug; an issue pushed to a tracker, deleted locally and fetched back a month
@@ -18,7 +18,7 @@ later keeps it too. Tracker numbers are a foreign key stored in a field, never
the name of anything.
```
tmp/issues/wire-sqlc-appclick.md
.tea/issues/wire-sqlc-appclick.md
```
A slug never contains a dot, which is how the store tells an issue from the
@@ -86,7 +86,7 @@ It is not a *permanent* state, and it is what the file's fate depends on:
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file | **nothing, ever** — in any state, named or not |
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file | removes it once `state: closed` |
**A successful push deletes `tmp/issues/<id>.md`** (and `<id>.comments.md`), on
**A successful push deletes `.tea/issues/<id>.md`** (and `<id>.comments.md`), on
create and on `--update` alike. What is in the store is what has not left this
machine; everything else is fetched again when it is needed. The rule, its
safety conditions, and how the slug survives are `/tea:sync`'s to state.
@@ -183,7 +183,7 @@ sections the reference actually came from.
Draw the graph with `issue_tree.py`. The reverse direction is a grep:
```bash
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md
```
## Shared rules
+181 -48
View File
@@ -16,7 +16,7 @@ only on this machine are first-class, not drafts on their way somewhere.
Identity is a slug derived from the title, and it is the only identity the
domain has. The file name is the id:
tmp/issues/wire-sqlc-appclick.md
.tea/issues/wire-sqlc-appclick.md
---
id: wire-sqlc-appclick
@@ -42,8 +42,8 @@ issue and a synced one without the domain learning a second vocabulary.
Every metadata field is one line and lists are inline, so plain grep works
without a parser:
grep -l 'labels:.*type/bug' tmp/issues/*.md
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
grep -l 'labels:.*type/bug' .tea/issues/*.md
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md # who depends on it
"""
import collections
import os
@@ -52,61 +52,182 @@ import re
# --------------------------------------------------------------------------
# where the store lives
# --------------------------------------------------------------------------
# `<repo root>/tmp/issues`, absolute, resolved once at import.
# `<project root>/.tea/issues`, absolute, resolved once at import — where the
# project root is the nearest directory up from the WORKING DIRECTORY that an
# operator has run `issue_init.py` in.
#
# It used to be the relative `tmp/issues`, which made "the store" whatever
# directory the shell happened to be standing in. One `cd` — and a `cd` outlives
# the command that ran it — was enough for readers to report an empty store on a
# full one and for writers to quietly build a second store beside the first.
# Two anchors have been wrong here, in this order. First the relative
# `tmp/issues`, which made "the store" whatever directory the shell happened to
# be standing in: one `cd` — and a `cd` outlives the command that ran it — and
# readers reported an empty store on a full one while writers built a second
# store beside the first. Then `__file__`, on the reasoning that a script's own
# location is a fact about the installation while cwd is a fact about the last
# `cd`. That reasoning holds for an installation; it does not hold for a STORE.
#
# The anchor is THIS FILE, not the working directory. A script's own location is
# a fact about the installation; cwd is a fact about the last `cd`. Walking up
# from __file__ therefore hands every script in both layers the same answer no
# matter where it is invoked from — including from inside tmp/issues itself.
# Anchored on `__file__`, an installed plugin resolves the store inside its own
# directory — and a plugin cache is versioned, so `~/.claude/plugins/cache/tea/
# tea/2.0.0/tmp/issues` stopped being found the moment the plugin became 2.1.0.
# Issues written from one project landed in the plugin and were invisible from
# the next. `origin: local` files — which ARE the issue, the only copy — were
# stranded a version bump at a time.
#
# So: the store is a fact about the PROJECT, exactly as the login pin is (see
# auth/scripts/pin.py, which has always resolved this way and says why). The
# anchor is an explicit marker an operator created, not a marker inferred from
# the tree: `.git` is present in every clone including this plugin's own, and
# AGENTS.md was worse still — the agents-sync hook writes one next to every
# AGENTS.md, so the plugin root always carried one and cwd never got a turn.
#
# Nothing is guessed when the marker is absent. `store_root()` returns None and
# the callers report which directories were searched; a wrong directory that
# looks like it worked is the failure this replaces.
#
# An explicit --out still wins over all of this, and is used exactly as typed: a
# relative --out stays relative to cwd, because that is what the operator asked
# for. There is no environment override; the store is where the repo is.
# for.
STORE_PARTS = ("tmp", "issues")
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
# git; the agents-sync hook only ever puts one at a repository root.
REPO_MARKERS = (".git", "AGENTS.md")
_HERE = os.path.dirname(os.path.abspath(__file__))
MARKER = ".tea"
STORE_PARTS = (MARKER, "issues")
def repo_root(start):
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None.
def anchors(start=None):
"""The directories a root search starts from, in order, first hit wins.
Markers, not a fixed number of `..` hops: how deep this file sits below the
root is an implementation detail of the repo layout, and the layout is not
a promise."""
`start` overrides them and exists so the resolution can be exercised
against a scratch tree. Otherwise: the project Claude Code was opened on,
then the working directory. The same order as `pin.search_dirs`, for the
same reason — both answer "which project is this", and a project that
disagrees with itself about that has two identities."""
if start is not None:
return [os.path.abspath(start)]
out = []
for d in (os.environ.get("CLAUDE_PROJECT_DIR"), os.getcwd()):
if d and os.path.isdir(d):
d = os.path.abspath(d)
if d not in out:
out.append(d)
return out
# The walk itself — the parent chain and the hop out of a linked worktree —
# lives here rather than in the identity layer that first needed it, because
# the domain is the layer everything else may depend on and it depends on
# nothing. `pin.py` imports these three; one written copy of the walk means the
# guard, the transport and the store cannot disagree about a directory. They
# did once: in a worktree, `tea` worked and every script said "no login
# pinned".
def parents(start):
"""`start` and every ancestor of it, up to the filesystem root."""
d = os.path.abspath(start)
while True:
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
return d
yield d
parent = os.path.dirname(d)
if parent == d:
return None
return
d = parent
def store_root(start=None):
"""Absolute path of the issue store.
def gitdir_of(d):
"""The private git directory `d/.git` points at, or None.
`start` overrides the anchor and exists so the resolution can be exercised
against a scratch tree. When these scripts are not inside a repository at
all, cwd gets a turn; failing that the historical cwd-relative location
stands, made absolute so an error message can name the directory it really
looked in."""
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
root = repo_root(anchor)
if root:
return os.path.join(root, *STORE_PARTS)
return os.path.abspath(os.path.join(*STORE_PARTS))
Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a
directory and there is nothing to follow."""
p = os.path.join(d, ".git")
if not os.path.isfile(p):
return None
try:
with open(p) as f:
head = f.read(4096)
except OSError:
return None
for line in head.splitlines():
line = line.strip()
if line.startswith("gitdir:"):
target = line[len("gitdir:"):].strip()
if not target:
return None
if not os.path.isabs(target):
target = os.path.join(d, target)
return os.path.abspath(target)
return None
def main_worktree(d):
"""If `d` is a linked worktree, the main working tree of its repository.
`<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir`
file holds a path to `<main>/.git`; the main working tree is its parent.
The `.git` basename check keeps this to worktrees: a submodule's `.git`
is a pointer too, but it points into `<super>/.git/modules/…`, and the
tree it belongs to is already on the parent chain."""
gitdir = gitdir_of(d)
if not gitdir or not os.path.isdir(gitdir):
return None
common = gitdir
marker = os.path.join(gitdir, "commondir")
if os.path.isfile(marker):
try:
with open(marker) as f:
rel = f.read().strip()
except OSError:
rel = ""
if rel:
common = os.path.abspath(os.path.join(gitdir, rel))
if os.path.basename(common) != ".git":
return None
root = os.path.dirname(common)
if root and os.path.isdir(root) and root != os.path.abspath(d):
return root
return None
def project_root(start=None):
"""Nearest ancestor of an anchor (inclusive) holding `.tea/`, or None.
A marker, not a fixed number of `..` hops: how deep a caller sits below the
root is an implementation detail of the project layout, and the layout is
not a promise. Walking up means every script sees one store from anywhere
inside the project — including from inside the store itself — while a `cd`
into a DIFFERENT project correctly answers with that project's store.
A linked worktree is the same project on another branch, and the marker is
gitignored, so it is only ever in the main checkout: the chain is searched
first and always wins, then the main working tree of any worktree met on
it. Initializing inside a worktree would give one project two stores, and
the directory holding the second one disappears with the branch."""
for anchor in anchors(start):
hops = []
for d in parents(anchor):
if os.path.isdir(os.path.join(d, MARKER)):
return d
main = main_worktree(d)
if main and main not in hops:
hops.append(main)
for root in hops:
# One level of indirection, never two: a main checkout is not
# itself a linked worktree, so this cannot chain and cannot cycle.
for d in parents(root):
if os.path.isdir(os.path.join(d, MARKER)):
return d
return None
def store_root(start=None):
"""Absolute path of the issue store, or None when no project was found."""
root = project_root(start)
return os.path.join(root, *STORE_PARTS) if root else None
def no_project_error(start=None):
"""Why no store could be resolved, naming every directory searched.
The searched directories are the anchors, not the whole chain above them:
an operator who sees the two places the search began knows immediately
whether it began where they meant it to."""
return ("no %s/ found — searched up from %s. Run issue_init.py in the "
"project you mean to track issues in."
% (MARKER, " and ".join(anchors(start)) or "nowhere"))
ISSUE_ROOT = store_root()
@@ -577,16 +698,20 @@ class StoreMissing(Exception):
def __init__(self, root):
self.root = root
Exception.__init__(self, "store %s does not exist" % root)
Exception.__init__(self, no_project_error() if root is None
else "store %s does not exist" % root)
def store_exists(root):
return os.path.isdir(root)
return root is not None and os.path.isdir(root)
def require_store(root):
"""Assert the store is there before reading or writing it."""
if not os.path.isdir(root):
"""Assert the store is there before reading or writing it.
`root` is None when no project was found at all — a different failure from
a project whose store has not been created yet, and StoreMissing says so."""
if not store_exists(root):
raise StoreMissing(root)
return root
@@ -597,7 +722,11 @@ def create_store(root):
Only the commands that legitimately bootstrap a store call this — issue_new
and pull — and both announce it. Nothing creates a store as a side effect of
a write any more: a missing directory is something to report, not something
to conjure."""
to conjure. An unresolved root is never conjured either: without a marker
there is no project to create a store IN, and guessing one is how a store
ended up inside the plugin."""
if root is None:
raise StoreMissing(None)
if os.path.isdir(root):
return False
os.makedirs(root)
@@ -607,7 +736,11 @@ def create_store(root):
def store_error(root):
"""Why `root` cannot be read as a store, or None when it holds issues.
The two messages are distinct on purpose — see StoreMissing."""
The three messages are distinct on purpose — no project at all, a project
with no store, and a store with nothing in it are three different things to
do next."""
if root is None:
return no_project_error()
if not os.path.isdir(root):
return ("store %s does not exist — nothing was created; pass --out to "
"point elsewhere" % root)
@@ -629,7 +762,7 @@ def all_ids(root):
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):
if not store_exists(root):
return []
return sorted(f[:-3] for f in os.listdir(root)
if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-"))
+4 -1
View File
@@ -81,9 +81,12 @@ def main(argv=None):
g = ap.add_mutually_exclusive_group()
g.add_argument("--check", metavar="N|TEXT", help="tick one item: number or substring")
g.add_argument("--uncheck", metavar="N|TEXT", help="untick one item: number or substring")
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: .tea/issues)")
args = ap.parse_args(argv)
if args.out is None:
sys.exit("issue_ac.py: %s" % issue.no_project_error())
path = issue.path_of(args.out, args.id)
if not os.path.exists(path):
sys.exit("issue_ac.py: no issue %r in %s" % (args.id, args.out))
@@ -27,7 +27,7 @@ def main():
ap.add_argument("--quiet", action="store_true", help="exit code only")
ap.add_argument("--strict", action="store_true", help="treat warnings as errors")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
problem = issue.store_error(args.out)
@@ -156,10 +156,12 @@ def main(argv=None):
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)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args(argv)
root = args.out
if root is None:
sys.exit("issue_evict.py: %s" % issue.no_project_error())
if not issue.store_exists(root):
sys.exit("issue_evict.py: store %s does not exist — nothing to evict" % root)
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
"""
issue_index.py — rebuild tmp/issues/INDEX.md from what is on disk. Offline.
issue_index.py — rebuild .tea/issues/INDEX.md from what is on disk. Offline.
A map of the local store, nothing else. The `origin` column is the only place
the index acknowledges that a tracker exists: `local` means the issue has never
left this machine, `gitea` means the sync layer has pushed or pulled it. Both
are ordinary issues here.
The store is <repo root>/tmp/issues unless --out says otherwise; an existing
The store is <project root>/.tea/issues unless --out says otherwise; an existing
store with nothing in it gets an "_empty_" table, a store that is not there is
an error rather than a directory to create.
@@ -100,7 +100,7 @@ def build(root):
def main():
ap = argparse.ArgumentParser(description="Rebuild the local issue index (offline)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
# An existing store with nothing in it is a legitimate thing to index — it
# gets an "_empty_" table. A store that is not there is not.
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""
issue_init.py — make this project one that tracks issues. Offline.
issue_init.py initialize the current directory
issue_init.py --at ~/code/x initialize somewhere else
issue_init.py --dry-run say what it would do, touch nothing
Creates `.tea/` — the marker every other script resolves the store from. The
marker is deliberately something an operator makes, not something inferred from
the tree: `.git` is in every clone including this plugin's own, so a plugin that
inferred its root from one wrote issues into itself. See issue.py's docstring.
Initializing is therefore a statement, and the only one that matters here:
*this* directory is the project whose issues live in it. It is answered once,
by a person, and every script downstream reads the answer instead of guessing.
What it does, all of it idempotent:
- creates `.tea/issues/` and `.tea/payload/`
- moves an existing `tmp/issues/` and `tmp/payload/` in, if it finds them
- adds `.tea/` to `.gitignore`
The move is the migration off the old layout and it is a move, not a copy: two
stores is the state this whole change exists to prevent, and a store left
behind at the old path is a store somebody will edit by accident. It refuses to
overwrite — if both locations hold a file of the same name, it stops and says
so rather than picking a winner.
`.tea/` is gitignored because an `origin: local` issue is the only copy of that
work and the operator, not this script, decides what goes in a shared history.
Committing the store is a legitimate choice — drop the line if you make it.
"""
import argparse
import os
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue # noqa: E402
LEGACY = {"issues": os.path.join("tmp", "issues"),
"payload": os.path.join("tmp", "payload")}
def gitignore_lines(path):
if not os.path.isfile(path):
return []
with open(path) as f:
return [line.rstrip("\n") for line in f]
def add_to_gitignore(path, entry, dry_run=False):
"""Append `entry` unless some line already ignores it. True when written."""
lines = gitignore_lines(path)
if any(line.strip().rstrip("/") == entry.rstrip("/") for line in lines):
return False
if dry_run:
return True
trailer = "" if not lines or lines[-1] == "" else "\n"
with open(path, "a") as f:
f.write("%s%s\n" % (trailer, entry))
return True
def migrate(src, dst, dry_run=False):
"""Move the contents of `src` into `dst`. Returns what it moved, or None.
Contents, not the directory, so an already-created destination is not a
reason to refuse. A name that exists on both sides is: that is two versions
of one issue, and which one survives is not a decision a migration gets to
make quietly."""
if not os.path.isdir(src):
return None
names = sorted(os.listdir(src))
if not names:
return []
clashes = [n for n in names if os.path.exists(os.path.join(dst, n))]
if clashes:
sys.exit("issue_init.py: %s and %s both hold %s — move or delete one "
"side first; nothing was changed"
% (src, dst, ", ".join(clashes[:5])
+ (" (+%d more)" % (len(clashes) - 5) if len(clashes) > 5 else "")))
if dry_run:
return names
os.makedirs(dst, exist_ok=True)
for n in names:
shutil.move(os.path.join(src, n), os.path.join(dst, n))
try:
os.rmdir(src) # only when we emptied it
except OSError:
pass
return names
def run(root, dry_run=False):
"""Initialize `root`. Returns a list of lines describing what happened."""
done = []
marker = os.path.join(root, issue.MARKER)
fresh = not os.path.isdir(marker)
for name in ("issues", "payload"):
d = os.path.join(marker, name)
if not os.path.isdir(d):
if not dry_run:
os.makedirs(d)
done.append("created %s" % os.path.join(issue.MARKER, name))
for name, legacy in LEGACY.items():
src = os.path.join(root, legacy)
moved = migrate(src, os.path.join(marker, name), dry_run)
if moved:
done.append("moved %d file(s) from %s to %s"
% (len(moved), legacy, os.path.join(issue.MARKER, name)))
elif moved == []:
done.append("%s was empty — nothing to move" % legacy)
if add_to_gitignore(os.path.join(root, ".gitignore"),
issue.MARKER + "/", dry_run):
done.append("added %s/ to .gitignore" % issue.MARKER)
if not done:
done.append("already initialized — nothing to do")
elif fresh:
done.append("%s now tracks issues in %s/issues"
% (root, issue.MARKER))
return done
def main():
ap = argparse.ArgumentParser(
description="Create the .tea/ marker that makes a directory a project")
ap.add_argument("--at", default=os.getcwd(),
help="directory to initialize (default: cwd)")
ap.add_argument("--dry-run", action="store_true",
help="report what would happen; change nothing")
args = ap.parse_args()
root = os.path.abspath(args.at)
if not os.path.isdir(root):
sys.exit("issue_init.py: %s is not a directory" % root)
existing = issue.project_root(root)
if existing and existing != root:
sys.stderr.write(
"warning: %s already sits inside the project at %s — a second "
"marker here gives it a second store, and the nearer one wins.\n"
% (root, existing))
for line in run(root, args.dry_run):
print(("would: " if args.dry_run else "") + line)
return 0
if __name__ == "__main__":
sys.exit(main())
+15 -5
View File
@@ -16,7 +16,7 @@ tracker and removes the file.
issue_new.py --type bug --title "Fix tea-guard crash on empty settings" \
--depends wire-sqlc-appclick --milestone v0.2
Writes tmp/issues/<slug>.md prefilled with the type's template, prints the
Writes .tea/issues/<slug>.md prefilled with the type's template, prints the
path, and rebuilds INDEX.md. Fill the sections in an editor or with Edit; run
issue_check.py when done.
@@ -156,9 +156,14 @@ def main():
ap.add_argument("--depends", action="append", default=[],
help="id this issue depends on; repeat")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
# Before anything reads the store path — slug collision, dependency check.
# There is no store to be second-guessed about when there is no project.
if args.out is None:
sys.exit("issue_new.py: %s" % issue.no_project_error())
labels = ["type/%s" % args.type]
if args.severity:
labels.append("severity/%s" % args.severity)
@@ -183,9 +188,14 @@ def main():
depends=args.depends)
# The first issue in a fresh checkout has to create the store, but it says
# so — and it says where, because the path is absolute.
if issue.create_store(args.out):
sys.stderr.write("created store %s\n" % os.path.abspath(args.out))
# so — and it says where, because the path is absolute. A store it cannot
# place at all is a different answer: creating one is only ever allowed
# inside a project somebody initialized.
try:
if issue.create_store(args.out):
sys.stderr.write("created store %s\n" % os.path.abspath(args.out))
except issue.StoreMissing as e:
sys.exit("issue_new.py: %s" % e)
path = issue.save(args.out, iss)
issue_index.build(args.out)
@@ -13,7 +13,7 @@ this works identically for issues that were never pushed anywhere.
Downwards is what this draws (what an issue depends on). The other direction is
a grep, not a flag:
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md
"""
import argparse
import os
@@ -65,9 +65,9 @@ def main():
ap.add_argument("ids", nargs="*", help="roots (default: issues nothing depends on)")
ap.add_argument("--depth", type=int, default=6, help="max depth (default: 6)")
ap.add_argument("--write", action="store_true",
help="also write tmp/issues/tree-<slug>.md")
help="also write .tea/issues/tree-<slug>.md")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
problem = issue.store_error(args.out)