penult 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
penult/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """penult: from an idea to a publication draft, posted where its author can review it.
2
+
3
+ The seed is a GitHub issue. penult fetches it into a workspace next to it, holds the
4
+ brief, the research notes, the essay, the images and their credits, checks the draft
5
+ with ductus and penlike, renders it as an issue comment, and, once the author says
6
+ so, hands it to ``article`` for an unpublished Substack or Medium draft. The
7
+ thinking in between (research, drafting, the style passes) is an agent's, laid out
8
+ in the ``penult`` skill; the verbs here keep every step from being improvised twice.
9
+
10
+ >>> import penult
11
+ >>> penult.prefs(files={})["preferences"]["platform"]
12
+ 'substack'
13
+ """
14
+
15
+ from penult.base import STAGES, PenultError
16
+ from penult.store import DEFAULT_PREFERENCES, data_dir, preferences
17
+ from penult.tools import (
18
+ TOOLS,
19
+ approve,
20
+ check,
21
+ image_add,
22
+ image_generate,
23
+ image_search,
24
+ mark,
25
+ post,
26
+ prefs,
27
+ prefs_set,
28
+ publish,
29
+ render,
30
+ start,
31
+ status,
32
+ )
33
+ from penult.workspace import Workspace
34
+
35
+ __all__ = [
36
+ "DEFAULT_PREFERENCES",
37
+ "STAGES",
38
+ "TOOLS",
39
+ "PenultError",
40
+ "Workspace",
41
+ "approve",
42
+ "check",
43
+ "data_dir",
44
+ "image_add",
45
+ "image_generate",
46
+ "image_search",
47
+ "mark",
48
+ "post",
49
+ "preferences",
50
+ "prefs",
51
+ "prefs_set",
52
+ "publish",
53
+ "render",
54
+ "start",
55
+ "status",
56
+ ]
penult/__main__.py ADDED
@@ -0,0 +1,72 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+ """``penult`` on the command line: every verb of :data:`penult.tools.TOOLS`.
3
+
4
+ penult start https://github.com/<owner>/essays/issues/30
5
+ penult status docs/<slug>
6
+ penult check docs/<slug>
7
+ penult image-search "a 1990s modem" --folder docs/<slug>
8
+ penult render docs/<slug> && penult post docs/<slug>
9
+ penult approve docs/<slug> --note "looks good, ship it"
10
+ penult publish docs/<slug>
11
+
12
+ ``--json`` anywhere prints the whole result instead of its text. The exit status is 0
13
+ when the result is ok and 1 when it is not.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import functools
19
+ import json
20
+ import sys
21
+
22
+ import cw
23
+
24
+ from penult import tools
25
+ from penult.base import PenultError
26
+
27
+
28
+ def _command(func):
29
+ func = tools.without(func)
30
+
31
+ @functools.wraps(func)
32
+ def command(*args, **kwargs):
33
+ try:
34
+ return func(*args, **kwargs)
35
+ except PenultError as error:
36
+ raise cw.CommandError(str(error)) from error
37
+
38
+ return command
39
+
40
+
41
+ def _egress(as_json):
42
+ def egress(result, *, out, err):
43
+ if as_json or not isinstance(result, dict):
44
+ print(
45
+ json.dumps(result, indent=2, ensure_ascii=False, default=str), file=out
46
+ )
47
+ else:
48
+ print(result.get("text") or result.get("summary") or "", file=out)
49
+ return 0 if not isinstance(result, dict) or result.get("ok", True) else 1
50
+
51
+ return egress
52
+
53
+
54
+ def main(argv=None):
55
+ """Run the command line. The ``penult`` console script."""
56
+ for stream in (sys.stdout, sys.stderr):
57
+ getattr(stream, "reconfigure", lambda **_: None)(errors="backslashreplace")
58
+ argv = list(sys.argv[1:] if argv is None else argv)
59
+ as_json = "--json" in argv
60
+ commands = {f.__name__.replace("_", "-"): _command(f) for f in tools.TOOLS}
61
+ code = cw.dispatch(
62
+ commands,
63
+ [a for a in argv if a != "--json"],
64
+ prog="penult",
65
+ convention=cw.MODERN,
66
+ egress=_egress(as_json),
67
+ )
68
+ raise SystemExit(code)
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
penult/base.py ADDED
@@ -0,0 +1,51 @@
1
+ """Errors, constants and the stage names shared across penult.
2
+
3
+ penult takes a seed (a GitHub issue) to a publication draft posted for review. The
4
+ stages below name where a workspace stands; every verb reads and advances them
5
+ through the workspace state file, and nothing here depends on any other module.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ APP_NAME = "penult"
11
+ DATA_DIR_ENVVAR = "PENULT_DATA_DIR"
12
+
13
+ #: A seed with fewer words than this (title, body and comments together) is "thin":
14
+ #: the agent asks the user where to take it before researching or drafting.
15
+ THIN_SEED_WORDS = 250
16
+
17
+ #: The stages a workspace moves through, in order. ``approved`` is set by the user
18
+ #: (or by the agent relaying the user's words) and gates ``published``.
19
+ STAGES = (
20
+ "seeded",
21
+ "researched",
22
+ "drafted",
23
+ "styled",
24
+ "illustrated",
25
+ "posted",
26
+ "approved",
27
+ "published",
28
+ )
29
+
30
+ STATE_FILE = "penult.json"
31
+ SEED_FILE = "seed.md"
32
+ BRIEF_FILE = "brief.md"
33
+ ESSAY_FILE = "essay.md"
34
+ IMAGES_FILE = "images.md"
35
+ COMMENT_FILE = "comment.md"
36
+ CHECK_FILE = "check.md"
37
+ IMG_DIR = "img"
38
+ RESEARCH_DIR = "research"
39
+
40
+
41
+ class PenultError(Exception):
42
+ """An error whose message says what to do next."""
43
+
44
+
45
+ def stage_index(stage: str) -> int:
46
+ """Where a stage sits in :data:`STAGES`; unknown stages sort first.
47
+
48
+ >>> stage_index("drafted") > stage_index("seeded")
49
+ True
50
+ """
51
+ return STAGES.index(stage) if stage in STAGES else -1
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: penult
3
+ description: Take an idea (a GitHub issue) to a publication draft posted for the author's review, then, once they approve, to an unpublished Substack or Medium draft. Use on "make a draft for <issue url>", "turn this issue into an essay", "write up issue N for Substack", "draft the next essay", "post the draft on the issue", "they approved it, publish the draft", or whenever an essay goes from a seed to a reviewed draft. Drives the whole path with the penult verbs (workspace, state, images within budget, issue comment, publishing hand-off) and the skills that own each step: do-research for checking claims and finding critiques, penlike (and its author-choice guide) and deslop for voice, ductus for machine-sounding passages, illustration for credited images. Posts the draft only where the author asked (the issue thread, public if the repository is); never publishes to a platform, and never sends anything to a platform before the author says the posted draft is good.
4
+ license: MIT
5
+ metadata:
6
+ audience: users
7
+ ---
8
+
9
+ # penult: from a seed to a draft the author can review
10
+
11
+ The author gives you a seed, usually an issue in their essays repository, and wants back a link to a **draft posted as a comment on that issue**, with images inline, in their voice, every claim checked and the other side of each argument known. They iterate with you there. When they say it is good, you make an **unpublished** platform draft and give them the link; they press Publish themselves. Nothing you do publishes to a platform. The issue comment is as public as the repository is, which is what the author asked for; if the essay must stay private until it is out, say so before posting and ask where the review should happen.
12
+
13
+ Everything about *how* the author wants this (voices, platform, image budget, where their credentials are) is in their private preferences: `penult prefs`. Everything about *this* essay (the seed, the brief, the research, the draft, the images and their credits, what was posted) is in the workspace that `penult start` makes next to the issue, in `docs/<slug>/` of its repository, so that it is versioned and searchable later.
14
+
15
+ ## The path
16
+
17
+ ```bash
18
+ penult start <issue url> # from inside the repository's checkout; prints the workspace and whether the seed is thin
19
+ penult status <workspace> # at any time: stage, files, what comes next
20
+ ```
21
+
22
+ ### 1. Seed and brief
23
+
24
+ Read `seed.md`. Fill the `[DECIDE: ...]` lines of `brief.md`: thesis, angle and shape, audience, voices, platform, visual style. The essay is **never a report** unless the author asked for one: no executive summary, no numbered sections of findings, no bullet lists of evidence. It is an argument, a story and its moral, a debunking, a field guide.
25
+
26
+ **If `start` says the seed is thin** (few words, no thesis): do not research or draft. Write the author your questions, each with your recommendation (where to take it, what the point is, who it is for, which style), post them as a comment on the issue, record them in `brief.md`, and stop until they answer. A rich seed (a long body, a worked argument, an existing report) gets no questions: decide, write the assumptions into `brief.md`, and go on. Say in the final hand-over what you assumed.
27
+
28
+ ### 2. Research
29
+
30
+ Load the `do-research` skill, Deep depth. The themes are fixed by the job: **verify every claim the seed makes** (numbers, quotes, attributions, dates), **find the critiques and counter-evidence** so the essay knows both sides, **the standard names** for the things it talks about, and **candidate images**: figures, tables, charts and public-domain art worth using, each with its source and licence. Run the themes as parallel agents; each writes one Vancouver-referenced note into `research/` of the workspace (lowercase filenames, `[name](url)` references, estimates labelled, "unverified" said plainly). Then `penult mark <workspace> --stage researched`. (Every verb below takes the workspace as its first argument or as `--folder`; the examples leave it out for brevity.)
31
+
32
+ ### 3. Draft
33
+
34
+ Write `essay.md` with front matter (`title:`, `subtitle:`, optional `tags:`), the title again as the one `#` heading, relative image paths (`img/...`) and `[^n]` footnotes where a source matters. Hyperlink sources inline the way the author's published essays do. What the research corrected, correct in the essay; where a claim stays contested, let the essay say so in one sentence rather than hedge everywhere. Keep the author's own sentences from the seed wherever they are there. `penult mark --stage drafted`.
35
+
36
+ ### 4. Voice
37
+
38
+ Load the `penlike` skill and run `penlike guide`: it is the author's own rule book for which models fit which subject, audience and part of a text, and what not to overdo. Choose the base voice (the `voice_models` preference, usually the author's own model) and at most two other authors from the guide. Then, passage by passage, with the surrounding paragraphs in view so each rewrite still flows:
39
+
40
+ 1. `ductus gauge essay.md` and your own reading mark the spans that sound machine-made (generalising, balanced-for-balance's-sake, tidy lists of three, summary closers). Those get rewritten first, in the base voice (`penlike brief --like <model> --style essay`).
41
+ 2. The passages the guide reserves for another author (an opening jab, a close, a deadpan debunking) are rewritten in that author's register, short, and only where the subject invites it.
42
+ 3. The `deslop` skill's passes for rhythm, specificity and ownership; then `penlike check` against the base voice, at most two rounds.
43
+
44
+ `penult check <workspace>` runs ductus and penlike over the essay and writes a check file under the private data root (it quotes the author's style-model figures, so it never goes into the workspace); read it, but do not chase a zero: human writing is flagged too, and bland prose with no tell is the worse failure. `penult mark --stage styled`.
45
+
46
+ ### 5. Images
47
+
48
+ For each place an image belongs (the hero, one per major turn of the argument, any figure the research found):
49
+
50
+ - **Existing images first**: `penult image-search "<what the beat shows>" --folder <workspace>` (the `illustration` package: Openverse, public-domain art, with licence and attribution). Take one with `penult image-add <url> --name <file> --credit "<credit line>" --license <licence>`; the credit goes in the Markdown caption.
51
+ - **Prompts**: write at least `min_image_prompts` (default 5) prompts into `images.md`, each saying the beat, the style, the composition. Generate **at most `max_generated_images` (default 2)** of them with `penult image-generate "<prompt>" --name <file>`: the top one or two, chosen for where an image matters most. The budget is deliberate: generated images are paid and often off; the author tries the other prompts by hand and gives you the files (`penult image-add <file> --name ... --credit "Image generated for this essay" --prompt "<the prompt>"`).
52
+ - Figures and tables from the research are redrawn (matplotlib, a clean table image) and credited to their source.
53
+
54
+ Place each image in `essay.md` as its own paragraph: `![alt](img/file.png "Caption with the credit")`. `penult mark --stage illustrated`.
55
+
56
+ ### 6. Post the draft
57
+
58
+ Commit and push the workspace (the comment shows images by their raw GitHub URL, so an unpushed image is a broken picture; `penult post` refuses until they are pushed). Then:
59
+
60
+ ```bash
61
+ penult render <workspace> # writes comment.md: heading, subtitle, absolute images, a footer naming the research folder
62
+ penult post <workspace> # posts it; a second run updates the same comment (--new for another)
63
+ ```
64
+
65
+ Give the author the comment link. Iterate on their comments: edit `essay.md`, re-run the voice checks that matter, `render`, `post`. Record anything they say about voice or images that should hold for later essays with `penlike guide-add "<one line>" --source <issue url>` or `penult prefs-set`.
66
+
67
+ ### 7. Approval, then the platform draft
68
+
69
+ Only when the author says the posted draft is good, in their words:
70
+
71
+ ```bash
72
+ penult approve <workspace> --by <who> --note "<their words>" # of the text as posted; a later edit needs a new post and approval
73
+ penult publish <workspace> # substack by default: an UNPUBLISHED draft via `article draft-substack`; prints the editor link
74
+ penult publish <workspace> --dry-run # what Substack would lose (tables, inline images), no credentials needed
75
+ ```
76
+
77
+ `publish` uses the `article` package with the credentials file named in the preferences (`article_env_file`), never a credential in a repository. Medium is article's Import-a-story step and comes after the Substack post is live. Hand the author the editor link; they review and publish. You do not.
78
+
79
+ ## Hand-over, every time
80
+
81
+ Say: the comment or draft link; which voices were used for which passages and why (from the guide); what the research corrected in the seed; the images used and their credits; the prompts left for the author to try; what you assumed where the seed was silent; and anything the author must do (a credential, an approval, the Publish button).
82
+
83
+ ## Never
84
+
85
+ - Publish, schedule, or send anything to readers. The platform step makes a draft and stops.
86
+ - Put a model, a preference, a credential, or anything from `~/.local/share/penlike` or `~/.local/share/penult` into a repository, an issue or a commit.
87
+ - Spend past the image budget without the author asking, or present a generated image as a photograph.
88
+ - Invent a fact, a number, a quote or a source to fill a gap. A gap is a question, written `[ASK: ...]` in the draft and asked.
89
+ - Turn the essay into a report, or sand off the author's opinions into balance.
penult/github.py ADDED
@@ -0,0 +1,235 @@
1
+ """GitHub, through the ``gh`` and ``git`` commands: read an issue, post or edit a comment, find raw URLs.
2
+
3
+ Every function takes a ``runner`` (the shape of :func:`subprocess.run`) so tests
4
+ run without a network and another transport can be dropped in. The default needs
5
+ an authenticated ``gh``.
6
+
7
+ >>> parse_issue_url("https://github.com/octo/essays/issues/30")
8
+ ('octo', 'essays', 30)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import subprocess
16
+ from pathlib import Path
17
+ from typing import Any, Callable
18
+ from urllib.parse import quote
19
+
20
+ from penult.base import PenultError
21
+
22
+ __all__ = [
23
+ "edit_comment",
24
+ "fetch_issue",
25
+ "parse_issue_url",
26
+ "post_comment",
27
+ "raw_url_base",
28
+ "repo_of_checkout",
29
+ "unpushed",
30
+ ]
31
+
32
+ Runner = Callable[..., Any]
33
+
34
+ _ISSUE_URL = re.compile(
35
+ r"^https?://(?:www\.)?github\.com/([^/]+)/([^/]+)/issues/(\d+)(?:[/#?].*)?$"
36
+ )
37
+ _REMOTE = re.compile(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?/?$")
38
+ _TIMEOUT = 600
39
+
40
+
41
+ def parse_issue_url(url: str) -> tuple[str, str, int]:
42
+ """``(owner, repo, number)`` from an issue URL, or an error that shows the shape."""
43
+ found = _ISSUE_URL.match(url.strip())
44
+ if not found:
45
+ raise PenultError(
46
+ f"not a GitHub issue URL: {url!r}; expected https://github.com/<owner>/<repo>/issues/<n>"
47
+ )
48
+ owner, repo, number = found.groups()
49
+ return owner, repo, int(number)
50
+
51
+
52
+ def _run(runner: Runner, args: list[str], *, what: str, **kwargs: Any) -> str:
53
+ try:
54
+ done = runner(args, capture_output=True, text=True, timeout=_TIMEOUT, **kwargs)
55
+ except FileNotFoundError as error:
56
+ raise PenultError(
57
+ f"{args[0]!r} is not installed; it is needed to {what}"
58
+ ) from error
59
+ if done.returncode != 0:
60
+ detail = (done.stderr or done.stdout or "").strip()
61
+ raise PenultError(f"could not {what}: {detail or 'command failed'}")
62
+ return done.stdout or ""
63
+
64
+
65
+ def fetch_issue(url: str, *, runner: Runner = subprocess.run) -> dict[str, Any]:
66
+ """The issue's title, body, labels and comments, as one JSON-ready dict."""
67
+ owner, repo, number = parse_issue_url(url)
68
+ out = _run(
69
+ runner,
70
+ ["gh", "issue", "view", str(number), "-R", f"{owner}/{repo}", "--json",
71
+ "title,body,labels,comments,url,author,createdAt"],
72
+ what=f"read issue {owner}/{repo}#{number}",
73
+ ) # fmt: skip
74
+ raw = json.loads(out)
75
+ return {
76
+ "kind": "github-issue",
77
+ "owner": owner,
78
+ "repo": repo,
79
+ "number": number,
80
+ "url": raw.get("url") or url,
81
+ "title": raw.get("title", ""),
82
+ "body": raw.get("body") or "",
83
+ "author": (raw.get("author") or {}).get("login", ""),
84
+ "created": raw.get("createdAt", ""),
85
+ "labels": [label.get("name", "") for label in raw.get("labels") or []],
86
+ "comments": [
87
+ {
88
+ "author": (c.get("author") or {}).get("login", ""),
89
+ "created": c.get("createdAt", ""),
90
+ "body": c.get("body") or "",
91
+ "url": c.get("url", ""),
92
+ }
93
+ for c in raw.get("comments") or []
94
+ ],
95
+ }
96
+
97
+
98
+ def post_comment(url: str, body_file: Path, *, runner: Runner = subprocess.run) -> str:
99
+ """Post a file's content as a comment on the issue; returns the comment's URL."""
100
+ owner, repo, number = parse_issue_url(url)
101
+ out = _run(
102
+ runner,
103
+ ["gh", "issue", "comment", str(number), "-R", f"{owner}/{repo}", "--body-file", str(body_file)],
104
+ what=f"comment on {owner}/{repo}#{number}",
105
+ ) # fmt: skip
106
+ return out.strip().splitlines()[-1] if out.strip() else ""
107
+
108
+
109
+ def edit_comment(
110
+ comment_url: str, body_file: Path, *, runner: Runner = subprocess.run
111
+ ) -> str:
112
+ """Replace an existing comment's body (``comment_url`` ends in ``#issuecomment-<id>``)."""
113
+ found = re.search(
114
+ r"github\.com/([^/]+)/([^/]+)/issues/\d+#issuecomment-(\d+)", comment_url
115
+ )
116
+ if not found:
117
+ raise PenultError(f"not an issue-comment URL: {comment_url!r}")
118
+ owner, repo, comment_id = found.groups()
119
+ payload = json.dumps({"body": Path(body_file).read_text(encoding="utf-8")})
120
+ _run(
121
+ runner,
122
+ ["gh", "api", "-X", "PATCH", f"repos/{owner}/{repo}/issues/comments/{comment_id}", "--input", "-"],
123
+ what=f"edit comment {comment_id}",
124
+ input=payload,
125
+ ) # fmt: skip
126
+ return comment_url
127
+
128
+
129
+ def repo_of_checkout(
130
+ path: Path, *, runner: Runner = subprocess.run
131
+ ) -> dict[str, Any] | None:
132
+ """The checkout holding ``path``: its root, GitHub owner/repo, branch and commit, or ``None``."""
133
+ try:
134
+ git = ["git", "-C", str(path)]
135
+ root = _run(
136
+ runner, [*git, "rev-parse", "--show-toplevel"], what="find the repo"
137
+ ).strip()
138
+ git = ["git", "-C", root]
139
+ remote = _run(
140
+ runner, [*git, "remote", "get-url", "origin"], what="read the remote"
141
+ ).strip()
142
+ branch = _run(
143
+ runner, [*git, "rev-parse", "--abbrev-ref", "HEAD"], what="read the branch"
144
+ ).strip()
145
+ sha = _run(runner, [*git, "rev-parse", "HEAD"], what="read the commit").strip()
146
+ except PenultError:
147
+ return None
148
+ found = _REMOTE.search(remote) if "github" in remote else None
149
+ if not found:
150
+ return None
151
+ owner, repo = found.groups()
152
+ return {
153
+ "root": root,
154
+ "owner": owner,
155
+ "repo": repo,
156
+ "branch": branch,
157
+ "sha": sha or branch,
158
+ }
159
+
160
+
161
+ def raw_url_base(checkout: dict[str, Any], folder: Path) -> str:
162
+ """The ``raw.githubusercontent.com`` prefix for files in ``folder`` at the checkout's commit.
163
+
164
+ The commit, not the branch, so the links keep working after the branch is
165
+ merged and deleted.
166
+
167
+ >>> raw_url_base({"root": "/r", "owner": "o", "repo": "e", "branch": "main", "sha": "abc123"}, Path("/r/docs/an essay"))
168
+ 'https://raw.githubusercontent.com/o/e/abc123/docs/an%20essay/'
169
+ """
170
+ rel = (
171
+ Path(folder).resolve().relative_to(Path(checkout["root"]).resolve()).as_posix()
172
+ )
173
+ rel = quote(rel) + "/" if rel not in ("", ".") else ""
174
+ ref = checkout.get("sha") or checkout["branch"]
175
+ return f"https://raw.githubusercontent.com/{checkout['owner']}/{checkout['repo']}/{ref}/{rel}"
176
+
177
+
178
+ def _status_paths(porcelain_z: str) -> set[str]:
179
+ """Paths in ``git status --porcelain -z`` output (a rename lists its old path too).
180
+
181
+ >>> sorted(_status_paths("?? a.png\\0R new.png\\0old.png\\0"))
182
+ ['a.png', 'new.png', 'old.png']
183
+ """
184
+ found: set[str] = set()
185
+ entries = [e for e in porcelain_z.split("\0") if e]
186
+ i = 0
187
+ while i < len(entries):
188
+ entry = entries[i]
189
+ found.add(entry[3:] if len(entry) > 3 else entry)
190
+ if entry[:1] in ("R", "C") and i + 1 < len(entries):
191
+ found.add(entries[i + 1])
192
+ i += 1
193
+ i += 1
194
+ return found
195
+
196
+
197
+ def unpushed(
198
+ checkout: dict[str, Any], paths: list[Path], *, runner: Runner = subprocess.run
199
+ ) -> list[str]:
200
+ """Which of ``paths`` are not yet committed and pushed to the branch's upstream.
201
+
202
+ A raw URL only works for a file that is on GitHub, so ``post`` refuses while any
203
+ image it references is in this list. Paths are compared in git's own form
204
+ (forward slashes, unescaped thanks to ``-z``).
205
+ """
206
+ root = Path(checkout["root"]).resolve()
207
+ names = [Path(p).resolve().relative_to(root).as_posix() for p in paths]
208
+ if not names:
209
+ return []
210
+ git = ["git", "-C", str(root)]
211
+ dirty = _run(
212
+ runner,
213
+ [*git, "status", "--porcelain", "-z", "--", *names],
214
+ what="check the tree",
215
+ )
216
+ missing = _status_paths(dirty)
217
+ try:
218
+ ahead = _run(
219
+ runner,
220
+ [
221
+ *git,
222
+ "log",
223
+ "--name-only",
224
+ "-z",
225
+ "--pretty=format:",
226
+ "@{upstream}..HEAD",
227
+ "--",
228
+ *names,
229
+ ],
230
+ what="compare with the upstream",
231
+ )
232
+ missing |= {p.strip() for p in re.split(r"[\0\n]", ahead) if p.strip()}
233
+ except PenultError:
234
+ missing |= set(names) # no upstream: nothing of this branch is on GitHub
235
+ return sorted(n for n in names if n in missing)