AnyPS5/tools/debt_split.py

234 lines
8.6 KiB
Python

import argparse
import difflib
import posixpath
import re
import subprocess
import sys
from pathlib import Path
from check_conventions import DEBT as INDEX, debt_name
DEBT_DIR = "docs/dev/debt"
HEADING = re.compile(r"^(#{1,6}) (.+?)\s*$")
LINK = re.compile(r"\]\(([^)\s]+)\)")
def parse(text):
items = []
section = None
current = None
for line in text.splitlines():
heading = HEADING.match(line)
if heading:
section = heading[2] if len(heading[1]) == 3 else section
current = None
items.append(["heading", section, [line]])
elif line.startswith("- "):
current = ["entry", section, [line]]
items.append(current)
elif current and (line.startswith(" ") or line.startswith("\t")):
current[2].append(line)
elif line.strip():
current = None
items.append(["text", section, [line]])
else:
current = None
return [(kind, section, "\n".join(lines)) for kind, section, lines in items]
def key(kind, text):
return debt_name(text) if kind == "entry" else None
def relink(text, source, destination):
def replace(match):
target = match.group(1)
if re.match(r"^[a-z][a-z0-9+.-]*:", target):
return match.group(0)
path, _, anchor = target.partition("#")
resolved = posixpath.normpath(posixpath.join(posixpath.dirname(source), path)) if path else source
moved = posixpath.relpath(resolved, posixpath.dirname(destination))
return "](" + moved + ("#" + anchor if anchor else "") + ")"
return LINK.sub(replace, text)
def debt_file(name):
return f"{DEBT_DIR}/{name}.md"
def render(title, sections, order):
out = [title, ""]
for section in sorted(sections, key=lambda section: order.index(section) if section in order else len(order)):
out += [f"### {section}", ""] + sections[section] + [""]
return "\n".join(out)
def read_debt(root, name):
path = root / debt_file(name)
sections = {}
if path.exists():
section = None
for kind, item_section, text in parse(path.read_text(encoding="utf-8")):
if kind == "heading" and item_section and text.startswith("### "):
section = item_section
sections.setdefault(section, [])
elif kind != "heading" and section:
sections[section].append(text)
return sections
def write_debt(root, name, sections, order):
path = root / debt_file(name)
sections = {section: entries for section, entries in sections.items() if entries}
if not sections:
if path.exists():
path.unlink()
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(render(f"# {name} technical debt", sections, order), encoding="utf-8")
def sections_of(text):
return [section for kind, section, _ in parse(text) if kind == "heading" and section]
def split(text):
order = sections_of(text)
index, files = [], {}
for kind, section, item in parse(text):
name = key(kind, item)
if name is None:
index.append((kind, item))
else:
files.setdefault(name, {}).setdefault(section, []).append(relink(item, INDEX, debt_file(name)))
return index, files, order
def render_index(index):
out = []
for kind, item in index:
if kind == "heading" and out and out[-1] != "":
out.append("")
out.append(item)
if kind == "heading" or kind == "text":
out.append("")
return "\n".join(out).rstrip("\n") + "\n"
def index_note():
return ("text", f"Entries about one library or component are in [debt](debt/), one file per library "
"or component, under the same sections as this page. Entries that belong to none stay here.")
def command_split(root):
text = (root / INDEX).read_text(encoding="utf-8")
index, files, order = split(text)
if not files:
sys.exit(f"{INDEX} has no entries to move")
for name, sections in files.items():
existing = read_debt(root, name)
for section, entries in sections.items():
existing.setdefault(section, []).extend(entries)
write_debt(root, name, existing, order)
if index_note() not in index:
index.insert(1, index_note())
(root / INDEX).write_text(render_index(index), encoding="utf-8")
print("\n".join(sorted(debt_file(name) for name in files)))
def git(root, *args, check=True):
return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, encoding="utf-8", check=check).stdout
def apply_change(root, base, new, index_text):
order = sections_of(index_text) or sections_of(new)
before = [(kind, section, item, key(kind, item)) for kind, section, item in parse(base) if kind == "entry" and key(kind, item)]
after = [(kind, section, item, key(kind, item)) for kind, section, item in parse(new) if kind == "entry" and key(kind, item)]
files = {}
def load(name):
if name not in files:
files[name] = read_debt(root, name)
return files[name]
def locate(entry):
_, section, item, name = entry
entries = load(name).get(section, [])
moved = relink(item, INDEX, debt_file(name))
if moved not in entries:
raise LookupError(f"{debt_file(name)}: entry not found under '{section}': {item.splitlines()[0][:100]}")
return entries, entries.index(moved)
def insert(entry, anchor):
_, section, item, name = entry
entries = load(name).setdefault(section, [])
moved = relink(item, INDEX, debt_file(name))
position = len(entries)
if anchor is not None and anchor[1] == section and anchor[3] == name:
anchored = relink(anchor[2], INDEX, debt_file(name))
if anchored in entries:
position = entries.index(anchored) + 1
entries.insert(position, moved)
matcher = difflib.SequenceMatcher(None, [entry[2] for entry in before], [entry[2] for entry in after], autojunk=False)
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
continue
removed, added = before[i1:i2], after[j1:j2]
kept_old, kept_new = set(), set()
for offset, (old, fresh) in enumerate(zip(removed, added)):
if old[1] == fresh[1] and old[3] == fresh[3]:
entries, position = locate(old)
entries[position] = relink(fresh[2], INDEX, debt_file(fresh[3]))
kept_old.add(offset)
kept_new.add(offset)
for offset, old in enumerate(removed):
if offset not in kept_old:
entries, position = locate(old)
del entries[position]
for offset, fresh in enumerate(added):
if offset in kept_new:
continue
anchor = after[j1 + offset - 1] if j1 + offset > 0 else None
insert(fresh, anchor)
for name, sections in files.items():
write_debt(root, name, sections, order)
return sorted(debt_file(name) for name in files)
def unkeyed(text):
return "\n".join(item for kind, _, item in parse(text) if key(kind, item) is None)
def command_resolve(root):
stages = {}
for line in git(root, "ls-files", "-u", "--", INDEX).splitlines():
meta, _ = line.split("\t", 1)
_, sha, stage = meta.split()
stages[stage] = sha
if not {"1", "2", "3"} <= set(stages):
sys.exit(f"{INDEX} is not in a three-way conflict")
base, ours, theirs = (git(root, "cat-file", "-p", stages[stage]) for stage in "123")
if unkeyed(base) != unkeyed(theirs):
sys.exit(f"{INDEX}: the change also edits lines that stay in {INDEX}; resolve those by hand")
try:
touched = apply_change(root, base, theirs, ours)
except LookupError as error:
sys.exit(str(error))
(root / INDEX).write_text(ours, encoding="utf-8")
git(root, "add", "--", INDEX, *touched)
print("\n".join(touched))
def main():
parser = argparse.ArgumentParser(description=f"Split {INDEX} into one file per library or component, and move entries of a change made before the split")
parser.add_argument("command", choices=("split", "resolve"),
help=f"split: move the entries of {INDEX} into {DEBT_DIR}/; resolve: during a rebase stopped on {INDEX}, apply the stopped commit's entry changes to {DEBT_DIR}/ and stage them")
args = parser.parse_args()
root = Path(git(Path.cwd(), "rev-parse", "--show-toplevel").strip())
command_split(root) if args.command == "split" else command_resolve(root)
if __name__ == "__main__":
main()