Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
96441ce
WIP (usage checkpoint 2026-10-17; untested on these bytes): adopt-pro…
Oct 17, 2026
c5daced
Merge origin/main (24a977c0) into fix/798-adopt-legs-asked-first
Oct 17, 2026
8466900
adopt-project.py: `execute` asks about both leg repositories before i…
Oct 17, 2026
0bf6c7f
adopt-project.py: a `--local-remote-dir` under a link loop reaches th…
Oct 17, 2026
99cc97e
tests: the gh stubs and stand-ins that meet `execute`'s and `check`'s…
Oct 17, 2026
9166f84
scaffold-project.py, adopt-project.py and setup-project.py: the plan …
Oct 17, 2026
7193663
adopt-project.py: a leg GitHub will not create, named after a source …
Oct 17, 2026
bbf2c3a
Merge main d8b8eb33 into fix/798-adopt-legs-asked-first: `folder_in_t…
Oct 17, 2026
a13f2b2
adopt-project.py: a leg name is taken on GitHub only when `gh repo vi…
Oct 17, 2026
748c8e6
tests: one pins `check` runs as people run it, with no `--local-remot…
Oct 17, 2026
70801c9
tests: the argument of `cmd_execute` is built before the `with pytest…
Oct 18, 2026
495d4b3
adopt-project.py: a root over 100 characters, which no GitHub reposit…
Oct 18, 2026
9ff260f
Merge #861's fix/798-adopt-legs-asked-first (70801c91) into l3-863-gi…
Oct 18, 2026
66fc8f6
family.py, adopt-project.py and the four tools' `--org`: `init` asks …
Oct 18, 2026
99afbc3
adopt-project.py and shape_materialize.py: `check` asks #861's leg qu…
Oct 18, 2026
77feec0
Merge main 33419925 into fix/798-adopt-legs-asked-first: `check` keep…
Oct 18, 2026
79d518d
tests: the two `check` runs of #842's `test_pin_source_top_level.py` …
Oct 18, 2026
25226ab
Merge branch 'main' into fix/798-adopt-legs-asked-first
brettheap Oct 18, 2026
9695935
Merge branch 'main' into fix/838-github-name-limits
brettheap Oct 18, 2026
8ef2feb
Merge #861's final head 25226aba into l3-863-github-limits-init-check…
Oct 18, 2026
722c899
Merge #861's squash fe99b8b0 into l3-863-github-limits-init-check-org…
Oct 18, 2026
cc9e96b
Merge #859's final head 9695935d into l3-863-github-limits-init-check…
Oct 18, 2026
c83b3b6
Merge #859's squash 281df263 into l3-863-github-limits-init-check-org…
Oct 18, 2026
83923dd
Merge branch 'main' into l3-863-github-limits-init-check-org
brettheap Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 150 additions & 22 deletions adopt-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,14 @@
either leg's name, where a name GitHub only redirects (a
repository renamed or moved away) is free -- a NOTE and no
finding where gh cannot answer, so that `check` stays usable
offline. It prints what will happen and changes nothing.
offline. Before that, it asks what `execute` asks of the three
names, the organisation and the topic (#863): a name the disk
cannot hold, and without `--local-remote-dir` a name, an
`org:` or a topic GitHub will not take, each a finding under
`execute`'s code; after a name or an `org:` found there, whose
fix renames both legs, the legs are not asked about, and after
a topic they are. It prints what will happen and changes
nothing.
execute asks about both legs before it makes either (#798), then
creates the two legs, extracts them with `git filter-repo`, makes
the one split commit on a branch of the source, sets the
Expand Down Expand Up @@ -147,7 +154,11 @@
`org:` over the 39 it keeps an organisation's name to (`adopt-org-too-long`),
and an id whose `xf-project-<id>` topic is over its 50
(`adopt-topic-too-long`), which `gh repo edit --add-topic` refused only once
both legs and the split were pushed.
both legs and the split were pushed; and an `org:` holding a character GitHub
gives no organisation (`.`), ending in a hyphen or holding two in a row
(`adopt-bad-org`, #863). `check` finds each of these, the disk's included,
under the same code: `execute` passed what `check` had called `plan ok`
(#863). Both read one list, `_what_cannot_be_held`.

WHY `git filter-repo` AND NOT A VENDORED COPY. Extracting history correctly is
a solved problem with one correct implementation, and a vendored copy of it
Expand Down Expand Up @@ -274,6 +285,8 @@
GITHUB_PROJECT_MAX, GITHUB_TOPIC_MAX, refuse_a_topic_github_cannot_set,
refuse_an_owner_github_cannot_have,
)
# And the characters GitHub gives an organisation's name (#863).
from shape_materialize import refuse_an_owner_github_cannot_spell # noqa: E402
# Whether a value this tool writes into a file is text a file can hold
# (#255), asked beside `one_printable_line`, the trailer's question.
from shape_materialize import unwritable_reason # noqa: E402
Expand Down Expand Up @@ -10616,14 +10629,34 @@ def cmd_check(args) -> int:
if renamed is not None and exc.remediation == renamed:
remediation.append(renamed)
else:
# WHAT THE DISK OR GITHUB WILL NOT HOLD (#863): the names, the
# organisation and the topic, `execute`'s one list, which it refuses
# the first of before either leg exists, each a finding here under
# its code. Only `execute` asked them, so `check` said `plan ok` over
# a plan `execute` then refused.
held, held_fixes, held_codes = _held_findings(
plan, names, local_remote_dir, naming)
findings.extend(held)
remediation.extend(held_fixes)
# LAST, as `execute` asks it last, right before it makes either leg
# (#798), and only of names `execute` would get that far with: what
# stands in the way of the two leg repositories, of the disk under
# `--local-remote-dir` and otherwise of GitHub, with `gh repo view
# --json nameWithOwner`.
# --json nameWithOwner`. NOT ASKED AFTER A FINDING ABOVE WHOSE FIX
# RENAMES THE LEGS (`RENAMES_THE_LEGS`): a name too long is mended
# with a shorter --project, and an `org:` too long or misspelled
# with another `org:`, so `<org>/<Project>-spec` and `-code` change,
# and the question asked now would be asked of two repositories the
# mended plan does not name. A topic finding is mended with another
# `id:`, which names no repository: the legs keep their names, the
# question is still the right one, and asked beside it, one round
# shows both (#871's review, S1), as it is asked beside the
# manifest's and the split branch's findings, which `execute`
# refuses first too.
# A question gh cannot answer is a NOTE here, not a finding.
legs, fixes, unasked = _leg_remote_findings(plan, names,
local_remote_dir)
legs, fixes, unasked = (
([], [], []) if RENAMES_THE_LEGS.intersection(held_codes)
else _leg_remote_findings(plan, names, local_remote_dir))
findings.extend(legs)
remediation.extend(fixes)
notes = [*notes, *unasked]
Expand Down Expand Up @@ -11467,6 +11500,38 @@ def _leg_remote_findings(plan: Plan, names: dict,
return findings, list(dict.fromkeys(fixes)), notes


#: The codes of `_what_cannot_be_held` whose fix changes the names the two
#: leg repositories are asked under, `<org>/<Project>-spec` and
#: `<org>/<Project>-code` (#871's review, S1): `adopt-name-too-long` is
#: mended with a shorter --project, `adopt-org-too-long` and `adopt-bad-org`
#: with another `org:`. `check` asks #861's leg question only where none of
#: them is found. `adopt-topic-too-long` is not one: it is mended with
#: another `id:`, and an id names no repository.
RENAMES_THE_LEGS = frozenset({"adopt-name-too-long", "adopt-org-too-long",
"adopt-bad-org"})


def _held_findings(plan: Plan, names: dict, local_remote_dir: Path | None,
naming: NamingPolicy
) -> tuple[list[str], list[str], list[str]]:
"""`check`'s `(findings, remediation, codes)` for what the disk or
GitHub will not hold of the three names, the organisation and the topic:
every refusal of `_what_cannot_be_held`, the list `execute` refuses the
first of (#863), each as a FINDING under its code, and the codes, which
say whether the leg question is still asked (`RENAMES_THE_LEGS`). Asked
only of an `org:` `execute` would get that far with: another is
`unsafe-value`, refused first in `_repository_urls`."""
try:
org = checked_value("org", _plan_org(plan))
except Refusal:
return [], [], []
refused = list(_what_cannot_be_held(plan, names, org,
local_remote_dir is not None, naming))
return ([f"FINDING {exc.code}: {exc.detail}" for exc in refused],
list(dict.fromkeys(exc.remediation for exc in refused)),
[exc.code for exc in refused])


def _make_the_remote_dir(remote_dir: Path) -> None:
"""`--local-remote-dir`'s `mkdir`, after the question said it can be
made (`folder_in_the_way`): what fails here anyway -- a file put there
Expand Down Expand Up @@ -11996,17 +12061,63 @@ def _plan_org(plan: Plan) -> str:
return str(plan.get("org"))


def _repository_urls(args, plan: Plan, names: dict,
source: Source) -> tuple[bool, dict, dict]:
def _repository_urls(args, plan: Plan, names: dict, source: Source,
naming: NamingPolicy | None = None
) -> tuple[bool, dict, dict]:
"""Where the three repositories are: `(local, repositories, urls)`.

Split out of `cmd_execute` for #138. The assembly root's URL is the SOURCE
itself under `--local-remote-dir`, because an in-place adoption pushes its
split branch back to the repository it read.
split branch back to the repository it read. What the disk or GitHub will
not hold of them is refused here, the first of `_what_cannot_be_held`,
the list `check` finds each of (#863). `naming` gives the topic, and is
loaded when not given.
"""
org = checked_value("org", _plan_org(plan))
repositories = {role: f"{org}/{name}" for role, name in names.items()}
local = args.local_remote_dir is not None
refused = next(_what_cannot_be_held(plan, names, org, local, naming), None)
if refused is not None:
raise refused
return (local, repositories,
_remote_urls(org, names, args.local_remote_dir, source))


def _what_cannot_be_held(plan: Plan, names: dict, org: str, local: bool,
naming: NamingPolicy | None = None):
"""Each `Refusal` of what the disk or GitHub will not hold of the three
names, the organisation and the topic, in the order `execute` asks them,
one at a time: the names, and without `--local-remote-dir` the `org:`,
its length and then its characters, and the topic.

ONE LIST, TWO READERS (#863). `execute` refuses the first one
(`_repository_urls`), before either leg exists; `check` finds every one,
under the same code (`_held_findings`). Only `execute` asked them, so a
plan `check` called `plan ok` was refused by `execute` for a name the
disk or GitHub will not take, an organisation or a topic: one list, as
`_refuse_what_check_finds` makes `check` and `execute` read one list of
the manifest's values, means the two cannot disagree again. It is a
generator, so `execute` asks nothing after the first refusal."""
asked = [lambda: _refuse_names_nothing_keeps(names, org, local)]
if not local:
named = f"{plan.path}: `org:`"
asked += [
lambda: refuse_an_owner_github_cannot_have(
"adopt-org-too-long", org, named, ORG_REMEDIATION),
lambda: refuse_an_owner_github_cannot_spell(
"adopt-bad-org", org, named, ORG_SPELLING_REMEDIATION),
lambda: _refuse_the_plans_topic(plan, naming)]
for ask in asked:
try:
ask()
except Refusal as exc:
yield exc


def _refuse_names_nothing_keeps(names: dict, org: str, local: bool) -> None:
"""`adopt-name-too-long`: a name the disk or GitHub will not hold
(#261, #838), asked by `_what_cannot_be_held` for `execute` and `check`
alike (#863)."""
# Each name is a directory's too: the work directory's for all three, and
# under `--local-remote-dir` a bare `<name>.git` for the two new legs. A
# name of 247 to 250 characters passed every check and died in
Expand All @@ -12032,13 +12143,7 @@ def _repository_urls(args, plan: Plan, names: dict,
# repository's to rename, and keeps "Use a shorter --project"
# (#859's re-review, NIT 3).
raise Refusal(exc.code, exc.detail, _rename_first(
repositories["assembly"], SOURCE_NAME_TOO_LONG)) from None
if not local:
refuse_an_owner_github_cannot_have(
"adopt-org-too-long", org, f"{plan.path}: `org:`",
ORG_REMEDIATION)
return (local, repositories,
_remote_urls(org, names, args.local_remote_dir, source))
f"{org}/{names['assembly']}", SOURCE_NAME_TOO_LONG)) from None


#: How `adopt-org-too-long` and `adopt-topic-too-long` are told to go on
Expand All @@ -12049,6 +12154,12 @@ def _repository_urls(args, plan: Plan, names: dict,
"re-run `plan` with --org, as GitHub spells the organisation, the "
"`<org>` of `https://github.com/<org>`: GitHub gives no user or "
f"organisation a name of more than {GITHUB_OWNER_MAX} characters.")
#: How `adopt-bad-org` is told to go on (#863): `ORG_REMEDIATION`'s way out,
#: without the number, which is not what it broke.
ORG_SPELLING_REMEDIATION = (
"Remediation: no repository was created. Fix `org:` in the plan, or "
"re-run `plan` with --org, as GitHub spells the organisation, the "
"`<org>` of `https://github.com/<org>`.")
TOPIC_REMEDIATION = (
"Remediation: no repository was created. Fix `id:` in the plan, or "
f"re-run `plan` with an explicit --id, of at most {GITHUB_ID_MAX} "
Expand Down Expand Up @@ -12080,13 +12191,29 @@ def _a_leg_named_after_a_long_root(names: dict) -> bool:
for role in ("spec", "code"))


def _refuse_the_plans_topic(plan: Plan,
naming: NamingPolicy | None = None) -> None:
"""`_refuse_a_topic_github_cannot_set` of the topic the plan's id gives,
for `_what_cannot_be_held` (#863). An id that is no project id is
`plan-bad-id`, which `Plan.project_id` refuses where it always was, and
no topic is asked of it here."""
try:
project_id = plan.project_id
except Refusal:
return
naming = naming or NamingPolicy.load(NAMING_POLICY)
_refuse_a_topic_github_cannot_set(plan, naming.topic_for(project_id))


def _refuse_a_topic_github_cannot_set(plan: Plan, topic: str) -> None:
"""`adopt-topic-too-long`: an id whose `xf-project-<id>` topic GitHub
will not set (#838), refused before `_confirm` and before either leg
exists. `_set_the_topic` sets it on all three only once both legs and
the split are pushed, so an id over 39 characters, which a project name
of 40 or more gives by default, ended the run there, exit 2, with the
adoption pushed and the topic unset. Asked in GitHub mode alone: under
exists, with the names and the organisation (`_what_cannot_be_held`,
#863), and found by `check` alike. `_set_the_topic` sets it on all
three only once both legs and the split are pushed, so an id over 39
characters, which a project name of 40 or more gives by default, ended
the run there, exit 2, with the adoption pushed and the topic unset.
Asked in GitHub mode alone: under
`--local-remote-dir` no topic is set. The id is named by where it came
from, `id:` or `DERIVED_ID`, as `_id_field` names it."""
named = "`id:`" if plan.get("id") is not None else DERIVED_ID
Expand Down Expand Up @@ -12966,7 +13093,8 @@ def cmd_execute(args) -> int:
# every value below becomes an argument to `git` or `gh`, so each one is
# validated before it is used rather than after something has gone wrong.
spec_path, code_path, branch, tracking = _checked_plan_values(plan)
local, repositories, urls = _repository_urls(args, plan, names, source)
local, repositories, urls = _repository_urls(args, plan, names, source,
naming)
_refuse_an_unwritable_clone_url(urls["assembly"])
paths_for = _leg_paths(plan)
_refuse_what_check_finds(plan, source, args.local_remote_dir)
Expand All @@ -12988,9 +13116,9 @@ def cmd_execute(args) -> int:
_extracts_a_leg(paths_for))
for line in plan.seeding_record_disagreements(seeded):
print(line)
# The topic GitHub will not set was refused in `_repository_urls`, with
# the names and the organisation, as `check` finds them (#863).
topic = naming.topic_for(plan.project_id)
if not local:
_refuse_a_topic_github_cannot_set(plan, topic)
print(_topics_line(topic, local))
_confirm(args, plan, names)

Expand Down
13 changes: 10 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -888,9 +888,16 @@ named.

options:
-h, --help show this help message and exit
--org ORG
--family FAMILY the holder's name, ONE CamelCase token, e.g. InkRouter
--id ID lowercase family id (default: --family lowercased)
--org ORG the organisation the holder is created in; at most 39
characters without --local-remote-dir, GitHub's limit
on an organisation's name
--family FAMILY the holder's name, ONE CamelCase token, e.g.
InkRouter, and its repository's: at most 100
characters without --local-remote-dir, GitHub's limit
on a repository's name
--id ID lowercase family id (default: --family lowercased); at
most 39 characters without --local-remote-dir, as
GitHub keeps the topic `xf-project-<id>` to 50
--name NAME display name (default: --family); validate-family.py
holds it to the same rule
--visibility {private,public,internal}
Expand Down
26 changes: 19 additions & 7 deletions scaffold-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@
directory's is refused as `scaffold-name-too-long`, and so is one GitHub will
not give a repository; an `--org` longer than GitHub keeps one is
`scaffold-org-too-long`, and an id whose topic GitHub will not set is
`scaffold-topic-too-long` (#838). GitHub's three limits are asked only
without `--local-remote-dir`, by the runs that make repositories on GitHub.
`scaffold-topic-too-long` (#838). An `--org` holding a character GitHub gives
no organisation (`.`), ending in a hyphen or holding two in a row is
`scaffold-bad-org` (#863). GitHub's limits are asked only without
`--local-remote-dir`, by the runs that make repositories on GitHub.

IDEMPOTENCY. It refuses to write into a remote or a working directory that
already exists. There is no --force: re-running over a live project is not a
Expand Down Expand Up @@ -120,6 +122,8 @@
GITHUB_TOPIC_MAX, refuse_a_topic_github_cannot_set,
refuse_an_owner_github_cannot_have,
)
# And the characters GitHub gives an organisation's name (#863).
from shape_materialize import refuse_an_owner_github_cannot_spell # noqa: E402
from shape_advisory import scaffold_lines # noqa: E402
# Whether `--elected-by`, `--reference` and `--name` are text a file can
# hold: the question `adopt-project.py` asks of the same values (#282).
Expand Down Expand Up @@ -876,16 +880,22 @@ def _refuse_bad_id(project_id: str, given: str | None) -> None:
f"{NOTHING_MADE} Pass --org as GitHub spells the organisation, the "
"`<org>` of `https://github.com/<org>`: GitHub gives no user or "
f"organisation a name of more than {GITHUB_OWNER_MAX} characters.")
#: How `scaffold-bad-org` is told to go on (#863): `ORG_REMEDIATION`'s way
#: out, without the number, which is not what it broke.
ORG_SPELLING_REMEDIATION = (
f"{NOTHING_MADE} Pass --org as GitHub spells the organisation, the "
"`<org>` of `https://github.com/<org>`.")


def _refuse_what_github_cannot_hold(args, project_id: str, policy) -> None:
"""`scaffold-org-too-long` and `scaffold-topic-too-long`: an `--org`
GitHub gives no organisation, and an id whose `xf-project-<id>` topic
GitHub will not set (#838), refused where GitHub is asked, before any
repository or work directory exists and under `--dry-run` alike. No
process has started yet where `--elected-by` or GIT_AUTHOR_NAME names
the elector; otherwise `_identity` has asked `git config user.name`
first, as it has before `scaffold-bad-id`.
GitHub will not set (#838); and `scaffold-bad-org`, an `--org` whose
characters GitHub gives no organisation (#863). Each is refused where
GitHub is asked, before any repository or work directory exists and
under `--dry-run` alike. No process has started yet where `--elected-by`
or GIT_AUTHOR_NAME names the elector; otherwise `_identity` has asked
`git config user.name` first, as it has before `scaffold-bad-id`.

THE TOPIC IS SET LAST (`_set_topics`), after `_create_remotes`,
`_seed_legs` and `_build_assembly_root`, so an id over 39 characters,
Expand All @@ -897,6 +907,8 @@ def _refuse_what_github_cannot_hold(args, project_id: str, policy) -> None:
return
refuse_an_owner_github_cannot_have("scaffold-org-too-long", args.org,
"--org", ORG_REMEDIATION)
refuse_an_owner_github_cannot_spell("scaffold-bad-org", args.org,
"--org", ORG_SPELLING_REMEDIATION)
if args.id:
named, remediation = "--id", PASSED_TOPIC_REMEDIATION
else:
Expand Down
Loading
Loading