Coverage for plugin/scripts/_skeleton_common.py: 100%
32 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""The two things all three skeleton finishers need: a README stub and a git identity.
4Small on purpose. What lives here is what genuinely does not vary by language — `uv`,
5`cargo` and `go mod` differ on almost everything about a manifest, but a repo needs a
6non-empty `README.md` either way, and the author metadata comes from the same
7`git config` in all three cases.
9`host_url` is here for the same reason: the owner/repo URL goes into `[project.urls]`,
10into `[package].repository`, and nowhere at all on the Go side, so the mapping from
11`--host` to a domain belongs to none of them individually.
12"""
14from __future__ import annotations
16import shutil
17import subprocess # nosec B404
18from pathlib import Path
20HOSTS = {"github": "github.com", "gitlab": "gitlab.com"}
23def host_domain(host: str) -> str:
24 """Return the domain for *host*, falling back to GitHub's for an unknown name.
26 >>> host_domain("github")
27 'github.com'
28 >>> host_domain("gitlab")
29 'gitlab.com'
31 The fallback is deliberate rather than an error: the caller has already written a
32 manifest by this point, and a plausible URL beats a half-initialised repo.
34 >>> host_domain("codeberg")
35 'github.com'
36 """
37 return HOSTS.get(host, HOSTS["github"])
40def host_url(domain: str, owner: str, repo: str) -> str:
41 """Return the canonical project URL for *owner*/*repo* on *domain*.
43 >>> host_url("github.com", "Jebel-Quant", "rhiza-claude")
44 'https://github.com/Jebel-Quant/rhiza-claude'
45 >>> host_url(host_domain("gitlab"), "acme", "widgets")
46 'https://gitlab.com/acme/widgets'
47 """
48 return f"https://{domain}/{owner}/{repo}"
51def seed_readme(target: Path, *, repo: str, description: str | None, create: bool = False) -> bool:
52 """Give an empty `README.md` a title and description; return whether it was written.
54 `uv init --lib` creates `README.md` **empty** — zero bytes. The template's
55 `.rhiza/tests/test_readme_validation.py` asserts ``len(content) > 0``, so a repo
56 built by the documented `/init` chain failed `make rhiza-test` before it had done
57 anything wrong. Closing that gap is exactly the skeleton's remit.
59 Only an empty (or whitespace-only) file is written. `/rhiza:docs` owns the real
60 README and must never find its work overwritten — this is a stub to clear the gate,
61 not a document. Nothing is created if `README.md` is absent, since for uv its
62 absence is a different failure the template reports separately — pass *create* for
63 an initialiser that writes no README at all (`cargo init`, `go mod init`), where the
64 file being missing is the normal case rather than a signal.
65 """
66 readme = target / "README.md"
67 if readme.is_file():
68 if readme.read_text(encoding="utf-8").strip():
69 return False
70 elif not create:
71 return False
72 body = f"# {repo}\n"
73 if description:
74 body += f"\n{description}\n"
75 # No fenced code blocks: the same template test executes any it finds.
76 body += "\nRun `/rhiza:docs` to write this properly.\n"
77 readme.write_text(body, encoding="utf-8")
78 return True
81def git_identity(target: Path) -> tuple[str | None, str | None]:
82 """Return ``(name, email)`` from git config in *target*, or ``(None, None)``.
84 This is where `uv init` gets the authors entry it writes — and when git has no
85 identity configured it writes **no `authors` key at all**, which the template's
86 pyproject gate requires. So the same source is consulted here to fill the gap.
87 """
88 git = shutil.which("git")
89 if git is None: # pragma: no cover - git is present everywhere this runs
90 return None, None
92 def read(key: str) -> str | None:
93 """Return the value git config reports for *key*, or None when it is unset.
95 `git config --get` exits **1** for a key that is simply unset, which is the
96 commonest case here and not a failure — the empty stdout *is* the answer. Any
97 other non-zero exit (no repo, unreadable config) also yields empty stdout, and
98 "no identity available" is the same outcome for all of them.
99 """
100 # rc-ignored: `git config --get` exits 1 on an unset key; empty stdout is the answer.
101 result = subprocess.run( # nosec B603
102 [git, "config", "--get", key], cwd=str(target), capture_output=True, text=True,
103 check=False,
104 ) # fmt: skip
105 value = result.stdout.strip()
106 return value or None
108 return read("user.name"), read("user.email")
111def author_entry(owner: str, name: str | None, email: str | None) -> str:
112 """Render a single ``Name <email>`` author string for a Cargo `authors` array.
114 Falls back to *owner* when git reports no name: the gate needs a non-empty author,
115 and the repo owner is the best fact available on a machine with no git identity.
117 >>> author_entry("Jebel-Quant", "Ada Lovelace", "ada@example.com")
118 'Ada Lovelace <ada@example.com>'
120 Either half of the git identity can be missing independently, so all three
121 degradations are reachable on a real machine:
123 >>> author_entry("Jebel-Quant", None, "team@example.com")
124 'Jebel-Quant <team@example.com>'
125 >>> author_entry("Jebel-Quant", "Ada Lovelace", None)
126 'Ada Lovelace'
127 >>> author_entry("Jebel-Quant", None, None)
128 'Jebel-Quant'
129 """
130 author = name or owner
131 return f"{author} <{email}>" if email else author