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

1#!/usr/bin/env python3 

2"""The two things all three skeleton finishers need: a README stub and a git identity. 

3 

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. 

8 

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""" 

13 

14from __future__ import annotations 

15 

16import shutil 

17import subprocess # nosec B404 

18from pathlib import Path 

19 

20HOSTS = {"github": "github.com", "gitlab": "gitlab.com"} 

21 

22 

23def host_domain(host: str) -> str: 

24 """Return the domain for *host*, falling back to GitHub's for an unknown name. 

25 

26 >>> host_domain("github") 

27 'github.com' 

28 >>> host_domain("gitlab") 

29 'gitlab.com' 

30 

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. 

33 

34 >>> host_domain("codeberg") 

35 'github.com' 

36 """ 

37 return HOSTS.get(host, HOSTS["github"]) 

38 

39 

40def host_url(domain: str, owner: str, repo: str) -> str: 

41 """Return the canonical project URL for *owner*/*repo* on *domain*. 

42 

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}" 

49 

50 

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. 

53 

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. 

58 

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 

79 

80 

81def git_identity(target: Path) -> tuple[str | None, str | None]: 

82 """Return ``(name, email)`` from git config in *target*, or ``(None, None)``. 

83 

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 

91 

92 def read(key: str) -> str | None: 

93 """Return the value git config reports for *key*, or None when it is unset. 

94 

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 

107 

108 return read("user.name"), read("user.email") 

109 

110 

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. 

113 

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. 

116 

117 >>> author_entry("Jebel-Quant", "Ada Lovelace", "ada@example.com") 

118 'Ada Lovelace <ada@example.com>' 

119 

120 Either half of the git identity can be missing independently, so all three 

121 degradations are reachable on a real machine: 

122 

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