#!/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())