From 386e8fd299c1c7f3e05f580af7f72ecd30345f90 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 21:22:55 +0000 Subject: [PATCH 1/8] WIP: adopt-project.py: a source with no remote named `origin` is read from its only remote (#1276) Not gated yet (reboot checkpoint). The rows pass on this change (14) and 12 of them fail on main; mutants M1-M5 run (M2 equivalent), M6-M20 not yet run; the adoption gates, test_shape_doctor, the leg-path collisions module and the guards not yet run. Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 408 ++++++++++++++++++------- docs/cli.md | 13 +- tests/test_adopt_source_remote_name.py | 397 ++++++++++++++++++++++++ 3 files changed, 698 insertions(+), 120 deletions(-) create mode 100644 tests/test_adopt_source_remote_name.py diff --git a/adopt-project.py b/adopt-project.py index 006e6758..70359c75 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -2737,11 +2737,14 @@ class Source: def __init__(self, path: Path, repository: str | None, branch: str, commit: str, work_root: Path | None = None, - cloned: bool = False): + cloned: bool = False, remote: str = "origin"): self.path = path self.repository = repository self.branch = branch self.commit = commit + #: The remote its facts are read from: `origin`, or, where it has + #: none, its only remote (`_source_remote`, #1276). + self.remote = remote #: The work directory this run opened the source in: where a named #: repository is cloned to be read, and where `execute` makes the #: assembly's clone, so where a symlink the plan keeps in the root @@ -2876,14 +2879,16 @@ def clone(cls, repository: str, work_root: Path, @classmethod def _at(cls, path: Path, repository: str | None, work_root: Path, cloned: bool, instead: str | None = None) -> "Source": - branch = _default_branch(path, instead) + remote = _source_remote(path) + branch = _default_branch(path, instead, remote) # The branch's own commit, read by its full name and nothing else # (#797): `git rev-parse refs/heads/` looks further, and a tag # named `refs/heads/` answers it. As the bytes git keeps the name, # under any locale (#653). commit = _git_out_exact(["show-ref", "--verify", "--hash", f"{BRANCH_REFS}{branch}"], path).lower() - return cls(path, repository, branch, commit, work_root, cloned) + return cls(path, repository, branch, commit, work_root, cloned, + remote) def tree(self) -> list[tuple[str, str, str, int]]: """(path, mode, blob sha, size) for every file at the pinned commit. @@ -2997,10 +3002,95 @@ def commit_count(self) -> int: return int(git_out(["rev-list", "--count", self.commit], cwd=self.path)) +#: The remote a source's facts are read from where it has one of that name: +#: the repository it is a clone of, its default branch and its branches. +ORIGIN = "origin" + + +def _remote_names(path: Path) -> list[str]: + """The source's remotes as `git remote` lists them, each as `Source.tree` + reads a path (`_git_path`); none where git cannot list them.""" + try: + listed = git_out(["remote"], cwd=path, binary=True) + except Refusal: + return [] + return [_git_path(name) for name in listed.splitlines() if name] + + +def _remotes_of(path: Path) -> tuple[str, list[str]]: + """`(remote, passed_over)`: the remote the source's facts are read from, + `_source_remote`'s, and the remotes it has where it has no `origin` and + none is read in that one's place, which is never guessed among; else + `[]`. + + A SOURCE CLONED UNDER `clone.defaultRemoteName` HAS NO `origin` (#1276). + `git clone` names the remote after that setting, `upstream` say, and + `plan` read only a remote named `origin`: it refused `adopt-no-org` + with no `--org`, read the branch HEAD was on as the default branch, and + asked `--tracking-branch` of the source's own branches, where the same + source with `origin` read the organisation, `origin/HEAD` and + `origin`'s branches. So the source's ONLY remote is read in `origin`'s + place, as git itself reads it (`git ls-remote --get-url`, `git fetch` + with no remote named): the current branch's remote, else the only + remote, else `origin`, measured with git 2.43. + + A SOURCE THAT HAS AN `origin` IS READ AS IT WAS, whatever other remotes + it has: listed by `git remote`, or a file in `.git/branches` or + `.git/remotes`, which `git remote` does not list and `git remote + get-url origin` reads. A source with no remote is read as it was too. + + NOR IS ONE GUESSED AMONG SEVERAL. The current branch's remote is not + asked: it is whichever branch this working copy has checked out, not + the branch adopted, which is read from the remote, and `git switch` + changed the remote it named, the organisation and the default branch + with it, between `plan` and `check`. Nor is `clone.defaultRemoteName`: + git reads it in `git clone` alone, and it is this machine's setting + now, not the source's. A source with several remotes and no `origin` + is read as one with no remote, and `adopt-no-org` and + `plan-tracking-branch-missing` name the remotes passed over + (`_passed_over`). So is a remote whose name is not printable ASCII, + which every way through names in a command line, in any locale.""" + names = _remote_names(path) + if not names or ORIGIN in names or subprocess.run( + ["git", "remote", "get-url", ORIGIN], cwd=path, + capture_output=True, check=False).returncode == 0: + return ORIGIN, [] + if len(names) == 1 and names[0].isascii() and names[0].isprintable(): + return names[0], [] + return ORIGIN, names + + +def _source_remote(path: Path) -> str: + """The remote the source's facts are read from (#1276): `origin` where + it has one, else its only remote, else `origin` (`_remotes_of`).""" + return _remotes_of(path)[0] + + +def _passed_over(path: Path) -> str: + """How a refusal or a finding says the source's remotes were passed over + (`_remotes_of`), by name; empty where none was.""" + passed = _remotes_of(path)[1] + if len(passed) > 1: + return (f"it has no remote `origin`, and which of its {len(passed)} " + f"others it is a clone of is not guessed: {_listed(passed)}") + return (f"it has no remote `origin`, and its one remote, " + f"{_listed(passed)}, is not read in that one's place, as its " + "name is not printable ASCII" if passed else "") + + +def _the_sources(remote: str) -> str: + """The source's remote `remote`, as a refusal or a note names it: `the + source's `origin``, word for word as before #1276, or `the source's + remote `upstream``.""" + return ("the source's `origin`" if remote == ORIGIN + else f"the source's remote {_spelled(remote)}") + + def _remote_repository(path: Path) -> str | None: """The `org/repo` the source's `origin` names (`url_repository`, the one reading `shape-doctor.py` shares); None when it has no origin, or one - that names no such pair: a path names none (#521).""" + that names no such pair: a path names none (#521). With no `origin`, + its only remote's (`_source_remote`, #1276).""" url = _origin_url(path) return None if url is None else url_repository(url) @@ -3009,7 +3099,9 @@ def _origin_url(path: Path) -> str | None: """The source's `origin` as git hands it to a fetch, `git remote get-url origin`: its first url, with any `url..insteadOf` applied. None when it has none. THE ONE READ of it, so that what `plan` compares and - what it says it did not compare are one url (#521). + what it says it did not compare are one url (#521). With no `origin`, + the url of its only remote, read the same way (`_source_remote`, + #1276). NOR DOES A URL THAT IS NOT UTF-8 NAME ONE (#292). git keeps the URL as the bytes it was given: a clone of a directory named in a code page that @@ -3021,14 +3113,28 @@ def _origin_url(path: Path) -> str | None: source by its path. """ try: - return git_out(["remote", "get-url", "origin"], cwd=path) + return git_out(["remote", "get-url", _source_remote(path)], cwd=path) except (Refusal, UnicodeDecodeError): return None #: Where git keeps the default branch of the source's remote `origin`, as -#: `git clone` and `git remote set-head` record it. +#: `git clone` and `git remote set-head` record it. A source's own is +#: `_remote_head`'s; this one is also the assembly's clone's, which `execute` +#: makes from the source and git names `origin` (`_CloneGit`). ORIGIN_HEAD = "refs/remotes/origin/HEAD" + + +def _remote_head(remote: str) -> str: + """Where git keeps the default branch of the source's remote `remote` + (`_source_remote`): `ORIGIN_HEAD` for `origin`.""" + return f"refs/remotes/{remote}/HEAD" + + +def _remote_branches(remote: str) -> str: + """Where git keeps the branches of the source's remote `remote` as it + last fetched them (`_source_remote`): `ORIGIN_BRANCHES` for `origin`.""" + return f"refs/remotes/{remote}/" #: How `Source.open` refuses a source whose default branch is no branch of #: its own, and the way through it, in `plan`, `check` and `execute` alike #: (#797). @@ -3038,11 +3144,15 @@ def _origin_url(path: Path) -> str | None: "nobody named in its place") -def _default_branch(path: Path, instead: str | None = None) -> str: +def _default_branch(path: Path, instead: str | None = None, + remote: str | None = None) -> str: """The branch the adoption reads, in the order a reader would guess it: the default branch of the source's remote `origin`, as its `origin/HEAD` names it; else the branch its HEAD is on; else `main`. The name of a branch of the source's own, which `Source._at` reads by its full name. + `remote` is the source's remote, `origin` or, where it has none, its + only remote, whose `/HEAD` is read in `origin/HEAD`'s place + (`_source_remote`, #1276): read once, by `Source._at`, or here. BY FULL NAME, AND WHOLE (#797). `git symbolic-ref --short` gave a name with `origin/` to take off, and it was taken off HEAD's too, so a HEAD @@ -3082,12 +3192,14 @@ def _default_branch(path: Path, instead: str | None = None) -> str: `check` and `execute`. Otherwise it is refused by name, with the ways through (`_no_branch_of_its_own`), and never read at HEAD's branch or any other in its place.""" - named = _symbolic_ref(path, ORIGIN_HEAD) - origins = _branch_under(named, ORIGIN_BRANCHES) + remote = _source_remote(path) if remote is None else remote + named = _symbolic_ref(path, _remote_head(remote)) + origins = _branch_under(named, _remote_branches(remote)) if named is not None and origins is None: - print(f"NOTE the source's `origin/HEAD` names {_spelled(named)}, " - "which is no branch of its remote `origin`, so it is passed " - "over, and the branch its HEAD is on is read instead.") + print(f"NOTE the source's {_spelled(remote + '/HEAD')} names " + f"{_spelled(named)}, which is no branch of its remote " + f"{_spelled(remote)}, so it is passed over, and the branch its " + "HEAD is on is read instead.") head = None if origins else _branch_under(_symbolic_ref(path, "HEAD"), BRANCH_REFS) branch = origins or head or "main" @@ -3097,35 +3209,39 @@ def _default_branch(path: Path, instead: str | None = None) -> str: if instead in branches: return instead if origins: - raise _no_branch_of_its_own(path, branch, "origin", instead, branches) + raise _no_branch_of_its_own(path, branch, "origin", instead, branches, + remote) raise _no_branch_of_its_own(path, branch, "head" if head else None, - instead, branches) + instead, branches, remote) def _no_branch_of_its_own(path: Path, branch: str, where: str | None, - instead: str | None, branches: list) -> Refusal: + instead: str | None, branches: list, + remote: str = ORIGIN) -> Refusal: """`source-default-branch-not-local`: the source's default branch, `branch`, is no branch of its own, nor is `instead`. `where` is what named it: `origin` (its `origin/HEAD`), `head` (HEAD, on a branch with no commit yet), or None (no `origin/HEAD` and a detached HEAD, so the `main` this tool reads then). Each way through is said: the branch - made, or HEAD moved, and `--tracking-branch`.""" + made, or HEAD moved, and `--tracking-branch`. `remote` is the source's + remote (`_source_remote`), named in `origin`'s place (#1276).""" nor = "" if instead in (None, branch) else \ f", nor one named {_spelled(instead)}" held = f"{nor} (its branches: {_branches_named(branches)})" + head_ref, named_remote = _spelled(f"{remote}/HEAD"), _spelled(remote) if where == "origin": - detail = (f"the source's `origin/HEAD` names {_spelled(branch)}, the " - "default branch its remote `origin` had when it was last " - f"asked, and the source has no branch {_spelled(branch)} of " - f"its own{held}. A clone made with `git clone -b ` " - "has none, nor does one whose own was deleted, and the " - f"source {NOT_IN_ITS_PLACE}") - first = _from_the_remote(path, branch) + detail = (f"the source's {head_ref} names {_spelled(branch)}, the " + f"default branch its remote {named_remote} had when it was " + f"last asked, and the source has no branch " + f"{_spelled(branch)} of its own{held}. A clone made with " + "`git clone -b ` has none, nor does one whose own " + f"was deleted, and the source {NOT_IN_ITS_PLACE}") + first = _from_the_remote(path, branch, remote) else: named = (f"its HEAD is on {_spelled(branch)}, which has no commit " "yet, so it is no branch" if where == "head" else - "it has no `origin/HEAD` naming a branch of its remote " - "`origin`, its HEAD is detached, and it has no branch " + f"it has no {head_ref} naming a branch of its remote " + f"{named_remote}, its HEAD is detached, and it has no branch " "`main`, which this tool reads then") detail = f"the source names no branch of its own to read: {named}" \ f"{held}. The source {NOT_IN_ITS_PLACE}" @@ -3139,7 +3255,7 @@ def _no_branch_of_its_own(path: Path, branch: str, where: str | None, f"no branch {_spelled(branch)} of its own.") -def _from_the_remote(path: Path, branch: str) -> str: +def _from_the_remote(path: Path, branch: str, remote: str = ORIGIN) -> str: """The first way through for `origin/HEAD`'s `branch`: made the source's own from its remote's, where the source has fetched that; and where it has not, as after the remote renamed its default branch, the @@ -3147,20 +3263,23 @@ def _from_the_remote(path: Path, branch: str) -> str: printed with the name in it only where the name is printable, which a lone surrogate never is: a name holding U+0085 or U+2028 broke the line, and #425's `caf\\udce9` printed a command git refuses. Otherwise the name - is `_spelled` and the step said in words.""" - if branch not in _branch_names(path, ORIGIN_BRANCHES): - return ("Remediation: its remote `origin` has no branch " + is `_spelled` and the step said in words. `remote` is the source's + remote (`_source_remote`), a name `_remotes_of` reads only where it is + printable ASCII, so it is always in the command (#1276).""" + named, typed = _spelled(remote), shlex.quote(remote) + if branch not in _branch_names(path, _remote_branches(remote)): + return (f"Remediation: its remote {named} has no branch " f"{_spelled(branch)} either, as the source last fetched it: " - "ask `origin` for its default branch again, `git remote " - "set-head origin --auto`, run in the source.") + f"ask {named} for its default branch again, `git remote " + f"set-head {typed} --auto`, run in the source.") made = (f"`git branch {shlex.quote(branch)} " - f"{shlex.quote('origin/' + branch)}`" if branch.isprintable() - else f"a branch named {_spelled(branch)} made from `origin`'s " + f"{shlex.quote(remote + '/' + branch)}`" if branch.isprintable() + else f"a branch named {_spelled(branch)} made from {named}'s " "branch of that name") return ("Remediation: make it a branch of the source's own, from its " - f"remote's: {made}, run in the source; where `origin` may have " - "changed its default branch since, run `git remote set-head " - "origin --auto` first.") + f"remote's: {made}, run in the source; where {named} may have " + f"changed its default branch since, run `git remote set-head " + f"{typed} --auto` first.") def _symbolic_ref(path: Path, ref: str) -> str | None: @@ -4975,14 +5094,22 @@ def _misread_findings(plan: Plan, unwritten=()) -> list[str]: #: what it prints once after it (#479). `execute` refuses under the same code #: a tracking branch the remote the split goes to does not have. TRACKING_BRANCH_MISSING = "plan-tracking-branch-missing" -TRACKING_BRANCH_REMEDIATION = ( - "Remediation: set `tracking_branch:` in the plan to a branch the source " - "has: its default branch, as `source.default_branch:` records it, unless " - "the project is to track another. Or re-run `plan` without " - "`--tracking-branch`, which records that default branch. A branch that " - "is on GitHub and not yet in the source's clone is fetched into it first " - "(`git fetch origin`), and then `check` is run again.") -#: Where `_branches_of` reads the branches of a source that has fetched any. +def _tracking_branch_remediation(remote: str = "origin") -> str: + """What `check` prints once after `plan-tracking-branch-missing`, the + fetch in it from the source's remote (`_source_remote`, #1276).""" + return ( + "Remediation: set `tracking_branch:` in the plan to a branch the " + "source has: its default branch, as `source.default_branch:` records " + "it, unless the project is to track another. Or re-run `plan` without " + "`--tracking-branch`, which records that default branch. A branch " + "that is on GitHub and not yet in the source's clone is fetched into " + f"it first (`git fetch {shlex.quote(remote)}`), and then `check` is " + "run again.") + + +TRACKING_BRANCH_REMEDIATION = _tracking_branch_remediation() +#: Where `_branches_of` reads the branches of a source that has fetched any +#: from its remote `origin`; from another remote, `_remote_branches`'. ORIGIN_BRANCHES = "refs/remotes/origin/" #: How many branches a finding names; past it, the rest are counted. BRANCHES_NAMED = 8 @@ -4993,15 +5120,18 @@ def _branches_of(source: Source) -> tuple[str, list[str]]: request goes to, as the source knows them, and how a finding names where they were read: its remote `origin`'s when it has fetched any, and otherwise its own. Each name is read as `Source.tree` reads a path, - so a byte that is not UTF-8 is kept rather than refused.""" + so a byte that is not UTF-8 is kept rather than refused. With no + `origin`, its only remote's (`Source.remote`, #1276); and where it has + several and none is read in `origin`'s place, its own, as for a source + with no remote.""" + remote = _remote_branches(source.remote) raw = git_out(["for-each-ref", "--format=%(refname)", BRANCH_REFS, - ORIGIN_BRANCHES], cwd=source.path, binary=True) + remote], cwd=source.path, binary=True) refs = [_git_path(line) for line in raw.splitlines()] - fetched = sorted(ref[len(ORIGIN_BRANCHES):] for ref in refs - if ref.startswith(ORIGIN_BRANCHES) - and ref != f"{ORIGIN_BRANCHES}HEAD") + fetched = sorted(ref[len(remote):] for ref in refs + if ref.startswith(remote) and ref != f"{remote}HEAD") if fetched: - return "the source's remote `origin`", fetched + return f"the source's remote {_spelled(source.remote)}", fetched return "the source", sorted(ref[len(BRANCH_REFS):] for ref in refs if ref.startswith(BRANCH_REFS)) @@ -5020,13 +5150,17 @@ def _tracking_branch_problem(source: Source, what: str, """Why `tracking`, named as `what`, cannot be this adoption's tracking branch, as a sentence; None when it is a branch of the source as `_branches_of` reads them. ONE TEST, asked by `plan` as a NOTE and by - `check` and `execute` as `plan-tracking-branch-missing`.""" + `check` and `execute` as `plan-tracking-branch-missing`. Where the + source's own branches were read because none of its remotes is read in + `origin`'s place, it names them (`_passed_over`, #1276).""" whose, branches = _branches_of(source) if tracking in branches: return None + passed = _passed_over(source.path) if whose == "the source" else "" return (f"{what} is {_spelled(tracking)}, which is no branch of {whose} " f"(its branches: {_branches_named(branches)}; the source's " - f"default branch is {_spelled(source.branch)}). `execute` pushes " + f"default branch is {_spelled(source.branch)}" + f"{'; ' + passed if passed else ''}). `execute` pushes " "both legs to it, writes it into the root's `project.yaml` as the " "branch the project tracks, and opens the split's pull request " "against it once both legs and the split are pushed, where " @@ -5045,7 +5179,7 @@ def _tracking_branch_findings(plan: Plan, source: Source if problem is None: return [], [] return ([f"FINDING {TRACKING_BRANCH_MISSING}: {problem}"], - [TRACKING_BRANCH_REMEDIATION]) + [_tracking_branch_remediation(source.remote)]) def _tracking_branch_notes(args, source: Source) -> list[str]: @@ -5067,26 +5201,42 @@ def _tracking_branch_notes(args, source: Source) -> list[str]: REMOTE_UNREADABLE = "adopt-remote-unreadable" #: How `_refuse_a_tracking_branch_the_remote_lacks` ends, and the sentence it #: adds under `--local-remote-dir`, where the source is the remote. -REMOTE_LACKS_REMEDIATION = ( - "Remediation: no leg repository was created and nothing was pushed. " - "`check` reads the branches the source's clone remembers, and a clone " - "keeps one its remote has since deleted or renamed: an ordinary `git " - "fetch` does not prune it, and a clone made before GitHub renamed its " - "default branch still names the old one. In the source, run `git fetch " - "--prune origin` and `git remote set-head origin --auto`, and where the " - "default branch was renamed, rename the clone's own to match, as " - "GitHub's notice of the rename says (`git branch -m `). Then " - "re-run `plan` (without `--tracking-branch` it records the default " - "branch the remote names now) and `check`, and run `execute` again.") -LOCAL_REMOTE_LACKS = ( - " Under `--local-remote-dir` the split is pushed back into the source " - "itself, so the tracking branch must be one of the source's own " - "branches, and one only its remote `origin` has is not: `git branch " - " origin/` makes it one.") +def _remote_lacks_remediation(remote: str = "origin") -> str: + """How `_refuse_a_tracking_branch_the_remote_lacks` ends, its commands + run in the source with its remote (`_source_remote`, #1276).""" + typed = shlex.quote(remote) + return ( + "Remediation: no leg repository was created and nothing was pushed. " + "`check` reads the branches the source's clone remembers, and a " + "clone keeps one its remote has since deleted or renamed: an " + "ordinary `git fetch` does not prune it, and a clone made before " + "GitHub renamed its default branch still names the old one. In the " + f"source, run `git fetch --prune {typed}` and `git remote set-head " + f"{typed} --auto`, and where the default branch was renamed, rename " + "the clone's own to match, as GitHub's notice of the rename says " + "(`git branch -m `). Then re-run `plan` (without " + "`--tracking-branch` it records the default branch the remote names " + "now) and `check`, and run `execute` again.") + + +def _local_remote_lacks(remote: str = "origin") -> str: + """The sentence it adds under `--local-remote-dir`, where the source is + the remote, naming the source's remote (`_source_remote`, #1276).""" + return ( + " Under `--local-remote-dir` the split is pushed back into the source " + "itself, so the tracking branch must be one of the source's own " + f"branches, and one only its remote {_spelled(remote)} has is not: " + f"`git branch {shlex.quote(remote)}/` makes it one.") + + +REMOTE_LACKS_REMEDIATION = _remote_lacks_remediation() +LOCAL_REMOTE_LACKS = _local_remote_lacks() def _refuse_a_tracking_branch_the_remote_lacks(urls: dict, tracking: str, - local: bool) -> None: + local: bool, + source_remote: str = "origin" + ) -> None: """Refuse a tracking branch the repository the split goes to does not have, BEFORE a leg repository exists (#479; #493's review, F1). @@ -5107,6 +5257,8 @@ def _refuse_a_tracking_branch_the_remote_lacks(urls: dict, tracking: str, destination there, so its own branches are the ones asked. A remote that cannot be read is refused as `adopt-remote-unreadable`, at the same point: the split push needs it as much, and that came after both legs. + `source_remote` is the source's remote (`Source.remote`), which the + remediation's commands run in the source name (#1276). """ ref = f"refs/heads/{tracking}" remote = _spelled(urls["assembly"]) @@ -5138,7 +5290,8 @@ def _refuse_a_tracking_branch_the_remote_lacks(urls: dict, tracking: str, else "`execute` opens the split's pull request against it once " "both legs and the split are pushed, where GitHub refuses a base " "that is not a branch (`Base ref must be a branch`)"), - REMOTE_LACKS_REMEDIATION + (LOCAL_REMOTE_LACKS if local else "")) + _remote_lacks_remediation(source_remote) + + (_local_remote_lacks(source_remote) if local else "")) #: The name the naming policy takes for an assembly root, in the words @@ -5592,8 +5745,9 @@ def _checked_flag_inputs(args, naming: NamingPolicy) -> None: def _org_read_from(source: Source) -> str: """Where `plan` read the organisation it records when `--org` is not given (#427): the `org/repo` it cloned as `--source`, or the source's - `origin` (`Source.repository`).""" - return "`--source`" if source.cloned else "the source's `origin`" + `origin` (`Source.repository`), or with none its only remote + (`Source.remote`, #1276).""" + return "`--source`" if source.cloned else _the_sources(source.remote) def _planned_org(org: str | None, source: Source) -> str: @@ -5628,12 +5782,21 @@ def _planned_org(org: str | None, source: Source) -> str: if org is not None: return checked_value("--org", org) read = (source.repository or "/").split("/")[0] + passed = "" if read or source.cloned else _passed_over(source.path) + if passed: + raise Refusal( + "adopt-no-org", "the source names no organisation this tool can " + f"read: {passed}", + "Remediation: pass --org , or, in the source, name the " + "remote it is a clone of `origin` (`git remote rename " + "origin`), and its organisation, default branch and branches " + "are read from that remote.") if not read: raise Refusal( - "adopt-no-org", "the source's `origin` names no organisation " - "this tool can read: it has none, its URL is not UTF-8 or ends " - "in no `org/repo`, or it is a path, which names no repository on " - "GitHub", + "adopt-no-org", f"{_the_sources(source.remote)} names no " + "organisation this tool can read: it has none, its URL is not " + "UTF-8 or ends in no `org/repo`, or it is a path, which names no " + "repository on GitHub", "Remediation: pass --org .") where = _org_read_from(source) try: @@ -6144,7 +6307,7 @@ def _source_named(source: Source) -> str: return str(source.repository) where = _shown(str(source.path)) if source.repository: - where += f" (origin: {_shown(source.repository)})" + where += f" ({source.remote}: {_shown(source.repository)})" return where @@ -9975,26 +10138,34 @@ def _entry_findings(plan: Plan, source: Source, #: What `check` says once after its findings when one of them is about the #: split branch. -SPLIT_BRANCH_REMEDIATION = ( - "Remediation: `execute` pushes its one split commit to the plan's " - "`adopt_branch:` in the repository the split goes to: the source itself " - "under `--local-remote-dir`, and otherwise the assembly repository on " - "GitHub. It moves a branch already there only as a fast-forward, and it " - "extracts each leg from a clone of the source with that branch checked " - "out, so a branch git cannot hold beside it, there or in the source, " - "stops either run. A run leaves its split branch where it pushed it, " - "verified or not, so an earlier run's split is what " - "`adopt-branch-exists` names most often: if that run verified and its " - "pull request is the adoption you want, review that instead. Otherwise " - "delete each branch a finding names, where it names it, once neither it " - "nor a pull request from it is wanted, or set `adopt_branch:` to a name " - "git can create there, and run `check` again. A branch named as the " - "source's `origin` last fetched it leaves `check`'s findings once `git " - "fetch --prune origin` in the source finds it gone there. A branch the " - "source itself holds is also in the history `check` reads for the " - "source's submodules, and `git filter-repo` exports it once the plan " - "names another branch, so a finding that names one of its commits " - "stands until that branch is deleted.") +def _split_branch_remediation(remote: str = "origin") -> str: + """What `check` says once after its findings when one of them is about + the split branch, naming the source's remote (`_source_remote`, + #1276).""" + return ( + "Remediation: `execute` pushes its one split commit to the plan's " + "`adopt_branch:` in the repository the split goes to: the source " + "itself under `--local-remote-dir`, and otherwise the assembly " + "repository on GitHub. It moves a branch already there only as a " + "fast-forward, and it extracts each leg from a clone of the source " + "with that branch checked out, so a branch git cannot hold beside it, " + "there or in the source, stops either run. A run leaves its split " + "branch where it pushed it, verified or not, so an earlier run's " + "split is what `adopt-branch-exists` names most often: if that run " + "verified and its pull request is the adoption you want, review that " + "instead. Otherwise delete each branch a finding names, where it " + "names it, once neither it nor a pull request from it is wanted, or " + "set `adopt_branch:` to a name git can create there, and run `check` " + f"again. A branch named as {_the_sources(remote)} last fetched it " + "leaves `check`'s findings once `git fetch --prune " + f"{shlex.quote(remote)}` in the source finds it gone there. A branch " + "the source itself holds is also in the history `check` reads for " + "the source's submodules, and `git filter-repo` exports it once the " + "plan names another branch, so a finding that names one of its " + "commits stands until that branch is deleted.") + + +SPLIT_BRANCH_REMEDIATION = _split_branch_remediation() #: `execute`'s remediation for `plan-split-branch-taken`. SPLIT_BRANCH_TAKEN_REMEDIATION = ( @@ -10232,9 +10403,11 @@ def _places_check_asks(plan: Plan, source: Source, extracting: bool) -> list: source.path, extracting) assembly = _assembly_repository(plan) if assembly and (source.repository or "").lower() == assembly.lower(): - places.append((f"`{assembly}` on GitHub, as the source's `origin` " - "last fetched it,", GITHUB_RUN, False, - _branches_in(source.path, "refs/remotes/origin/"))) + places.append((f"`{assembly}` on GitHub, as " + f"{_the_sources(source.remote)} last fetched it,", + GITHUB_RUN, False, + _branches_in(source.path, + _remote_branches(source.remote)))) return places @@ -10253,7 +10426,7 @@ def _check_split_branch(plan: Plan, source: Source extracting = _extracts_a_leg(assigned_paths(plan.entries)) found = _split_branch_findings( branch, _places_check_asks(plan, source, extracting), source) - return found, [SPLIT_BRANCH_REMEDIATION] if found else [] + return found, [_split_branch_remediation(source.remote)] if found else [] def _a_leg_may_be_on_disk(urls: dict) -> bool: @@ -16280,7 +16453,8 @@ def cmd_execute(args) -> int: _refuse_an_occupied_work_dir(work_root, names, seeded) # The remote's own answer about the tracking branch, which `check` reads # only from what the source's clone remembers (#493's review, F1). - _refuse_a_tracking_branch_the_remote_lacks(urls, tracking, local) + _refuse_a_tracking_branch_the_remote_lacks(urls, tracking, local, + source.remote) _refuse_a_split_branch_left_behind(source, urls, branch, local) # Then a branch in git's way of the split branch, which no run of this # tool leaves behind (#320). @@ -17707,9 +17881,9 @@ def _path_origin_notes(args, source: Source) -> list[str]: path = _origin_path(source) if path is None: return [] - return [f"\nNOTE the source's `origin` is the path {_spelled(path)}, " - "which names no repository on GitHub, so `--org` and `--project` " - "were not compared with it.", + return [f"\nNOTE {_the_sources(source.remote)} is the path " + f"{_spelled(path)}, which names no repository on GitHub, so " + "`--org` and `--project` were not compared with it.", " `execute` without `--local-remote-dir` pushes the split to " f"{args.org}/{args.project}, opens its pull request there and " "sets its topic on it, so that must be the source's own " @@ -17874,12 +18048,16 @@ def _why_the_source_names_none(source: Source) -> str: """Why the live source names no repository, as `_not_the_source` says it beside the plan's record (#716): it has no `origin`, or its `origin` names none (a path, a url that ends in no `org/repo`, or one that is not - UTF-8: `_remote_repository`).""" + UTF-8: `_remote_repository`). With no `origin`, its only remote is named + in that one's place (`Source.remote`, #1276), and the remotes passed + over where none is (`_passed_over`).""" + remote = source.remote try: - git_out(["remote", "get-url", "origin"], cwd=source.path, binary=True) + git_out(["remote", "get-url", remote], cwd=source.path, binary=True) except Refusal: - return "it has no `origin`" - return "its `origin` names no repository" + return _passed_over(source.path) or "it has no `origin`" + named = "`origin`" if remote == ORIGIN else f"remote {_spelled(remote)}" + return f"its {named} names no repository" def _not_the_source(plan: Plan, source: Source, @@ -18562,10 +18740,12 @@ def main(argv: list[str] | None = None) -> int: help="the organisation the source repository is in, " "which the plan records as `org:` (default: the " "organisation of an `org/repo` --source, or the " - "one a local source's `origin` url names). A " + "one a local source's `origin` url names, or, " + "where it has no `origin`, its only remote's). A " "source whose `origin` is a path, or that has " - "none, names no organisation: `plan` refuses " - "it as `adopt-no-org` unless --org is given. " + "none and not one other remote, names no " + "organisation: `plan` refuses it as " + "`adopt-no-org` unless --org is given. " f"`execute` refuses one over {GITHUB_OWNER_MAX} " "characters without --local-remote-dir, GitHub's " "limit on an organisation's name.") diff --git a/docs/cli.md b/docs/cli.md index c8cd4d66..3825c77c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -494,12 +494,13 @@ options: --org ORG the organisation the source repository is in, which the plan records as `org:` (default: the organisation of an `org/repo` --source, or the one a local source's - `origin` url names). A source whose `origin` is a - path, or that has none, names no organisation: `plan` - refuses it as `adopt-no-org` unless --org is given. - `execute` refuses one over 39 characters without - --local-remote-dir, GitHub's limit on an - organisation's name. + `origin` url names, or, where it has no `origin`, its + only remote's). A source whose `origin` is a path, or + that has none and not one other remote, names no + organisation: `plan` refuses it as `adopt-no-org` + unless --org is given. `execute` refuses one over 39 + characters without --local-remote-dir, GitHub's limit + on an organisation's name. --id ID lowercase project id (default: --project lowercased). `execute` refuses one over 39 characters without --local-remote-dir: GitHub keeps a topic to 50, and diff --git a/tests/test_adopt_source_remote_name.py b/tests/test_adopt_source_remote_name.py new file mode 100644 index 00000000..3ab17762 --- /dev/null +++ b/tests/test_adopt_source_remote_name.py @@ -0,0 +1,397 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A source with no remote named `origin` is read from its only remote +(#1276). + +`git clone` names the remote after `clone.defaultRemoteName`, so a person +with `upstream` there has a clone whose one remote is `upstream`. +`adopt-project.py` read only a remote named `origin`: the organisation +(`_origin_url`), the default branch (`refs/remotes/origin/HEAD`) and the +branches a tracking branch is asked of (`refs/remotes/origin/*`). So `plan` +with no `--org` refused `adopt-no-org`, read the branch HEAD was on as the +default branch, and asked `--tracking-branch` of the source's own branches, +where the same source with its remote named `origin` read the organisation, +`origin/HEAD` and `origin`'s branches. + +Now, where a source has no `origin`, its only remote is read in that one's +place, as git itself reads it when no remote is named (`git ls-remote +--get-url`: the current branch's remote, else the only remote, else +`origin`). A source with an `origin` reads exactly as before, whatever other +remotes it has, and so does one with no remote. One with several remotes and +none named `origin` is not guessed among: `adopt-no-org` and +`plan-tracking-branch-missing` name the remotes passed over. + +Not #1261: that names the remote of each clone `execute` makes `origin`. +This is the person's own repository, whose remote the tool does not name. +Every source here is cloned with `-c clone.defaultRemoteName=upstream` and +the tool runs under the session's global config, so the clones `execute` +makes name their remote `origin` either way. Nothing fetches from GitHub: a +remote's url is set to GitHub's after its clone or fetch from a bare +repository here. The only `gh` run is `check`'s, answered by the session's +guard (`tests/gh_guard.py`) as a `gh` with no login. +""" + +from __future__ import annotations + +import shutil +import sys + +import pytest + +from conftest import ADOPT, FILE_PROTOCOL, REPO, git, make_source_repo, \ + run_script + +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import load_yaml # noqa: E402 + +#: A source small enough that `plan` asks no question. +TREE = {"README.md": "# Thing\n", "src/app.py": "print(1)\n", + "docs/spec.md": "# Spec\n"} +ORG, PROJECT = "testorg", "Thing" +GITHUB_URL = f"https://github.com/{ORG}/{PROJECT}.git" +FORK_URL = f"https://github.com/someone/{PROJECT}.git" +UPSTREAM = "upstream" +MISSING = "plan-tracking-branch-missing" +needs_filter_repo = pytest.mark.skipif( + shutil.which("git-filter-repo") is None, + reason="git filter-repo is not installed: `pip install git-filter-repo`") + + +def bare_of(tmp_path, owner: str, extra: str): + """A bare repository `/Thing.git` of a one-commit `main`, with a + branch `extra` beside it.""" + seed = make_source_repo(tmp_path / f"seed-{owner}", tree=TREE, edits=()) + git("branch", extra, cwd=seed) + bare = tmp_path / owner / f"{PROJECT}.git" + git(*FILE_PROTOCOL, "clone", "-q", "--bare", str(seed), str(bare), + cwd=tmp_path) + return bare + + +def cloned_as(tmp_path, remote: str, *flags: str): + """The source `Thing`, cloned from `testorg/Thing.git` (`main`, and + `develop`) under `clone.defaultRemoteName=`, with that remote's + url then GitHub's, and HEAD on a branch `feature` of its own, so that + the default branch is not HEAD's.""" + bare = bare_of(tmp_path, ORG, "develop") + source = tmp_path / PROJECT + git(*FILE_PROTOCOL, "-c", f"clone.defaultRemoteName={remote}", "clone", + "-q", *flags, str(bare), str(source), cwd=tmp_path) + git("remote", "set-url", remote, GITHUB_URL, cwd=source) + return source + + +def with_fork(tmp_path, source, track: bool = False) -> None: + """A second remote `fork` added to `source` and fetched (`main`, and + `forkonly`), its url then GitHub's; with `track`, the source's HEAD on a + branch that tracks it.""" + bare = bare_of(tmp_path, "someone", "forkonly") + git("remote", "add", "fork", str(bare), cwd=source) + git(*FILE_PROTOCOL, "fetch", "-q", "fork", cwd=source) + git("remote", "set-url", "fork", FORK_URL, cwd=source) + if track: + git("switch", "-q", "-c", "onfork", "--track", "fork/forkonly", + cwd=source) + + +def planned(tmp_path, source, *flags: str, name: str = "plan"): + """`plan` of `source` as `Thing`, with the flags given: the run and the + plan's path.""" + out = tmp_path / f"{name}.yaml" + result = run_script(ADOPT, "plan", "--source", str(source), "--project", + PROJECT, "--elected-by", "Test Human", + "--elected-on", "2026-11-08", "--out", str(out), + *flags) + return result, out + + +def check(plan): + return run_script(ADOPT, "check", "--plan", str(plan)) + + +# --- the issue's rows: a source cloned under `upstream` ---------------------- + +def test_plan_reads_the_organisation_from_the_only_remote(tmp_path): + """The issue's first row. On main: `REFUSED adopt-no-org: the source's + `origin` names no organisation`, exit 2, where the same source with + `origin` planned `testorg`. Now the organisation and the repository are + read from `upstream`, and the `source` line names the remote read.""" + source = cloned_as(tmp_path, UPSTREAM) + git("switch", "-q", "-c", "feature", cwd=source) + + result, plan = planned(tmp_path, source) + + assert result.returncode == 0, result.stdout + result.stderr + assert "adopt-no-org" not in result.stderr, result.stderr + written = load_yaml(plan) + assert written["org"] == ORG, written + assert written["source"]["repository"] == f"{ORG}/{PROJECT}" + assert f"{source} (upstream: {ORG}/{PROJECT}) @ " in result.stdout, \ + result.stdout + + +def test_the_default_branch_is_the_only_remotes_head(tmp_path): + """The issue's second row, the default branch. The source's HEAD is on + `feature`, and `upstream/HEAD` names `main`. On main `plan` read HEAD's + branch, `feature`, as the default and the tracking branch, as for a + source with no remote; with `origin` it read `main`. Now `main`.""" + source = cloned_as(tmp_path, UPSTREAM) + git("switch", "-q", "-c", "feature", cwd=source) + + result, plan = planned(tmp_path, source, "--org", ORG) + + assert result.returncode == 0, result.stdout + result.stderr + written = load_yaml(plan) + assert written["source"]["default_branch"] == "main", written["source"] + assert written["tracking_branch"] == "main", written + + +def test_a_tracking_branch_is_asked_of_the_only_remotes_branches(tmp_path): + """The issue's second row, `--tracking-branch`. `develop` is a branch of + `upstream` the source has not made its own. On main `plan` noted it as + "no branch of the source (its branches: `feature`, `main`...)" and + `check` found it, exit 1, where the same source with `origin` checked + ok. Now it checks ok; and `wip`, a branch of the source's own alone, is + found as no branch of `upstream`, with the fetch from `upstream`.""" + source = cloned_as(tmp_path, UPSTREAM) + git("switch", "-q", "-c", "feature", cwd=source) + git("branch", "wip", cwd=source) + + result, plan = planned(tmp_path, source, "--org", ORG, + "--tracking-branch", "develop") + assert result.returncode == 0, result.stdout + result.stderr + assert MISSING not in result.stdout + result.stderr, result.stdout + checked = check(plan) + assert checked.returncode == 0, checked.stdout + checked.stderr + + result, plan = planned(tmp_path, source, "--org", ORG, + "--tracking-branch", "wip", name="wip") + found = ("is `wip`, which is no branch of the source's remote `upstream` " + "(its branches: `develop`, `main`; the source's default branch " + "is `main`)") + assert f"NOTE the tracking branch {found}" in result.stdout, result.stdout + checked = check(plan) + assert checked.returncode == 1, checked.stdout + checked.stderr + assert f"FINDING {MISSING}: `tracking_branch` {found}" in checked.stderr + assert "(`git fetch upstream`), and then `check` is run again." in \ + checked.stderr, checked.stderr + + +@needs_filter_repo +def test_execute_names_the_only_remote_in_its_way_through(tmp_path): + """`execute --local-remote-dir` of the `develop` plan: `check`'s reading + passes it, and the source itself, where the split goes, has no + `develop`, so it is refused before any leg exists, as for the same + source with `origin`, and each command it gives is run with `upstream`. + On main it was refused as `check`'s finding, `develop` being no branch + it read.""" + source = cloned_as(tmp_path, UPSTREAM) + _, plan = planned(tmp_path, source, "--org", ORG, + "--tracking-branch", "develop") + + result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--local-remote-dir", str(tmp_path / "remotes"), + "--work-dir", str(tmp_path / "work")) + + assert result.returncode == 2, result.stdout + result.stderr + said = " ".join(result.stderr.split()) + assert f"REFUSED {MISSING}: `tracking_branch` is `develop`, which " in said + assert "as `check` prints them" not in said, said + assert ("run `git fetch --prune upstream` and `git remote set-head " + "upstream --auto`") in said, said + assert ("one only its remote `upstream` has is not: `git branch " + "upstream/` makes it one.") in said, said + assert "origin" not in said, said + assert not (tmp_path / "remotes").exists() + + +def test_a_clone_of_another_branch_names_the_only_remote(tmp_path): + """The default branch's refusal (#797) of a `git clone -b develop` made + under `upstream`: `upstream/HEAD` names `main`, which the source has no + branch of. It is refused by name, `upstream/HEAD` and its way through + said with `upstream`. On main HEAD's `develop` was read as the default + branch, and planned.""" + source = cloned_as(tmp_path, UPSTREAM, "-b", "develop") + + result, plan = planned(tmp_path, source, "--org", ORG) + + assert result.returncode == 2, result.stdout + result.stderr + assert ("REFUSED source-default-branch-not-local: the source's " + "`upstream/HEAD` names `main`, the default branch its remote " + "`upstream` had when it was last asked, and the source has no " + "branch `main` of its own (its branches: `develop`)") in \ + result.stderr, result.stderr + assert "`git branch main upstream/main`, run in the source; where " \ + "`upstream` may have changed its default branch since, run `git " \ + "remote set-head upstream --auto` first." in result.stderr + assert not plan.exists() + + +def test_check_asks_the_split_branch_of_the_only_remotes_branches(tmp_path): + """`check` asks for the split branch in the assembly repository, as the + source's remote last fetched it, where that is the source's own + repository. `upstream` has a branch `adopt`, which git cannot hold + beside `adopt/three-repo-shape`. On main `upstream`'s branches were not + read, and `check` said `plan ok`; it is found now, named as `upstream` + last fetched it, with the fetch that prunes it from `upstream`.""" + source = cloned_as(tmp_path, UPSTREAM) + git("update-ref", "refs/remotes/upstream/adopt", "HEAD", cwd=source) + _, plan = planned(tmp_path, source, "--org", ORG) + + checked = check(plan) + + assert checked.returncode == 1, checked.stdout + checked.stderr + said = " ".join(checked.stderr.split()) + assert (f"`{ORG}/{PROJECT}` on GitHub, as the source's remote `upstream` " + "last fetched it,") in said, said + assert ("A branch named as the source's remote `upstream` last fetched it " + "leaves `check`'s findings once `git fetch --prune upstream` in " + "the source finds it gone there.") in said, said + + +@pytest.mark.parametrize("url, said", [ + ("/srv/clones/Thing.git", None), + ("https://github.com/-evil/Thing.git", "-evil"), +], ids=["path", "refused-org"]) +def test_the_only_remote_is_named_where_origin_was(tmp_path, url, said): + """Every word that named `origin` names the remote read in its place: + a path names no organisation (`adopt-no-org`), and an organisation + `checked_value` refuses is named by where it came from (#427).""" + source = cloned_as(tmp_path, UPSTREAM) + git("remote", "set-url", UPSTREAM, url, cwd=source) + + result, _ = planned(tmp_path, source) + + assert result.returncode == 2, result.stdout + result.stderr + if said is None: + assert ("REFUSED adopt-no-org: the source's remote `upstream` names " + "no organisation this tool can read: it has none,") in \ + result.stderr, result.stderr + result, _ = planned(tmp_path, source, "--org", ORG, name="org") + assert result.returncode == 0, result.stdout + result.stderr + assert ("NOTE the source's remote `upstream` is the path " + f"`{url}`, which names no repository on GitHub") in \ + result.stdout, result.stdout + else: + assert ("--org (not given, so the organisation of the source's " + f"remote `upstream`) is '{said}'") in result.stderr, \ + result.stderr + + +@pytest.mark.parametrize("change, why", [ + (("remote", "set-url", UPSTREAM, "/srv/clones/Thing.git"), + "its remote `upstream` names no repository"), + (("remote", "add", "fork", FORK_URL), + "it has no remote `origin`, and which of its 2 others it is a clone of " + "is not guessed: `fork`, `upstream`"), +], ids=["path", "second-remote"]) +def test_a_plan_whose_source_names_none_now_says_why(tmp_path, change, why): + """A plan made while `upstream` named the source's repository, edited + to another organisation, of a source that names none now: `check` holds + the plan to its own record and says why the source names none, by the + remote it read or the remotes passed over (#716).""" + source = cloned_as(tmp_path, UPSTREAM) + result, plan = planned(tmp_path, source) + assert result.returncode == 0, result.stdout + result.stderr + text = plan.read_text(encoding="utf-8") + assert f"\norg: {ORG}\n" in text, text + with plan.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(text.replace(f"\norg: {ORG}\n", "\norg: other\n", 1)) + git(*change, cwd=source) + + checked = check(plan) + + assert checked.returncode == 1, checked.stdout + checked.stderr + assert (f"and the plan records the source as {ORG}/{PROJECT} ({why}): " + "an in-place adoption keeps the source's own name") in \ + checked.stderr, checked.stderr + + +# --- (c): a source with `origin` reads as it did ---------------------------- + +def test_a_source_with_origin_and_another_remote_reads_origin(tmp_path): + """The issue's third row. A source with `origin` and a second remote + `fork`, fetched, its HEAD on a branch that tracks `fork` -- the remote + `git ls-remote --get-url` names -- reads every fact from `origin`, as on + main: the organisation, `origin/HEAD`'s `main`, and `origin`'s + branches, so `forkonly` is no branch of it.""" + source = cloned_as(tmp_path, "origin") + with_fork(tmp_path, source, track=True) + + result, plan = planned(tmp_path, source, "--tracking-branch", "forkonly") + + assert result.returncode == 0, result.stdout + result.stderr + written = load_yaml(plan) + assert written["org"] == ORG, written + assert written["source"]["default_branch"] == "main", written["source"] + assert f"{source} (origin: {ORG}/{PROJECT}) @ " in result.stdout + assert ("NOTE the tracking branch is `forkonly`, which is no branch of " + "the source's remote `origin` (its branches: `develop`, `main`; " + "the source's default branch is `main`).") in result.stdout, \ + result.stdout + + +def test_a_legacy_origin_is_read_beside_another_remote(tmp_path): + """An `origin` kept as a file in `.git/branches`, which `git remote` + does not list and `git remote get-url origin` reads, beside one remote + `fork` in the config: `origin` is read, as on main, and `fork` is + not.""" + source = make_source_repo(tmp_path / PROJECT, tree=TREE, edits=()) + (source / ".git" / "branches").mkdir(exist_ok=True) + (source / ".git" / "branches" / "origin").write_text( + f"{GITHUB_URL}\n", encoding="utf-8") + git("remote", "add", "fork", FORK_URL, cwd=source) + + result, plan = planned(tmp_path, source) + + assert result.returncode == 0, result.stdout + result.stderr + assert load_yaml(plan)["org"] == ORG + assert f"{source} (origin: {ORG}/{PROJECT}) @ " in result.stdout + + +# --- (d): several remotes and no `origin` are not guessed among -------------- + +def test_several_remotes_and_no_origin_are_named_and_not_guessed(tmp_path): + """Two remotes, `upstream` and `fork`, and no `origin`. Neither is read: + `plan` with no `--org` refuses `adopt-no-org` naming both, and with + `--org` the source is read as one with no remote, HEAD's `feature` its + default branch, and a branch only a remote has is found naming both. + On main `adopt-no-org` said only "the source's `origin`... it has + none".""" + source = cloned_as(tmp_path, UPSTREAM) + with_fork(tmp_path, source) + git("switch", "-q", "-c", "feature", cwd=source) + passed = ("it has no remote `origin`, and which of its 2 others it is a " + "clone of is not guessed: `fork`, `upstream`") + + result, _ = planned(tmp_path, source) + assert result.returncode == 2, result.stdout + result.stderr + assert ("REFUSED adopt-no-org: the source names no organisation this " + f"tool can read: {passed}\n") in result.stderr, result.stderr + assert "(`git remote rename origin`)" in result.stderr + + result, plan = planned(tmp_path, source, "--org", ORG, + "--tracking-branch", "develop", name="org") + assert result.returncode == 0, result.stdout + result.stderr + assert load_yaml(plan)["source"]["default_branch"] == "feature" + found = ("is `develop`, which is no branch of the source (its branches: " + "`feature`, `main`; the source's default branch is `feature`; " + f"{passed}).") + assert f"NOTE the tracking branch {found}" in result.stdout, result.stdout + checked = check(plan) + assert checked.returncode == 1, checked.stdout + checked.stderr + assert f"FINDING {MISSING}: `tracking_branch` {found}" in checked.stderr + + +def test_an_only_remote_named_outside_printable_ascii_is_not_read(tmp_path): + """A remote whose name is not printable ASCII goes into every way + through as a command line, so it is not read in `origin`'s place, and + `adopt-no-org` says why, naming it.""" + source = cloned_as(tmp_path, "Ürsprung") + + result, _ = planned(tmp_path, source) + + assert result.returncode == 2, result.stdout + result.stderr + assert ("REFUSED adopt-no-org: the source names no organisation this " + "tool can read: it has no remote `origin`, and its one remote, " + "`Ürsprung`, is not read in that one's place, as its name " + "is not printable ASCII") in result.stderr, result.stderr From 8700d2fee5ec28c43419deb882ff32c4aed683e2 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 9 Nov 2026 14:20:58 +0000 Subject: [PATCH 2/8] adopt-project.py: a source whose only remote is not named `origin`, as `clone.defaultRemoteName` names it, is read from that remote, so `plan` reads its organisation, default branch and branches where it refused `adopt-no-org`, took HEAD's branch as the default and asked `--tracking-branch` of the source's own branches, and one with several remotes and no `origin` names them and guesses none (#1276) The final commit over the WIP commit. It changes the `_remotes_of` docstring to say `git switch` "would change" the remote the current branch names (only `git ls-remote --get-url`'s flip was measured), and records the gates. - Rows (tests/test_adopt_source_remote_name.py): 14 passed; on main 12 fail and 2 pass (the (c) row and the legacy-`origin` row). - Mutants M1-M20: 19 killed, M2 equivalent (`git remote get-url origin` exits 0 whenever `origin` is listed). - Gates: the adoption groups (1,620 passed, 4 skipped in ten runs, each module with the modules it imports fixtures from), test_shape_doctor (446 passed, 3 skipped), the leg-path collisions (104 passed), and the guards with test_update_add_codes_documented (349 passed, 3 skipped); every skip is the host's (git 2.43, a case-keeping disk, not Windows, GIT_CONFIG_GLOBAL set). Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adopt-project.py b/adopt-project.py index 70359c75..9f010279 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -3042,7 +3042,7 @@ def _remotes_of(path: Path) -> tuple[str, list[str]]: NOR IS ONE GUESSED AMONG SEVERAL. The current branch's remote is not asked: it is whichever branch this working copy has checked out, not the branch adopted, which is read from the remote, and `git switch` - changed the remote it named, the organisation and the default branch + would change the remote it named, the organisation and the default branch with it, between `plan` and `check`. Nor is `clone.defaultRemoteName`: git reads it in `git clone` alone, and it is this machine's setting now, not the source's. A source with several remotes and no `origin` From 890976c92524eba83e5748a21f58d624e121b50e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 9 Nov 2026 17:05:37 +0000 Subject: [PATCH 3/8] adopt-project.py: `check`'s way through for a tracking branch only a remote has no longer runs `git fetch origin` in a source that has no `origin` (a source with several remotes is given the rename or a branch made from a remote's, one with no remote none), a remote whose name starts with `-` is refused by name as one that is not printable ASCII is, the `--org` help says what is read, the way past a default branch names the remote read, and the dead constants are gone (#1276) A source with no `origin` and two or more other remotes, and one with no remote, were told `git fetch origin` after `plan-tracking-branch-missing`, which fails there (`fatal: 'origin' does not appear to be a git repository`) one line below "it has no remote `origin`". Where remotes were passed over, the remediation now gives what `adopt-no-org` gives, the rename `git remote rename origin` or `git branch refs/remotes//`; a source with no remote is given no fetch. `check`'s split-branch remediation leaves out its `git fetch --prune origin` sentence the same way. Run through in a copy of the several-remotes source, both ways make the same plan check `plan ok`. A remote named `-x` was refused for the wrong reason (git reads `get-url -x` as an option); it is refused by name, with `--` for its rename. `--org`'s help and docs/cli.md say exactly what is read. The push in `plan`'s way past a refused default branch names the remote read. The unread constants `TRACKING_BRANCH_REMEDIATION`, `REMOTE_LACKS_REMEDIATION` and `LOCAL_REMOTE_LACKS` are removed. Rows: the several-remotes and no-remote ways, an ESC-named and a `-x`-named remote, and `up$(id)` quoted in two remediations. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Sonnet 5.5 --- adopt-project.py | 195 +++++++++++++++++-------- docs/cli.md | 8 +- tests/test_adopt_source_remote_name.py | 169 +++++++++++++++++++-- 3 files changed, 296 insertions(+), 76 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 9f010279..65ee2f90 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -3017,6 +3017,29 @@ def _remote_names(path: Path) -> list[str]: return [_git_path(name) for name in listed.splitlines() if name] +def _remote_exists(path: Path, name: str) -> bool: + """Whether the source has a remote `name` as `git remote get-url` reads + one: listed by `git remote`, or kept as a file in `.git/branches` or + `.git/remotes`, which it does not list. `name` is a remote this tool + reads (`origin`, or one `_remotes_of` took), never one that starts with + `-`, which git would read as an option.""" + return subprocess.run(["git", "remote", "get-url", name], cwd=path, + capture_output=True, check=False).returncode == 0 + + +def _printable_ascii(name: str) -> bool: + """Whether `name` is printable ASCII, which every way through can type + in a command line in any locale.""" + return name.isascii() and name.isprintable() + + +def _readable_remote_name(name: str) -> bool: + """Whether `name` may be the source's remote read in `origin`'s place: + printable ASCII, and not one that starts with `-`, which git reads as an + option (`git remote get-url -x` is an unknown switch, not the remote).""" + return _printable_ascii(name) and not name.startswith("-") + + def _remotes_of(path: Path) -> tuple[str, list[str]]: """`(remote, passed_over)`: the remote the source's facts are read from, `_source_remote`'s, and the remotes it has where it has no `origin` and @@ -3048,14 +3071,15 @@ def _remotes_of(path: Path) -> tuple[str, list[str]]: now, not the source's. A source with several remotes and no `origin` is read as one with no remote, and `adopt-no-org` and `plan-tracking-branch-missing` name the remotes passed over - (`_passed_over`). So is a remote whose name is not printable ASCII, - which every way through names in a command line, in any locale.""" + (`_passed_over`) and the ways through, which are no fetch from an + `origin` it has not (`_tracking_branch_remediation`). So is a remote + whose name is not printable ASCII, which every way through names in a + command line, in any locale, and so is one that starts with `-`, which + git reads as an option.""" names = _remote_names(path) - if not names or ORIGIN in names or subprocess.run( - ["git", "remote", "get-url", ORIGIN], cwd=path, - capture_output=True, check=False).returncode == 0: + if not names or ORIGIN in names or _remote_exists(path, ORIGIN): return ORIGIN, [] - if len(names) == 1 and names[0].isascii() and names[0].isprintable(): + if len(names) == 1 and _readable_remote_name(names[0]): return names[0], [] return ORIGIN, names @@ -3066,6 +3090,16 @@ def _source_remote(path: Path) -> str: return _remotes_of(path)[0] +def _fetchable(source: Source) -> str | None: + """The remote a way through may fetch from: the one the source's facts + are read from (`Source.remote`) where the source has it, else None. A + source with no remote has none, and one whose remotes were passed over + has no `origin` (`_remotes_of`), so a fetch from `origin` fails there: + `fatal: 'origin' does not appear to be a git repository`.""" + return (source.remote if _remote_exists(source.path, source.remote) + else None) + + def _passed_over(path: Path) -> str: """How a refusal or a finding says the source's remotes were passed over (`_remotes_of`), by name; empty where none was.""" @@ -3073,9 +3107,13 @@ def _passed_over(path: Path) -> str: if len(passed) > 1: return (f"it has no remote `origin`, and which of its {len(passed)} " f"others it is a clone of is not guessed: {_listed(passed)}") + if not passed: + return "" + why = ("its name is not printable ASCII" if not _printable_ascii(passed[0]) + else "its name starts with `-`, which git reads as an option " + "(`git remote rename -- origin` takes it past that)") return (f"it has no remote `origin`, and its one remote, " - f"{_listed(passed)}, is not read in that one's place, as its " - "name is not printable ASCII" if passed else "") + f"{_listed(passed)}, is not read in that one's place, as {why}") def _the_sources(remote: str) -> str: @@ -3135,6 +3173,8 @@ def _remote_branches(remote: str) -> str: """Where git keeps the branches of the source's remote `remote` as it last fetched them (`_source_remote`): `ORIGIN_BRANCHES` for `origin`.""" return f"refs/remotes/{remote}/" + + #: How `Source.open` refuses a source whose default branch is no branch of #: its own, and the way through it, in `plan`, `check` and `execute` alike #: (#797). @@ -5094,20 +5134,43 @@ def _misread_findings(plan: Plan, unwritten=()) -> list[str]: #: what it prints once after it (#479). `execute` refuses under the same code #: a tracking branch the remote the split goes to does not have. TRACKING_BRANCH_MISSING = "plan-tracking-branch-missing" -def _tracking_branch_remediation(remote: str = "origin") -> str: - """What `check` prints once after `plan-tracking-branch-missing`, the - fetch in it from the source's remote (`_source_remote`, #1276).""" - return ( + + +def _tracking_branch_remediation(source: Source) -> str: + """What `check` prints once after `plan-tracking-branch-missing`: the + branch to name, and how a branch only a remote has gets into the source. + IT FETCHES FROM NO REMOTE THE SOURCE HAS NOT (#1276). Where the source + has the remote it reads, `origin` or its only remote, that is a fetch + from it (`_source_remote`). Where its remotes were passed over, as + `adopt-no-org` says them, it is no fetch from an `origin` it has not + (`fatal: 'origin' does not appear to be a git repository`) but what + `adopt-no-org` gives: the rename that names the remote it is a clone of + `origin`, or a branch made from a remote's. And a source with no remote + has no fetch to give.""" + first = ( "Remediation: set `tracking_branch:` in the plan to a branch the " "source has: its default branch, as `source.default_branch:` records " "it, unless the project is to track another. Or re-run `plan` without " - "`--tracking-branch`, which records that default branch. A branch " - "that is on GitHub and not yet in the source's clone is fetched into " - f"it first (`git fetch {shlex.quote(remote)}`), and then `check` is " - "run again.") + "`--tracking-branch`, which records that default branch.") + passed = _remotes_of(source.path)[1] + if passed: + return ( + f"{first} A branch one of the source's remotes has " + f"({_listed(passed)}), and the source has not made its own, is " + "made one with `git branch refs/remotes//" + "`, run in the source, where `` is that remote; or " + "the source can name the remote it is a clone of `origin` (`git " + "remote rename origin`), and its branches are read from " + "that remote. Then `check` is run again.") + remote = _fetchable(source) + if remote is None: + return f"{first} Then `check` is run again." + return ( + f"{first} A branch that is on GitHub and not yet in the source's " + f"clone is fetched into it first (`git fetch {shlex.quote(remote)}`), " + "and then `check` is run again.") -TRACKING_BRANCH_REMEDIATION = _tracking_branch_remediation() #: Where `_branches_of` reads the branches of a source that has fetched any #: from its remote `origin`; from another remote, `_remote_branches`'. ORIGIN_BRANCHES = "refs/remotes/origin/" @@ -5179,7 +5242,7 @@ def _tracking_branch_findings(plan: Plan, source: Source if problem is None: return [], [] return ([f"FINDING {TRACKING_BRANCH_MISSING}: {problem}"], - [_tracking_branch_remediation(source.remote)]) + [_tracking_branch_remediation(source)]) def _tracking_branch_notes(args, source: Source) -> list[str]: @@ -5199,8 +5262,8 @@ def _tracking_branch_notes(args, source: Source) -> list[str]: #: `execute`'s refusal of a remote `git ls-remote` cannot read, asked before #: any leg exists whether it has the tracking branch. REMOTE_UNREADABLE = "adopt-remote-unreadable" -#: How `_refuse_a_tracking_branch_the_remote_lacks` ends, and the sentence it -#: adds under `--local-remote-dir`, where the source is the remote. + + def _remote_lacks_remediation(remote: str = "origin") -> str: """How `_refuse_a_tracking_branch_the_remote_lacks` ends, its commands run in the source with its remote (`_source_remote`, #1276).""" @@ -5229,10 +5292,6 @@ def _local_remote_lacks(remote: str = "origin") -> str: f"`git branch {shlex.quote(remote)}/` makes it one.") -REMOTE_LACKS_REMEDIATION = _remote_lacks_remediation() -LOCAL_REMOTE_LACKS = _local_remote_lacks() - - def _refuse_a_tracking_branch_the_remote_lacks(urls: dict, tracking: str, local: bool, source_remote: str = "origin" @@ -5476,29 +5535,35 @@ def _refuse_a_root_no_disk_holds(name: str, flag: str, made: str) -> None: f"{PROJECT_NAME_FIX}") -#: What `plan` says after `checked_value`'s remedy when the value refused is -#: the source's own branch, which nobody passed (#797, L1-71's first case on -#: #457). The alphabet alone said nothing of the way past it, which works. -DEFAULT_BRANCH_WAY_PAST = ( - "The source's branch can keep its name: the source is read at it as it " - "is, and only the tracking branch is held to that pattern, since " - "`execute` pushes both legs to it and opens the split's pull request " - "against it. So make a branch whose name matches at the same commit, in " - "the source with that branch checked out (`git branch main`, and `git " - "push origin main` where the source has a remote `origin`), and pass " - "it: `--tracking-branch main`.") - - -def _checked_default_branch(what: str, branch: str) -> str: +def _default_branch_way_past(remote: str) -> str: + """What `plan` says after `checked_value`'s remedy when the value refused + is the source's own branch, which nobody passed (#797, L1-71's first case + on #457). The alphabet alone said nothing of the way past it, which + works. The push in it goes to the source's remote (`_source_remote`, + #1276): `origin`, or its only remote.""" + return ( + "The source's branch can keep its name: the source is read at it as " + "it is, and only the tracking branch is held to that pattern, since " + "`execute` pushes both legs to it and opens the split's pull request " + "against it. So make a branch whose name matches at the same commit, " + "in the source with that branch checked out (`git branch main`, and " + f"`git push {shlex.quote(remote)} main` where the source has a " + f"remote {_spelled(remote)}), and pass it: `--tracking-branch " + "main`.") + + +def _checked_default_branch(what: str, branch: str, + remote: str = ORIGIN) -> str: """`checked_value(what, branch)` of the source's default branch, which `plan` takes as the tracking branch when none is passed, its refusal - saying the way past it too (`DEFAULT_BRANCH_WAY_PAST`).""" + saying the way past it too (`_default_branch_way_past`). `remote` is the + source's remote (`Source.remote`).""" try: return checked_value(what, branch) except Refusal as refused: raise Refusal(refused.code, refused.detail, - f"{refused.remediation} {DEFAULT_BRANCH_WAY_PAST}" - ) from None + f"{refused.remediation} " + f"{_default_branch_way_past(remote)}") from None def _checked_plan_inputs(args, source: Source, @@ -5540,8 +5605,8 @@ def _checked_plan_inputs(args, source: Source, # create is refused here, in git's words. if args.tracking_branch is None: tracking = _flag_named(TRACKING_KEY, set()) - args.tracking_branch = _checked_default_branch(tracking, - source.branch) + args.tracking_branch = _checked_default_branch( + tracking, source.branch, source.remote) refuse_bad_branch("adopt-bad-tracking-branch", tracking, args.tracking_branch) # Whether `--id` was passed is read BEFORE it is filled in: a refusal of @@ -10136,12 +10201,17 @@ def _entry_findings(plan: Plan, source: Source, #: How a finding names the runs that extract a leg from the source: both. EITHER_RUN = "`execute`, with `--local-remote-dir` or without it," -#: What `check` says once after its findings when one of them is about the -#: split branch. -def _split_branch_remediation(remote: str = "origin") -> str: + +def _split_branch_remediation(remote: str | None = ORIGIN) -> str: """What `check` says once after its findings when one of them is about - the split branch, naming the source's remote (`_source_remote`, - #1276).""" + the split branch, naming the source's remote (`_source_remote`, #1276). + `remote` is None where the source has none to fetch from (`_fetchable`): + the sentence that gives the fetch is left out, for it would name an + `origin` the source has not.""" + gone = ("" if remote is None else + f" A branch named as {_the_sources(remote)} last fetched it " + "leaves `check`'s findings once `git fetch --prune " + f"{shlex.quote(remote)}` in the source finds it gone there.") return ( "Remediation: `execute` pushes its one split commit to the plan's " "`adopt_branch:` in the repository the split goes to: the source " @@ -10156,15 +10226,15 @@ def _split_branch_remediation(remote: str = "origin") -> str: "instead. Otherwise delete each branch a finding names, where it " "names it, once neither it nor a pull request from it is wanted, or " "set `adopt_branch:` to a name git can create there, and run `check` " - f"again. A branch named as {_the_sources(remote)} last fetched it " - "leaves `check`'s findings once `git fetch --prune " - f"{shlex.quote(remote)}` in the source finds it gone there. A branch " - "the source itself holds is also in the history `check` reads for " - "the source's submodules, and `git filter-repo` exports it once the " - "plan names another branch, so a finding that names one of its " - "commits stands until that branch is deleted.") + f"again.{gone} A branch the source itself holds is also in the " + "history `check` reads for the source's submodules, and `git " + "filter-repo` exports it once the plan names another branch, so a " + "finding that names one of its commits stands until that branch is " + "deleted.") +#: What `check` says once after its findings when one of them is about the +#: split branch, for a source whose remote is `origin`. SPLIT_BRANCH_REMEDIATION = _split_branch_remediation() #: `execute`'s remediation for `plan-split-branch-taken`. @@ -10426,7 +10496,8 @@ def _check_split_branch(plan: Plan, source: Source extracting = _extracts_a_leg(assigned_paths(plan.entries)) found = _split_branch_findings( branch, _places_check_asks(plan, source, extracting), source) - return found, [_split_branch_remediation(source.remote)] if found else [] + return found, ([_split_branch_remediation(_fetchable(source))] + if found else []) def _a_leg_may_be_on_disk(urls: dict) -> bool: @@ -18740,11 +18811,13 @@ def main(argv: list[str] | None = None) -> int: help="the organisation the source repository is in, " "which the plan records as `org:` (default: the " "organisation of an `org/repo` --source, or the " - "one a local source's `origin` url names, or, " - "where it has no `origin`, its only remote's). A " - "source whose `origin` is a path, or that has " - "none and not one other remote, names no " - "organisation: `plan` refuses it as " + "one a local source's `origin` url names; where " + "it has no `origin` and exactly one remote, that " + "remote's url, unless its name is not printable " + "ASCII or starts with `-`). A source with no " + "remote, with two or more and no `origin`, or " + "whose `origin` (or only remote) is a path, names " + "no organisation: `plan` refuses it as " "`adopt-no-org` unless --org is given. " f"`execute` refuses one over {GITHUB_OWNER_MAX} " "characters without --local-remote-dir, GitHub's " diff --git a/docs/cli.md b/docs/cli.md index 3825c77c..95e977cb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -494,9 +494,11 @@ options: --org ORG the organisation the source repository is in, which the plan records as `org:` (default: the organisation of an `org/repo` --source, or the one a local source's - `origin` url names, or, where it has no `origin`, its - only remote's). A source whose `origin` is a path, or - that has none and not one other remote, names no + `origin` url names; where it has no `origin` and + exactly one remote, that remote's url, unless its name + is not printable ASCII or starts with `-`). A source + with no remote, with two or more and no `origin`, or + whose `origin` (or only remote) is a path, names no organisation: `plan` refuses it as `adopt-no-org` unless --org is given. `execute` refuses one over 39 characters without --local-remote-dir, GitHub's limit diff --git a/tests/test_adopt_source_remote_name.py b/tests/test_adopt_source_remote_name.py index 3ab17762..6097a683 100644 --- a/tests/test_adopt_source_remote_name.py +++ b/tests/test_adopt_source_remote_name.py @@ -51,6 +51,13 @@ FORK_URL = f"https://github.com/someone/{PROJECT}.git" UPSTREAM = "upstream" MISSING = "plan-tracking-branch-missing" +#: A remote named as a shell reads it, and how a command line quotes it: the +#: way through must quote it, so that pasting it runs no command. +SHELL_ACTIVE = "up$(id)" +ONLY_REMOTES = [pytest.param(UPSTREAM, UPSTREAM, id="plain"), + pytest.param(SHELL_ACTIVE, "'up$(id)'", id="shell-active")] +#: The two characters the tests spell by code, so that no escape is typed. +ESC, BACKSLASH = chr(27), chr(92) needs_filter_repo = pytest.mark.skipif( shutil.which("git-filter-repo") is None, reason="git filter-repo is not installed: `pip install git-filter-repo`") @@ -145,14 +152,17 @@ def test_the_default_branch_is_the_only_remotes_head(tmp_path): assert written["tracking_branch"] == "main", written -def test_a_tracking_branch_is_asked_of_the_only_remotes_branches(tmp_path): +@pytest.mark.parametrize("remote, typed", ONLY_REMOTES) +def test_a_tracking_branch_is_asked_of_the_only_remotes_branches( + tmp_path, remote, typed): """The issue's second row, `--tracking-branch`. `develop` is a branch of `upstream` the source has not made its own. On main `plan` noted it as "no branch of the source (its branches: `feature`, `main`...)" and `check` found it, exit 1, where the same source with `origin` checked ok. Now it checks ok; and `wip`, a branch of the source's own alone, is - found as no branch of `upstream`, with the fetch from `upstream`.""" - source = cloned_as(tmp_path, UPSTREAM) + found as no branch of `upstream`, with the fetch from `upstream`, which + is quoted where its name is one a shell would run (`up$(id)`).""" + source = cloned_as(tmp_path, remote) git("switch", "-q", "-c", "feature", cwd=source) git("branch", "wip", cwd=source) @@ -165,26 +175,29 @@ def test_a_tracking_branch_is_asked_of_the_only_remotes_branches(tmp_path): result, plan = planned(tmp_path, source, "--org", ORG, "--tracking-branch", "wip", name="wip") - found = ("is `wip`, which is no branch of the source's remote `upstream` " + found = (f"is `wip`, which is no branch of the source's remote `{remote}` " "(its branches: `develop`, `main`; the source's default branch " "is `main`)") assert f"NOTE the tracking branch {found}" in result.stdout, result.stdout checked = check(plan) assert checked.returncode == 1, checked.stdout + checked.stderr assert f"FINDING {MISSING}: `tracking_branch` {found}" in checked.stderr - assert "(`git fetch upstream`), and then `check` is run again." in \ + assert (f"(`git fetch {typed}`), and then `check` is run again.") in \ checked.stderr, checked.stderr @needs_filter_repo -def test_execute_names_the_only_remote_in_its_way_through(tmp_path): +@pytest.mark.parametrize("remote, typed", ONLY_REMOTES) +def test_execute_names_the_only_remote_in_its_way_through(tmp_path, remote, + typed): """`execute --local-remote-dir` of the `develop` plan: `check`'s reading passes it, and the source itself, where the split goes, has no `develop`, so it is refused before any leg exists, as for the same - source with `origin`, and each command it gives is run with `upstream`. + source with `origin`, and each command it gives is run with `upstream`, + quoted where its name is one a shell would run (`up$(id)`). On main it was refused as `check`'s finding, `develop` being no branch it read.""" - source = cloned_as(tmp_path, UPSTREAM) + source = cloned_as(tmp_path, remote) _, plan = planned(tmp_path, source, "--org", ORG, "--tracking-branch", "develop") @@ -196,10 +209,10 @@ def test_execute_names_the_only_remote_in_its_way_through(tmp_path): said = " ".join(result.stderr.split()) assert f"REFUSED {MISSING}: `tracking_branch` is `develop`, which " in said assert "as `check` prints them" not in said, said - assert ("run `git fetch --prune upstream` and `git remote set-head " - "upstream --auto`") in said, said - assert ("one only its remote `upstream` has is not: `git branch " - "upstream/` makes it one.") in said, said + assert (f"run `git fetch --prune {typed}` and `git remote set-head " + f"{typed} --auto`") in said, said + assert (f"one only its remote `{remote}` has is not: `git branch " + f"{typed}/` makes it one.") in said, said assert "origin" not in said, said assert not (tmp_path / "remotes").exists() @@ -226,6 +239,30 @@ def test_a_clone_of_another_branch_names_the_only_remote(tmp_path): assert not plan.exists() +def test_the_way_past_a_default_branch_pushes_to_the_only_remote(tmp_path): + """`plan` refuses a default branch the tracking branch's pattern does not + take (`a=b`, which git does) as `unsafe-value`, and says the way past + it, a branch of another name pushed to the source's remote. For a source + cloned under `upstream` that was `git push origin main`, to a remote it + has not; now `git push upstream main`.""" + source = cloned_as(tmp_path, UPSTREAM) + git("branch", "a=b", cwd=source) + git("update-ref", "refs/remotes/upstream/a=b", "HEAD", cwd=source) + git("symbolic-ref", "refs/remotes/upstream/HEAD", + "refs/remotes/upstream/a=b", cwd=source) + + result, plan = planned(tmp_path, source, "--org", ORG) + + assert result.returncode == 2, result.stdout + result.stderr + said = " ".join(result.stderr.split()) + assert "REFUSED unsafe-value: --tracking-branch (not given, so the " \ + "source's default branch) is 'a=b'" in said, said + assert ("(`git branch main`, and `git push upstream main` where the " + "source has a remote `upstream`)") in said, said + assert "origin" not in said, said + assert not plan.exists() + + def test_check_asks_the_split_branch_of_the_only_remotes_branches(tmp_path): """`check` asks for the split branch in the assembly repository, as the source's remote last fetched it, where that is the source's own @@ -380,6 +417,114 @@ def test_several_remotes_and_no_origin_are_named_and_not_guessed(tmp_path): checked = check(plan) assert checked.returncode == 1, checked.stdout + checked.stderr assert f"FINDING {MISSING}: `tracking_branch` {found}" in checked.stderr + # Its way through is no `git fetch origin`, which fails here (`fatal: + # 'origin' does not appear to be a git repository`): the rename + # `adopt-no-org` gives, or a branch made from a remote's. + said = " ".join(checked.stderr.split()) + assert "git fetch" not in said, said + assert ("A branch one of the source's remotes has (`fork`, `upstream`), " + "and the source has not made its own, is made one with `git " + "branch refs/remotes//`, run in the " + "source, where `` is that remote; or the source can name " + "the remote it is a clone of `origin` (`git remote rename " + "origin`), and its branches are read from that remote. Then " + "`check` is run again.") in said, said + + +@pytest.mark.parametrize("through", [ + ("branch", "develop", f"refs/remotes/{UPSTREAM}/develop"), + ("remote", "rename", UPSTREAM, "origin"), +], ids=["branch", "rename"]) +def test_the_way_through_for_several_remotes_passes_check(tmp_path, through): + """Each way `check` gives for `develop`, a branch only `upstream` has, in + a source with `upstream` and `fork` and no `origin`, is run in the source, + and the same plan then checks `plan ok`.""" + source = cloned_as(tmp_path, UPSTREAM) + with_fork(tmp_path, source) + git("switch", "-q", "-c", "feature", cwd=source) + _, plan = planned(tmp_path, source, "--org", ORG, + "--tracking-branch", "develop") + checked = check(plan) + assert checked.returncode == 1, checked.stdout + checked.stderr + assert f"FINDING {MISSING}" in checked.stderr, checked.stderr + + git(*through, cwd=source) + + checked = check(plan) + assert checked.returncode == 0, checked.stdout + checked.stderr + assert "plan ok" in checked.stdout, checked.stdout + + +def test_a_source_with_no_remote_is_given_no_fetch(tmp_path): + """A source with no remote has none to fetch from, so `check`'s way + through for a tracking branch it has not leaves out the `git fetch + origin` it gave (`fatal: 'origin' does not appear to be a git + repository`), and says the rest; so does its way through for a split + branch, which gave `git fetch --prune origin`.""" + source = cloned_as(tmp_path, UPSTREAM) + git("remote", "remove", UPSTREAM, cwd=source) + git("switch", "-q", "-c", "feature", cwd=source) + _, plan = planned(tmp_path, source, "--org", ORG, + "--tracking-branch", "develop") + + checked = check(plan) + + assert checked.returncode == 1, checked.stdout + checked.stderr + said = " ".join(checked.stderr.split()) + assert (f"FINDING {MISSING}: `tracking_branch` is `develop`, which is no " + "branch of the source (its branches: `feature`, `main`; the " + "source's default branch is `feature`).") in said, said + assert "git fetch" not in said, said + assert ("which records that default branch. Then `check` is run " + "again.") in said, said + + # `check`'s other way through that fetched from a remote, for a split + # branch git cannot hold, leaves the fetch out too. + git("branch", "adopt", cwd=source) + checked = check(plan) + assert checked.returncode == 1, checked.stdout + checked.stderr + said = " ".join(checked.stderr.split()) + assert "FINDING plan-split-branch-taken" in said, said + assert "git fetch" not in said, said + assert "last fetched it" not in said, said + + +@pytest.mark.parametrize("make, name, why", [ + (("config", f"remote.up{ESC}[2Jx.url", GITHUB_URL), f"up{ESC}[2Jx", + "its name is not printable ASCII"), + (("remote", "add", "--", "-x", GITHUB_URL), "-x", + "its name starts with `-`, which git reads as an option (`git remote " + "rename -- origin` takes it past that)"), +], ids=["control-character", "leading-dash"]) +def test_an_only_remote_a_command_line_cannot_name_is_not_read( + tmp_path, make, name, why): + """A remote whose name holds an ESC (`git config` makes one: `git remote + add` refuses it, and `git remote` lists it) is refused by name, spelled, + where it was read as the source's remote and printed raw into the + `source` line, as the clearing of a screen. One named `-x` is refused by + name too: git reads `get-url -x` as an option, which refused it for + another reason. Both go with the rename and no fetch.""" + source = cloned_as(tmp_path, UPSTREAM) + git("remote", "remove", UPSTREAM, cwd=source) + git(*make, cwd=source) + git("switch", "-q", "-c", "feature", cwd=source) + spelled = name.replace(ESC, BACKSLASH + "x1b") + + result, _ = planned(tmp_path, source) + + assert result.returncode == 2, result.stdout + result.stderr + assert ("REFUSED adopt-no-org: the source names no organisation this " + "tool can read: it has no remote `origin`, and its one remote, " + f"`{spelled}`, is not read in that one's place, as {why}\n") in \ + result.stderr, result.stderr + result, plan = planned(tmp_path, source, "--org", ORG, + "--tracking-branch", "develop", name="org") + assert result.returncode == 0, result.stdout + result.stderr + checked = check(plan) + assert checked.returncode == 1, checked.stdout + checked.stderr + assert "git fetch" not in checked.stderr, checked.stderr + assert "(`git remote rename origin`)" in checked.stderr + assert ESC not in result.stdout + result.stderr + checked.stderr def test_an_only_remote_named_outside_printable_ascii_is_not_read(tmp_path): From 5cb1abdbc78f3dbe751bb91fa0e5d4f6ddc4cbd8 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 9 Nov 2026 21:34:58 +0000 Subject: [PATCH 4/8] adopt-project.py: the `git remote rename` that `check` and `adopt-no-org` print takes `--`, so it works for a remote named `-x`; one remote is worded as one, a remote whose name holds an ESC is given no `git branch`, the split-branch row expects the text for a source with no remote, and the dead `ORIGIN_BRANCHES` and four unused defaults are gone (#1276) RV1280b's review of 890976c92. CI's `tests` failed on tests/test_adopt_split_branch.py::test_check_says_how_to_go_on_once_after_its_findings: `small_source` has no remote, so `check` leaves out the sentence that sends the person to fetch from an `origin` it has not, and the row counted the text for a source whose remote is `origin`. It expects `_split_branch_remediation(None)` once and no `git fetch` now. `git remote rename origin` is `error: unknown switch` for a remote named `-x`. `check`'s remediation for a tracking branch only a remote has, and `adopt-no-org`'s, print `git remote rename -- origin` now, which works for every name `_remotes_of` passes over. A lone remote is worded as one ("A branch the source's remote `-x` has"). A remote whose name holds an ESC is given no `git branch refs/remotes//`, which git refuses (`_holds_branches` asks `git check-ref-format`), and the sentence says why. Each way, run as printed, makes `check` pass: (d), `-x` alone, and the rename `adopt-no-org` gives for `-x` and for an ESC remote. An `up$(id)` case is added to the row for `_default_branch_way_past` and to a split-branch row, so dropping the `shlex.quote` in either fails a row. The unused `ORIGIN_BRANCHES` and the defaults on `_remote_lacks_remediation`, `_local_remote_lacks`, `_refuse_a_tracking_branch_the_remote_lacks` and `_checked_default_branch` (every caller passes the remote) are removed. The `--org` help says "the one that remote's url names"; docs/cli.md is regenerated. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Sonnet 5.5 --- adopt-project.py | 95 ++++++++++---- docs/cli.md | 16 +-- tests/test_adopt_source_remote_name.py | 168 +++++++++++++++++++------ tests/test_adopt_split_branch.py | 6 +- 4 files changed, 211 insertions(+), 74 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 65ee2f90..d551863b 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -3040,6 +3040,20 @@ def _readable_remote_name(name: str) -> bool: return _printable_ascii(name) and not name.startswith("-") +def _holds_branches(name: str) -> bool: + """Whether git can hold a branch of the source's remote `name`, which is + whether `refs/remotes//` is a ref name git takes + (`git check-ref-format`, which opens no repository). A remote whose name + holds an ESC, which `git config` makes and `git remote add` refuses, is + not: git cannot fetch a branch of it, and `git branch + refs/remotes//` has none to name (`fatal: not a valid + object name`), though `git remote rename -- origin` works.""" + return subprocess.run( + ["git", "check-ref-format", + _path_argument(f"refs/remotes/{name}/branch")], + capture_output=True, check=False).returncode == 0 + + def _remotes_of(path: Path) -> tuple[str, list[str]]: """`(remote, passed_over)`: the remote the source's facts are read from, `_source_remote`'s, and the remotes it has where it has no `origin` and @@ -3171,7 +3185,8 @@ def _remote_head(remote: str) -> str: def _remote_branches(remote: str) -> str: """Where git keeps the branches of the source's remote `remote` as it - last fetched them (`_source_remote`): `ORIGIN_BRANCHES` for `origin`.""" + last fetched them (`_source_remote`): `refs/remotes/origin/` for + `origin`.""" return f"refs/remotes/{remote}/" @@ -3347,7 +3362,7 @@ def _branch_under(name: str | None, prefix: str) -> str | None: def _branch_names(path: Path, prefix: str) -> list[str]: """The names of the source's refs under `prefix` (`BRANCH_REFS`, its own - branches; `ORIGIN_BRANCHES`, its remote's as it last fetched them), each + branches; `_remote_branches`, its remote's as it last fetched them), each as `Source.tree` reads a path, so a byte that is not UTF-8 is kept rather than refused.""" raw = git_out(["for-each-ref", "--format=%(refname)", prefix], cwd=path, @@ -5136,6 +5151,44 @@ def _misread_findings(plan: Plan, unwritten=()) -> list[str]: TRACKING_BRANCH_MISSING = "plan-tracking-branch-missing" +def _ways_through_remotes_passed_over(passed: list[str]) -> str: + """What `_tracking_branch_remediation` says after its first way where the + source's remotes `passed` were passed over (`_remotes_of`): a branch + only one of them has is made the source's own from the branch git holds + of it, or the source names the remote it is a clone of `origin`. + + THE RENAME IS `git remote rename -- origin`. Without `--` git + reads a name that starts with `-` as an option (`git remote rename -x + origin` is `error: unknown switch`), and with it every name that is + passed over renames, `-x` and an ordinary one alike (git 2.43). + + A REMOTE GIT CAN HOLD NO BRANCH OF (`_holds_branches`) IS GIVEN NO `git + branch`. A name with an ESC in it is no part of a ref git takes, so + there is no `refs/remotes//` to make a branch from, and + the sentence says so; its remote is still renamed, where it has no + fetch refspec of its own (one with the usual refspec is `fatal: invalid + refspec`, as is any fetch from it).""" + holding = [name for name in passed if _holds_branches(name)] + rename = ("the source can name the remote it is a clone of `origin` " + "(`git remote rename -- origin`), and its branches are " + "read from that remote") + if not holding: + of = (f"the source's remote {_listed(passed)}" if len(passed) == 1 + else f"the source's remotes {_listed(passed)}") + return ( + f"`git branch` makes no branch of {of} the source's own, as git " + f"holds no branch of a remote named like that, but {rename}. " + "Then `check` is run again.") + has = (f"the source's remote {_listed(holding)} has" + if len(holding) == 1 else + f"one of the source's remotes has ({_listed(holding)})") + return ( + f"A branch {has}, and the source has not made its own, is made one " + "with `git branch refs/remotes//`, run in the " + f"source, where `` is that remote; or {rename}. Then `check` " + "is run again.") + + def _tracking_branch_remediation(source: Source) -> str: """What `check` prints once after `plan-tracking-branch-missing`: the branch to name, and how a branch only a remote has gets into the source. @@ -5154,14 +5207,7 @@ def _tracking_branch_remediation(source: Source) -> str: "`--tracking-branch`, which records that default branch.") passed = _remotes_of(source.path)[1] if passed: - return ( - f"{first} A branch one of the source's remotes has " - f"({_listed(passed)}), and the source has not made its own, is " - "made one with `git branch refs/remotes//" - "`, run in the source, where `` is that remote; or " - "the source can name the remote it is a clone of `origin` (`git " - "remote rename origin`), and its branches are read from " - "that remote. Then `check` is run again.") + return f"{first} {_ways_through_remotes_passed_over(passed)}" remote = _fetchable(source) if remote is None: return f"{first} Then `check` is run again." @@ -5171,9 +5217,6 @@ def _tracking_branch_remediation(source: Source) -> str: "and then `check` is run again.") -#: Where `_branches_of` reads the branches of a source that has fetched any -#: from its remote `origin`; from another remote, `_remote_branches`'. -ORIGIN_BRANCHES = "refs/remotes/origin/" #: How many branches a finding names; past it, the rest are counted. BRANCHES_NAMED = 8 @@ -5264,7 +5307,7 @@ def _tracking_branch_notes(args, source: Source) -> list[str]: REMOTE_UNREADABLE = "adopt-remote-unreadable" -def _remote_lacks_remediation(remote: str = "origin") -> str: +def _remote_lacks_remediation(remote: str) -> str: """How `_refuse_a_tracking_branch_the_remote_lacks` ends, its commands run in the source with its remote (`_source_remote`, #1276).""" typed = shlex.quote(remote) @@ -5282,7 +5325,7 @@ def _remote_lacks_remediation(remote: str = "origin") -> str: "now) and `check`, and run `execute` again.") -def _local_remote_lacks(remote: str = "origin") -> str: +def _local_remote_lacks(remote: str) -> str: """The sentence it adds under `--local-remote-dir`, where the source is the remote, naming the source's remote (`_source_remote`, #1276).""" return ( @@ -5294,8 +5337,7 @@ def _local_remote_lacks(remote: str = "origin") -> str: def _refuse_a_tracking_branch_the_remote_lacks(urls: dict, tracking: str, local: bool, - source_remote: str = "origin" - ) -> None: + source_remote: str) -> None: """Refuse a tracking branch the repository the split goes to does not have, BEFORE a leg repository exists (#479; #493's review, F1). @@ -5552,8 +5594,7 @@ def _default_branch_way_past(remote: str) -> str: "main`.") -def _checked_default_branch(what: str, branch: str, - remote: str = ORIGIN) -> str: +def _checked_default_branch(what: str, branch: str, remote: str) -> str: """`checked_value(what, branch)` of the source's default branch, which `plan` takes as the tracking branch when none is passed, its refusal saying the way past it too (`_default_branch_way_past`). `remote` is the @@ -5853,7 +5894,7 @@ def _planned_org(org: str | None, source: Source) -> str: "adopt-no-org", "the source names no organisation this tool can " f"read: {passed}", "Remediation: pass --org , or, in the source, name the " - "remote it is a clone of `origin` (`git remote rename " + "remote it is a clone of `origin` (`git remote rename -- " "origin`), and its organisation, default branch and branches " "are read from that remote.") if not read: @@ -18812,13 +18853,13 @@ def main(argv: list[str] | None = None) -> int: "which the plan records as `org:` (default: the " "organisation of an `org/repo` --source, or the " "one a local source's `origin` url names; where " - "it has no `origin` and exactly one remote, that " - "remote's url, unless its name is not printable " - "ASCII or starts with `-`). A source with no " - "remote, with two or more and no `origin`, or " - "whose `origin` (or only remote) is a path, names " - "no organisation: `plan` refuses it as " - "`adopt-no-org` unless --org is given. " + "it has no `origin` and exactly one remote, the " + "one that remote's url names, unless its name is " + "not printable ASCII or starts with `-`). A source " + "with no remote, with two or more and no " + "`origin`, or whose `origin` (or only remote) is " + "a path, names no organisation: `plan` refuses it " + "as `adopt-no-org` unless --org is given. " f"`execute` refuses one over {GITHUB_OWNER_MAX} " "characters without --local-remote-dir, GitHub's " "limit on an organisation's name.") diff --git a/docs/cli.md b/docs/cli.md index 95e977cb..b1d9154c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -495,14 +495,14 @@ options: the plan records as `org:` (default: the organisation of an `org/repo` --source, or the one a local source's `origin` url names; where it has no `origin` and - exactly one remote, that remote's url, unless its name - is not printable ASCII or starts with `-`). A source - with no remote, with two or more and no `origin`, or - whose `origin` (or only remote) is a path, names no - organisation: `plan` refuses it as `adopt-no-org` - unless --org is given. `execute` refuses one over 39 - characters without --local-remote-dir, GitHub's limit - on an organisation's name. + exactly one remote, the one that remote's url names, + unless its name is not printable ASCII or starts with + `-`). A source with no remote, with two or more and no + `origin`, or whose `origin` (or only remote) is a + path, names no organisation: `plan` refuses it as + `adopt-no-org` unless --org is given. `execute` + refuses one over 39 characters without --local-remote- + dir, GitHub's limit on an organisation's name. --id ID lowercase project id (default: --project lowercased). `execute` refuses one over 39 characters without --local-remote-dir: GitHub keeps a topic to 50, and diff --git a/tests/test_adopt_source_remote_name.py b/tests/test_adopt_source_remote_name.py index 6097a683..d5339b68 100644 --- a/tests/test_adopt_source_remote_name.py +++ b/tests/test_adopt_source_remote_name.py @@ -239,17 +239,20 @@ def test_a_clone_of_another_branch_names_the_only_remote(tmp_path): assert not plan.exists() -def test_the_way_past_a_default_branch_pushes_to_the_only_remote(tmp_path): +@pytest.mark.parametrize("remote, typed", ONLY_REMOTES) +def test_the_way_past_a_default_branch_pushes_to_the_only_remote( + tmp_path, remote, typed): """`plan` refuses a default branch the tracking branch's pattern does not take (`a=b`, which git does) as `unsafe-value`, and says the way past it, a branch of another name pushed to the source's remote. For a source cloned under `upstream` that was `git push origin main`, to a remote it - has not; now `git push upstream main`.""" - source = cloned_as(tmp_path, UPSTREAM) + has not; now `git push upstream main`, quoted where the name is one a + shell would run (`git push 'up$(id)' main`).""" + source = cloned_as(tmp_path, remote) git("branch", "a=b", cwd=source) - git("update-ref", "refs/remotes/upstream/a=b", "HEAD", cwd=source) - git("symbolic-ref", "refs/remotes/upstream/HEAD", - "refs/remotes/upstream/a=b", cwd=source) + git("update-ref", f"refs/remotes/{remote}/a=b", "HEAD", cwd=source) + git("symbolic-ref", f"refs/remotes/{remote}/HEAD", + f"refs/remotes/{remote}/a=b", cwd=source) result, plan = planned(tmp_path, source, "--org", ORG) @@ -257,32 +260,35 @@ def test_the_way_past_a_default_branch_pushes_to_the_only_remote(tmp_path): said = " ".join(result.stderr.split()) assert "REFUSED unsafe-value: --tracking-branch (not given, so the " \ "source's default branch) is 'a=b'" in said, said - assert ("(`git branch main`, and `git push upstream main` where the " - "source has a remote `upstream`)") in said, said + assert (f"(`git branch main`, and `git push {typed} main` where the " + f"source has a remote `{remote}`)") in said, said assert "origin" not in said, said assert not plan.exists() -def test_check_asks_the_split_branch_of_the_only_remotes_branches(tmp_path): +@pytest.mark.parametrize("remote, typed", ONLY_REMOTES) +def test_check_asks_the_split_branch_of_the_only_remotes_branches( + tmp_path, remote, typed): """`check` asks for the split branch in the assembly repository, as the source's remote last fetched it, where that is the source's own repository. `upstream` has a branch `adopt`, which git cannot hold beside `adopt/three-repo-shape`. On main `upstream`'s branches were not read, and `check` said `plan ok`; it is found now, named as `upstream` - last fetched it, with the fetch that prunes it from `upstream`.""" - source = cloned_as(tmp_path, UPSTREAM) - git("update-ref", "refs/remotes/upstream/adopt", "HEAD", cwd=source) + last fetched it, with the fetch that prunes it from `upstream`, quoted + where the name is one a shell would run (`up$(id)`).""" + source = cloned_as(tmp_path, remote) + git("update-ref", f"refs/remotes/{remote}/adopt", "HEAD", cwd=source) _, plan = planned(tmp_path, source, "--org", ORG) checked = check(plan) assert checked.returncode == 1, checked.stdout + checked.stderr said = " ".join(checked.stderr.split()) - assert (f"`{ORG}/{PROJECT}` on GitHub, as the source's remote `upstream` " - "last fetched it,") in said, said - assert ("A branch named as the source's remote `upstream` last fetched it " - "leaves `check`'s findings once `git fetch --prune upstream` in " - "the source finds it gone there.") in said, said + assert (f"`{ORG}/{PROJECT}` on GitHub, as the source's remote " + f"`{remote}` last fetched it,") in said, said + assert (f"A branch named as the source's remote `{remote}` last fetched " + f"it leaves `check`'s findings once `git fetch --prune {typed}` " + "in the source finds it gone there.") in said, said @pytest.mark.parametrize("url, said", [ @@ -404,7 +410,7 @@ def test_several_remotes_and_no_origin_are_named_and_not_guessed(tmp_path): assert result.returncode == 2, result.stdout + result.stderr assert ("REFUSED adopt-no-org: the source names no organisation this " f"tool can read: {passed}\n") in result.stderr, result.stderr - assert "(`git remote rename origin`)" in result.stderr + assert "(`git remote rename -- origin`)" in result.stderr result, plan = planned(tmp_path, source, "--org", ORG, "--tracking-branch", "develop", name="org") @@ -426,19 +432,20 @@ def test_several_remotes_and_no_origin_are_named_and_not_guessed(tmp_path): "and the source has not made its own, is made one with `git " "branch refs/remotes//`, run in the " "source, where `` is that remote; or the source can name " - "the remote it is a clone of `origin` (`git remote rename " - "origin`), and its branches are read from that remote. Then " - "`check` is run again.") in said, said + "the remote it is a clone of `origin` (`git remote rename -- " + " origin`), and its branches are read from that remote. " + "Then `check` is run again.") in said, said @pytest.mark.parametrize("through", [ ("branch", "develop", f"refs/remotes/{UPSTREAM}/develop"), - ("remote", "rename", UPSTREAM, "origin"), + ("remote", "rename", "--", UPSTREAM, "origin"), ], ids=["branch", "rename"]) def test_the_way_through_for_several_remotes_passes_check(tmp_path, through): """Each way `check` gives for `develop`, a branch only `upstream` has, in a source with `upstream` and `fork` and no `origin`, is run in the source, - and the same plan then checks `plan ok`.""" + as printed (the rename takes `--`, which a remote named `-x` needs), and + the same plan then checks `plan ok`.""" source = cloned_as(tmp_path, UPSTREAM) with_fork(tmp_path, source) git("switch", "-q", "-c", "feature", cwd=source) @@ -489,25 +496,57 @@ def test_a_source_with_no_remote_is_given_no_fetch(tmp_path): assert "last fetched it" not in said, said -@pytest.mark.parametrize("make, name, why", [ - (("config", f"remote.up{ESC}[2Jx.url", GITHUB_URL), f"up{ESC}[2Jx", - "its name is not printable ASCII"), - (("remote", "add", "--", "-x", GITHUB_URL), "-x", - "its name starts with `-`, which git reads as an option (`git remote " - "rename -- origin` takes it past that)"), -], ids=["control-character", "leading-dash"]) +#: An only remote a command line cannot name the way it names an ordinary +#: one, as `(make, name, why, ways)`: how a source gets it, its name, why +#: `adopt-no-org` says it is not read, and what `check` says to do for a +#: branch only it has. A name with an ESC (`git config` makes one, `git +#: remote add` refuses it) is one git can hold no branch of: `git branch +#: refs/remotes//` has none to name, so it is not +#: given. A name that starts with `-` holds branches, and its rename takes +#: `--`. +RENAME = ("the source can name the remote it is a clone of `origin` (`git " + "remote rename -- origin`), and its branches are read from " + "that remote. Then `check` is run again.") +ODD_ONLY_REMOTES = [ + pytest.param( + ("config", f"remote.up{ESC}[2Jx.url", GITHUB_URL), f"up{ESC}[2Jx", + "its name is not printable ASCII", + "`git branch` makes no branch of the source's remote " + f"`up{BACKSLASH}x1b[2Jx` the source's own, as git holds no branch of " + f"a remote named like that, but {RENAME}", id="control-character"), + pytest.param( + ("remote", "add", "--", "-x", GITHUB_URL), "-x", + "its name starts with `-`, which git reads as an option (`git remote " + "rename -- origin` takes it past that)", + "A branch the source's remote `-x` has, and the source has not made " + "its own, is made one with `git branch refs/remotes//" + "`, run in the source, where `` is that remote; or " + f"{RENAME}", id="leading-dash"), +] + + +def odd_only_remote(tmp_path, make): + """The source `Thing` with no remote but the one `make` makes, HEAD on a + branch `feature` of its own.""" + source = cloned_as(tmp_path, UPSTREAM) + git("remote", "remove", UPSTREAM, cwd=source) + git(*make, cwd=source) + git("switch", "-q", "-c", "feature", cwd=source) + return source + + +@pytest.mark.parametrize("make, name, why, ways", ODD_ONLY_REMOTES) def test_an_only_remote_a_command_line_cannot_name_is_not_read( - tmp_path, make, name, why): + tmp_path, make, name, why, ways): """A remote whose name holds an ESC (`git config` makes one: `git remote add` refuses it, and `git remote` lists it) is refused by name, spelled, where it was read as the source's remote and printed raw into the `source` line, as the clearing of a screen. One named `-x` is refused by name too: git reads `get-url -x` as an option, which refused it for - another reason. Both go with the rename and no fetch.""" - source = cloned_as(tmp_path, UPSTREAM) - git("remote", "remove", UPSTREAM, cwd=source) - git(*make, cwd=source) - git("switch", "-q", "-c", "feature", cwd=source) + another reason. Both go with the rename, which takes `--`, and no fetch; + the one git can hold branches of is also told the branch it makes, and + the other, why it has none.""" + source = odd_only_remote(tmp_path, make) spelled = name.replace(ESC, BACKSLASH + "x1b") result, _ = planned(tmp_path, source) @@ -517,16 +556,69 @@ def test_an_only_remote_a_command_line_cannot_name_is_not_read( "tool can read: it has no remote `origin`, and its one remote, " f"`{spelled}`, is not read in that one's place, as {why}\n") in \ result.stderr, result.stderr + assert ("name the remote it is a clone of `origin` (`git remote rename " + "-- origin`)") in " ".join(result.stderr.split()) result, plan = planned(tmp_path, source, "--org", ORG, "--tracking-branch", "develop", name="org") assert result.returncode == 0, result.stdout + result.stderr checked = check(plan) assert checked.returncode == 1, checked.stdout + checked.stderr - assert "git fetch" not in checked.stderr, checked.stderr - assert "(`git remote rename origin`)" in checked.stderr + said = " ".join(checked.stderr.split()) + assert "git fetch" not in said, said + assert ways in said, said + assert "git remote rename " not in said, said assert ESC not in result.stdout + result.stderr + checked.stderr +@pytest.mark.parametrize("make, name, why, ways", ODD_ONLY_REMOTES) +def test_the_rename_adopt_no_org_gives_reads_the_remote_as_origin( + tmp_path, make, name, why, ways): + """`adopt-no-org`'s way through for a remote `-x` or one with an ESC, + `git remote rename -- origin`, is run as printed: the same `plan` + with no `--org` then reads the organisation from it, and its `check` + passes. The rename without `--` is not a way for `-x`: git reads it as + an option (`error: unknown switch`).""" + source = odd_only_remote(tmp_path, make) + result, _ = planned(tmp_path, source) + assert result.returncode == 2, result.stdout + result.stderr + assert "REFUSED adopt-no-org" in result.stderr, result.stderr + + git("remote", "rename", "--", name, "origin", cwd=source) + + result, plan = planned(tmp_path, source, name="renamed") + assert result.returncode == 0, result.stdout + result.stderr + assert load_yaml(plan)["org"] == ORG + checked = check(plan) + assert checked.returncode == 0, checked.stdout + checked.stderr + assert "plan ok" in checked.stdout, checked.stdout + + +@pytest.mark.parametrize("through", [ + ("branch", "develop", "refs/remotes/-x/develop"), + ("remote", "rename", "--", "-x", "origin"), +], ids=["branch", "rename"]) +def test_the_way_through_for_a_remote_named_like_an_option_passes_check( + tmp_path, through): + """The only remote `-x` has `develop`, which the source has not made its + own: each way `check` gives, run in the source as printed, makes the + same plan check `plan ok`. The branch is made from `refs/remotes/-x/ + develop`, which is no option to git, and the rename takes `--`.""" + source = cloned_as(tmp_path, UPSTREAM) + git("remote", "rename", UPSTREAM, "--", "-x", cwd=source) + git("switch", "-q", "-c", "feature", cwd=source) + _, plan = planned(tmp_path, source, "--org", ORG, + "--tracking-branch", "develop") + checked = check(plan) + assert checked.returncode == 1, checked.stdout + checked.stderr + assert f"FINDING {MISSING}" in checked.stderr, checked.stderr + + git(*through, cwd=source) + + checked = check(plan) + assert checked.returncode == 0, checked.stdout + checked.stderr + assert "plan ok" in checked.stdout, checked.stdout + + def test_an_only_remote_named_outside_printable_ascii_is_not_read(tmp_path): """A remote whose name is not printable ASCII goes into every way through as a command line, so it is not read in `origin`'s place, and diff --git a/tests/test_adopt_split_branch.py b/tests/test_adopt_split_branch.py index 84694d2b..ec872be2 100644 --- a/tests/test_adopt_split_branch.py +++ b/tests/test_adopt_split_branch.py @@ -168,7 +168,11 @@ def test_check_says_how_to_go_on_once_after_its_findings(adopter, tmp_path): result = run_script(ADOPT, "check", "--plan", str(plan_for(source, tmp_path))) assert result.returncode == 1, result.stderr + result.stdout - assert result.stderr.count(adopter.SPLIT_BRANCH_REMEDIATION) == 1 + # `small_source` has no remote, so the form with no fetch is the one said + # (`_split_branch_remediation(None)`): `SPLIT_BRANCH_REMEDIATION`, the + # `origin` form, sends it to fetch from an `origin` it has not (#1276). + assert result.stderr.count(adopter._split_branch_remediation(None)) == 1 + assert "git fetch" not in result.stderr, result.stderr def test_execute_refuses_it_under_the_same_code_before_any_leg_exists( From 9d71536d153ac429d3e24547e54349de5857816c Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 10 Nov 2026 16:35:06 +0000 Subject: [PATCH 5/8] shape-doctor.py: a repository whose only remote is not named `origin` is offered the adoption line `plan` takes, `--project Thing` with no `--org`, where it was `--project WorkUpstream --org `, which `plan` refused `adopt-not-the-source`; the `naming` row and the Triad advisory read that remote too (#1292) After #1280 (#1276), `adopt-project.py plan` reads a source's only remote where it has no `origin`. The doctor's `origin_org` and `origin_name`, and `shape_advisory.repository_names`, read `origin` alone, so for a clone of `testorg/Thing` whose only remote is `upstream` the way in offered `--project WorkUpstream --org `, and that line, filled in as it asks, ended in `REFUSED adopt-not-the-source` (RV1280 S4 on #1280). The rule for which remote is read (`_remotes_of`, with `_remote_exists` and `_printable_ascii`) moves from `adopt-project.py` to `scripts/shape_advisory.py`, which both tools already import and no root copies, and `adopt-project.py` imports it under the names it always used. The doctor's two reads and `repository_names` ask `source_remote` from it. Not `scripts/repo_shape.py`: it is a shape copy every root pins, and a rule added there is an `upstream-changed` row for every project. Not a copy in the doctor held by a parity row: #521's copy of `plan`'s regex drifted. A source with an `origin`, several remotes and no `origin`, a remote named `-x` or not in printable ASCII, or no remote reads byte for byte as before in the doctor's `--json` and text, and `plan` is byte-identical on every probe source. No new command or flag. Rows in tests/test_shape_doctor.py: 14, 3 of which fail at #1280's head. Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 89 ++++--------------------- scripts/shape_advisory.py | 133 ++++++++++++++++++++++++++++++++++++- shape-doctor.py | 23 ++++++- tests/test_shape_doctor.py | 127 +++++++++++++++++++++++++++++++++++ 4 files changed, 292 insertions(+), 80 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index d551863b..b79c0b62 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -266,6 +266,18 @@ refuse_bad_leg_paths as _refuse_bad_leg_paths, ) import shape_advisory # noqa: E402 +# WHICH REMOTE IS THE SOURCE'S IS ONE RULE FOR BOTH TOOLS (#1292). +# `_remotes_of`, `_remote_exists` and `_printable_ascii` were defined here +# (#1276) and are `scripts/shape_advisory.py`'s now, under the names this +# file has always called them by: `shape-doctor.py` asks the same question +# of the repository it is run in, for the `--project` and `--org` of its +# adoption line, and a copy of the rule there would drift from this one as +# its copy of `plan`'s regex did (#521). `shape_advisory.remotes_of` says +# why the rule is what it is, and why it lives there. +from shape_advisory import ( # noqa: E402 + printable_ascii as _printable_ascii, remote_exists as _remote_exists, + remotes_of as _remotes_of, +) # #172's shape half, shared with the scaffold since #197 (see that section # below), under the names this file has always called it by. from shape_materialize import ( # noqa: E402 @@ -3007,39 +3019,6 @@ def commit_count(self) -> int: ORIGIN = "origin" -def _remote_names(path: Path) -> list[str]: - """The source's remotes as `git remote` lists them, each as `Source.tree` - reads a path (`_git_path`); none where git cannot list them.""" - try: - listed = git_out(["remote"], cwd=path, binary=True) - except Refusal: - return [] - return [_git_path(name) for name in listed.splitlines() if name] - - -def _remote_exists(path: Path, name: str) -> bool: - """Whether the source has a remote `name` as `git remote get-url` reads - one: listed by `git remote`, or kept as a file in `.git/branches` or - `.git/remotes`, which it does not list. `name` is a remote this tool - reads (`origin`, or one `_remotes_of` took), never one that starts with - `-`, which git would read as an option.""" - return subprocess.run(["git", "remote", "get-url", name], cwd=path, - capture_output=True, check=False).returncode == 0 - - -def _printable_ascii(name: str) -> bool: - """Whether `name` is printable ASCII, which every way through can type - in a command line in any locale.""" - return name.isascii() and name.isprintable() - - -def _readable_remote_name(name: str) -> bool: - """Whether `name` may be the source's remote read in `origin`'s place: - printable ASCII, and not one that starts with `-`, which git reads as an - option (`git remote get-url -x` is an unknown switch, not the remote).""" - return _printable_ascii(name) and not name.startswith("-") - - def _holds_branches(name: str) -> bool: """Whether git can hold a branch of the source's remote `name`, which is whether `refs/remotes//` is a ref name git takes @@ -3054,50 +3033,6 @@ def _holds_branches(name: str) -> bool: capture_output=True, check=False).returncode == 0 -def _remotes_of(path: Path) -> tuple[str, list[str]]: - """`(remote, passed_over)`: the remote the source's facts are read from, - `_source_remote`'s, and the remotes it has where it has no `origin` and - none is read in that one's place, which is never guessed among; else - `[]`. - - A SOURCE CLONED UNDER `clone.defaultRemoteName` HAS NO `origin` (#1276). - `git clone` names the remote after that setting, `upstream` say, and - `plan` read only a remote named `origin`: it refused `adopt-no-org` - with no `--org`, read the branch HEAD was on as the default branch, and - asked `--tracking-branch` of the source's own branches, where the same - source with `origin` read the organisation, `origin/HEAD` and - `origin`'s branches. So the source's ONLY remote is read in `origin`'s - place, as git itself reads it (`git ls-remote --get-url`, `git fetch` - with no remote named): the current branch's remote, else the only - remote, else `origin`, measured with git 2.43. - - A SOURCE THAT HAS AN `origin` IS READ AS IT WAS, whatever other remotes - it has: listed by `git remote`, or a file in `.git/branches` or - `.git/remotes`, which `git remote` does not list and `git remote - get-url origin` reads. A source with no remote is read as it was too. - - NOR IS ONE GUESSED AMONG SEVERAL. The current branch's remote is not - asked: it is whichever branch this working copy has checked out, not - the branch adopted, which is read from the remote, and `git switch` - would change the remote it named, the organisation and the default branch - with it, between `plan` and `check`. Nor is `clone.defaultRemoteName`: - git reads it in `git clone` alone, and it is this machine's setting - now, not the source's. A source with several remotes and no `origin` - is read as one with no remote, and `adopt-no-org` and - `plan-tracking-branch-missing` name the remotes passed over - (`_passed_over`) and the ways through, which are no fetch from an - `origin` it has not (`_tracking_branch_remediation`). So is a remote - whose name is not printable ASCII, which every way through names in a - command line, in any locale, and so is one that starts with `-`, which - git reads as an option.""" - names = _remote_names(path) - if not names or ORIGIN in names or _remote_exists(path, ORIGIN): - return ORIGIN, [] - if len(names) == 1 and _readable_remote_name(names[0]): - return names[0], [] - return ORIGIN, names - - def _source_remote(path: Path) -> str: """The remote the source's facts are read from (#1276): `origin` where it has one, else its only remote, else `origin` (`_remotes_of`).""" diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py index ae535edc..ea886ec8 100644 --- a/scripts/shape_advisory.py +++ b/scripts/shape_advisory.py @@ -504,6 +504,130 @@ def repository_name(url: str) -> str | None: return name or None +# --------------------------------------------------------------------------- +# Which remote a repository's facts are read from (#1276, #1292) +# --------------------------------------------------------------------------- +# +# ONE RULE FOR EVERY TOOL THAT READS A REPOSITORY'S REMOTE (#1292). After +# #1276 `adopt-project.py plan` read a source's only remote where it has no +# `origin`, and `shape-doctor.py` still read `origin` alone: for a clone +# whose only remote is `upstream` the doctor's adoption line said +# `--project WorkUpstream --org `, and that line, filled in as it +# asks, ended in `plan`'s `adopt-not-the-source`. The rule lives HERE, and +# both tools import it, so they cannot come to ask two questions. +# +# NOT A COPY IN THE DOCTOR HELD BY A PARITY TEST. The doctor kept such a +# copy of `plan`'s `origin` regex, and when `plan` changed the copy would +# have sent a person to a line `plan` refuses (#521): a parity test holds +# only the cases it was written for. NOR `scripts/repo_shape.py`, beside +# `url_repository`: that file is a SHAPE COPY every assembly root and family +# holder pins, so a rule added there puts an `upstream-changed +# scripts/repo_shape.py` row in front of every project for code no project's +# own copy calls, in a patch (`scripts/shape_materialize.py` says the same +# of its helpers). This module is in no copy list, and `adopt-project.py` +# and `shape-doctor.py` already import it. + +#: The remote a repository's facts are read from where it has one. +ORIGIN = "origin" + + +def _remote_names(root: Path) -> list[str]: + """The repository's remotes as `git remote` lists them, each decoded as + `adopt-project.py`'s `Source.tree` reads a path (`_git_path`: UTF-8, + a byte that is not UTF-8 kept as a surrogate); none where git cannot + list them.""" + try: + listed = git_out(["remote"], cwd=root, binary=True) + except Refusal: + return [] + return [name.decode("utf-8", "surrogateescape") + for name in listed.splitlines() if name] + + +def remote_exists(root: Path, name: str) -> bool: + """Whether the repository has a remote `name` as `git remote get-url` + reads one: listed by `git remote`, or kept as a file in `.git/branches` + or `.git/remotes`, which it does not list. `name` is a remote these + tools read (`origin`, or one `remotes_of` took), never one that starts + with `-`, which git would read as an option. Asked through `git_out`, + as every git here is: it refuses exactly where git exits non-zero.""" + try: + git_out(["remote", "get-url", name], cwd=root, binary=True) + except Refusal: + return False + return True + + +def printable_ascii(name: str) -> bool: + """Whether `name` is printable ASCII, which every way through can type + in a command line in any locale.""" + return name.isascii() and name.isprintable() + + +def _readable_remote_name(name: str) -> bool: + """Whether `name` may be the repository's remote read in `origin`'s + place: printable ASCII, and not one that starts with `-`, which git + reads as an option (`git remote get-url -x` is an unknown switch, not + the remote).""" + return printable_ascii(name) and not name.startswith("-") + + +def remotes_of(root: Path) -> tuple[str, list[str]]: + """`(remote, passed_over)`: the remote the repository's facts are read + from, `source_remote`'s, and the remotes it has where it has no `origin` + and none is read in that one's place, which is never guessed among; + else `[]`. `adopt-project.py` reads a source with it (`_remotes_of`), + and `shape-doctor.py` reads the repository it is run in with it, so + the doctor's adoption line names what `plan` will read (#1292). + + A SOURCE CLONED UNDER `clone.defaultRemoteName` HAS NO `origin` (#1276). + `git clone` names the remote after that setting, `upstream` say, and + `plan` read only a remote named `origin`: it refused `adopt-no-org` + with no `--org`, read the branch HEAD was on as the default branch, and + asked `--tracking-branch` of the source's own branches, where the same + source with `origin` read the organisation, `origin/HEAD` and + `origin`'s branches. So the source's ONLY remote is read in `origin`'s + place, as git itself reads it (`git ls-remote --get-url`, `git fetch` + with no remote named): the current branch's remote, else the only + remote, else `origin`, measured with git 2.43. + + A SOURCE THAT HAS AN `origin` IS READ AS IT WAS, whatever other remotes + it has: listed by `git remote`, or a file in `.git/branches` or + `.git/remotes`, which `git remote` does not list and `git remote + get-url origin` reads. A source with no remote is read as it was too. + + NOR IS ONE GUESSED AMONG SEVERAL. The current branch's remote is not + asked: it is whichever branch this working copy has checked out, not + the branch adopted, which is read from the remote, and `git switch` + would change the remote it named, the organisation and the default branch + with it, between `plan` and `check`. Nor is `clone.defaultRemoteName`: + git reads it in `git clone` alone, and it is this machine's setting + now, not the source's. A source with several remotes and no `origin` + is read as one with no remote, and `adopt-no-org` and + `plan-tracking-branch-missing` name the remotes passed over + (`adopt-project.py`'s `_passed_over`) and the ways through, which are + no fetch from an `origin` it has not (`_tracking_branch_remediation`); + the doctor's adoption line keeps `--org `. So is a remote + whose name is not printable ASCII, which every way through names in a + command line, in any locale, and so is one that starts with `-`, which + git reads as an option.""" + names = _remote_names(root) + if not names or ORIGIN in names or remote_exists(root, ORIGIN): + return ORIGIN, [] + if len(names) == 1 and _readable_remote_name(names[0]): + return names[0], [] + return ORIGIN, names + + +def source_remote(root: Path) -> str: + """The remote the repository's facts are read from (#1276): `origin` + where it has one, else its only remote, else `origin` (`remotes_of`). + The doctor's `origin_org` and `origin_name` and `repository_names` below + read the url of this one, as `adopt-project.py`'s `_origin_url` does + (#1292).""" + return remotes_of(root)[0] + + def repository_names(root: Path) -> list[str]: """The names this repository goes by: its directory's, then `origin`'s. @@ -511,6 +635,12 @@ def repository_names(root: Path) -> list[str]: to an ancestor otherwise, and an ancestor's name is not this directory's -- the trap `shape-doctor.py`'s `repo_local_origin_name` guards against. + WITH NO `origin`, ITS ONLY REMOTE'S (`source_remote`, #1292), as + `adopt-project.py`'s `_remote_repository` reads it after #1276: a clone + whose only remote is `upstream` and names `Atlas-code` is the code leg + of `Atlas` here as the same clone with `origin` is, and not a single + repository the doctor offers to adopt. + AN ORIGIN URL THAT IS NOT UTF-8 NAMES NOTHING, as no origin names nothing (#385). git keeps a URL as the bytes it was given, so a clone of a directory named in a legacy code page has one, and nobody did anything @@ -537,7 +667,8 @@ def repository_names(root: Path) -> list[str]: names = [root.name] if _is_a(root / ".git", lambda _mode: True): try: - url = git_out(["remote", "get-url", "origin"], cwd=root) + url = git_out(["remote", "get-url", source_remote(root)], + cwd=root) except (Refusal, OSError, UnicodeDecodeError): url = "" name = repository_name(url) if url else None diff --git a/shape-doctor.py b/shape-doctor.py index e44b4ef3..57a87e84 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -11360,9 +11360,20 @@ def origin_name(root: Path) -> str | None: above split off could be a fragment of a name. It is the answer `shape_advisory.repository_names` gives the same URL, so the `naming` row, `the way in` and the `Triad` row read one identity. + + WITH NO `origin`, ITS ONLY REMOTE'S (#1292). `adopt-project.py plan` + reads a source's only remote in `origin`'s place since #1276, and a + clone made under `clone.defaultRemoteName` has no `origin`: this read + `origin` alone, so `the way in` offered `--project WorkUpstream` for a + clone of `testorg/Thing` in `work-upstream`, and `plan` refused that + line `adopt-not-the-source`. The remote is `shape_advisory.source_remote`, + the rule `plan` reads by, not a copy of it: a source with an `origin` is + read as before, and one with several remotes and no `origin`, or whose + one remote's name is not printable ASCII or starts with `-`, has none. """ try: - url = git_out(["remote", "get-url", "origin"], cwd=root) + url = git_out(["remote", "get-url", + shape_advisory.source_remote(root)], cwd=root) except (Refusal, OSError, UnicodeDecodeError): return None if _WINDOWS_PATH_RE.match(url): @@ -11450,11 +11461,19 @@ def origin_org(root: Path) -> str | None: `.git` this user cannot reach RAISES (`carries_git`, #1110), where it was None, the answer for a folder that is no repository: `the way in` asks the `.git` first, and offers no way in over it. + + AND FROM THE REMOTE `plan` READS IT FROM (#1292): `origin`, else the + only remote, else none, by `shape_advisory.source_remote`, which + `adopt-project.py` imports too, so WHICH remote is not a copy either. + Read from `origin` alone, a clone whose only remote is `upstream` was + offered `--org `, and `plan` read `testorg` and refused the + `--project` beside it `adopt-not-the-source`. """ if not carries_git(root): return None try: - url = git_out(["remote", "get-url", "origin"], cwd=root) + url = git_out(["remote", "get-url", + shape_advisory.source_remote(root)], cwd=root) except (Refusal, OSError, UnicodeDecodeError): return None pair = url_repository(url) diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py index 26d962fb..a4bcead4 100644 --- a/tests/test_shape_doctor.py +++ b/tests/test_shape_doctor.py @@ -9813,6 +9813,133 @@ def test_an_ancestors_origin_names_no_org_for_a_loose_directory(standard, assert module.origin_org(loose) is None +#: The repository RV1280 measured on #1280 (S4), and a fork beside it. +THING = "https://github.com/testorg/Thing.git" +FORK = "https://github.com/someone/Fork.git" + + +def with_remotes(path: Path, remotes: list) -> Path: + """A one-commit repository at `path` whose remotes are `remotes`, + `(name, url)` pairs, written with `git config`, as `git clone` under + `clone.defaultRemoteName` writes its one: `git remote add` refuses a + name that starts with `-`, and #1280's review made its `-x` this way.""" + repository(path) + for name, url in remotes: + git("config", f"remote.{name}.url", url, cwd=path) + git("config", f"remote.{name}.fetch", + f"+refs/heads/*:refs/remotes/{name}/*", cwd=path) + return path + + +#: `(remotes, the --project and the organisation the adoption line names)` +#: for a clone in `work-upstream` (#1292). Its only remote is read where it +#: has no `origin`, as `adopt-project.py plan` reads it since #1276; an +#: `origin` is read whatever else it has; and several remotes with no +#: `origin`, or one whose name is not printable ASCII or starts with `-`, +#: are read as none, as `plan` passes them over. +REMOTE_LAYOUTS = [ + pytest.param([("upstream", THING)], "Thing", "testorg", + id="only-upstream"), + pytest.param([("origin", THING)], "Thing", "testorg", id="only-origin"), + pytest.param([("origin", THING), ("fork", FORK)], "Thing", "testorg", + id="origin-and-fork"), + pytest.param([("upstream", THING), ("fork", FORK)], "WorkUpstream", None, + id="two-and-no-origin"), + pytest.param([("-x", THING)], "WorkUpstream", None, id="starts-with-dash"), + pytest.param([("café", THING)], "WorkUpstream", None, + id="not-printable-ascii"), +] + + +@pytest.mark.parametrize("remotes, project, org", REMOTE_LAYOUTS) +def test_the_adoption_line_reads_the_remote_plan_reads(standard, tmp_path, + remotes, project, org): + """#1292 (RV1280 S4 on #1280): `plan` reads a source's only remote in + `origin`'s place, and the doctor read `origin` alone, so for a clone of + `testorg/Thing` whose only remote is `upstream`, in `work-upstream`, + `the way in` said `--project WorkUpstream --org `, which + `plan` refuses `adopt-not-the-source` once `testorg` is filled in. + + The doctor now asks `shape_advisory.source_remote`, the rule `plan` + imports, so the line is the one the same clone with `origin` gets, and + the `naming` row and the Triad advisory read the remote's name beside + the folder's, as they do for `origin`. Every other layout reads as it + did: `origin` whatever else is there, and none where `plan` reads none. + """ + module = doctor_module(standard) + here = with_remotes(tmp_path / "work-upstream", remotes) + row = module.check_the_way_in(module.Context(here)) + adopt = module.SHAPE_ROOT / "adopt-project.py" + line = (f"{module.PYTHON} {module.quote_arg(adopt, os.name)} " + f"plan --source {module.quote_arg(here, os.name)} " + f"--project {module.quote_arg(project, os.name)}") + if org is None: + line += f" --org {module.ORG_PLACEHOLDER}" + assert row.next_command == line, row.next_command + assert row.detail["origin_org"] == org, row.detail + assert row.detail["suggested_project"] == project, row.detail + names = ["work-upstream"] + ([] if org is None else ["Thing"]) + naming = module.check_not_a_root_naming(module.Context(here)) + assert naming.detail["names"] == names, naming.detail + assert module.shape_advisory.repository_names(here) == names + + +@pytest.mark.parametrize("remotes, project, org", REMOTE_LAYOUTS) +def test_plan_takes_the_adoption_line_as_the_doctor_fills_it( + standard, tmp_path, remotes, project, org): + """#1292's parity row: the doctor's adoption line, `` filled + in as its `why` asks, is a line `plan` takes, for every layout. Before + it, the `upstream` clone's line ended `REFUSED adopt-not-the-source: + --org testorg and --project WorkUpstream name testorg/WorkUpstream, and + the source is testorg/Thing`. The organisation the doctor reads is the + one `plan` reads off the same clone (`_remote_repository`), as + `test_the_adoption_line_names_an_org_only_where_plan_reads_none` holds + it for `origin`'s spellings. + """ + module = doctor_module(standard) + here = with_remotes(tmp_path / "work-upstream", remotes) + row = module.check_the_way_in(module.Context(here)) + named = row.detail["suggested_project"] + filled = [] if row.detail["origin_org"] else ["--org", "testorg"] + assert row.next_command.endswith( + f"--project {module.quote_arg(named, os.name)}" + + (f" --org {module.ORG_PLACEHOLDER}" if filled else "")), \ + row.next_command + # `--elected-by` is the person's, which `plan` reads from `git config + # user.name` where the line gives none, and this run has no such name; + # `--out` keeps the plan out of the directory the suite runs in. + result = run_script(standard / "adopt-project.py", "plan", "--source", + str(here), "--project", named, *filled, + "--elected-by", "Test Human", + "--out", str(tmp_path / "adoption-plan.yaml")) + said = result.stdout + result.stderr + assert "adopt-not-the-source" not in said, said + assert result.returncode == 0, said + read = adoption_module(standard)._remote_repository(here) + assert (read or "/").split("/")[0] == (org or ""), read + assert module.origin_org(here) == org + + +@pytest.mark.parametrize("remote", ["origin", "upstream"]) +def test_a_clone_whose_only_remote_names_a_leg_is_offered_no_adoption( + standard, tmp_path, remote): + """#1292: `shape_advisory.repository_names` read `origin` alone, so a + clone of `testorg/Atlas-code` whose only remote is `upstream` was a + single repository to the Triad advisory, and `the way in` offered to + adopt the code leg as a project of its own, a line `plan` refuses + `adopt-not-the-source`. Read from its only remote, it is the leg's row, + with no NEXT, as the same clone with `origin` is. + """ + module = doctor_module(standard) + leg = "https://github.com/testorg/Atlas-code.git" + here = with_remotes(tmp_path / "work", [(remote, leg)]) + assert module.shape_advisory.repository_names(here) == ["work", + "Atlas-code"] + row = module.check_the_way_in(module.Context(here)) + assert row.next_command is None, row.next_command + assert "NAMED OR WRITTEN AS A LEG" in row.reason, row.reason + + def test_the_way_in_row_emits_a_classifying_name_verbatim(standard, tmp_path): """#109: the derivation used to run unconditionally, respelling a name the `naming` row just above this one already classified. From c743b8e53cc87bdc16dca8297cf753a6ecd8078c Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 10 Nov 2026 23:30:59 +0000 Subject: [PATCH 6/8] adopt-project.py: the `--org` help says "two or more remotes", so at argparse's 80 columns `--local-remote-dir` is no longer split at its hyphen and `tests/test_github_name_limits.py` reads the bound where the value is typed again (#1276) CI's `tests` at 5cb1abdbc (run 42711055389, merged with main 8cf6477fe) failed `test_each_direct_tool_says_the_bound_where_the_value_is_typed [adopt-plan]`: #1276's longer `--org` help moved the wrap, and argparse broke `--local-remote-dir` at its hyphen, so the entry read `--local-remote- dir`. The row passes on main alone and failed with this branch's file. The help now says "with two or more remotes and no `origin`", which also says what the two are, and the row passes at 80 columns, the width CI and `scripts/render-cli-reference.py` use. docs/cli.md is regenerated. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 2 +- docs/cli.md | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 6cd49e0a..fd660782 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -20431,7 +20431,7 @@ def main(argv: list[str] | None = None) -> int: "it has no `origin` and exactly one remote, the " "one that remote's url names, unless its name is " "not printable ASCII or starts with `-`). A source " - "with no remote, with two or more and no " + "with no remote, with two or more remotes and no " "`origin`, or whose `origin` (or only remote) is " "a path, names no organisation: `plan` refuses it " "as `adopt-no-org` unless --org is given. " diff --git a/docs/cli.md b/docs/cli.md index a085924c..338c5feb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -497,12 +497,13 @@ options: `origin` url names; where it has no `origin` and exactly one remote, the one that remote's url names, unless its name is not printable ASCII or starts with - `-`). A source with no remote, with two or more and no - `origin`, or whose `origin` (or only remote) is a - path, names no organisation: `plan` refuses it as - `adopt-no-org` unless --org is given. `execute` - refuses one over 39 characters without --local-remote- - dir, GitHub's limit on an organisation's name. + `-`). A source with no remote, with two or more + remotes and no `origin`, or whose `origin` (or only + remote) is a path, names no organisation: `plan` + refuses it as `adopt-no-org` unless --org is given. + `execute` refuses one over 39 characters without + --local-remote-dir, GitHub's limit on an + organisation's name. --id ID lowercase project id (default: --project lowercased). `execute` refuses one over 39 characters without --local-remote-dir: GitHub keeps a topic to 50, and From 47c5479843ad1ee4ce53fcdb3f7191df52d785c3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 10 Nov 2026 23:33:17 +0000 Subject: [PATCH 7/8] tests/test_adopt_source_remote_name.py: a source whose only remote is named by the byte 0xFF is refused `adopt-no-org`, exit 2, as its name is not printable ASCII, so a strict decode of the remotes' names, which ended `plan` in a `UnicodeDecodeError` traceback, exit 1, fails a row (#1276) RV1292's N2: no row held `_remote_names`' `surrogateescape` decode, and the mutant that decodes the names strictly passed all 28 tests. The new row makes the remote with `git config remote.<0xFF>.url`, which `git remote` lists as that byte, and pins the exit, the code and the reason, not how the refusal spells the byte. Under that mutant it fails: exit 1, `UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0`. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_source_remote_name.py | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_adopt_source_remote_name.py b/tests/test_adopt_source_remote_name.py index d5339b68..e94e666c 100644 --- a/tests/test_adopt_source_remote_name.py +++ b/tests/test_adopt_source_remote_name.py @@ -632,3 +632,32 @@ def test_an_only_remote_named_outside_printable_ascii_is_not_read(tmp_path): "tool can read: it has no remote `origin`, and its one remote, " "`Ürsprung`, is not read in that one's place, as its name " "is not printable ASCII") in result.stderr, result.stderr + + +#: A remote's name that is the one byte 0xFF, which is not UTF-8, as +#: `os.fsdecode` keeps it: `git config remote..url` makes such a +#: remote, and `git remote` lists it as that byte. +NOT_UTF_8_REMOTE = chr(0xDCFF) + + +def test_an_only_remote_named_by_a_byte_that_is_not_utf_8_is_not_read( + tmp_path): + """A source whose only remote is named by the byte 0xFF is refused + `adopt-no-org`, exit 2, saying the remote is not read in `origin`'s + place as its name is not printable ASCII. Its name is read as a byte + that is not UTF-8 is kept (`surrogateescape`): read strictly, `plan` + ended in a `UnicodeDecodeError` traceback, exit 1. How the refusal + spells the byte is not pinned here.""" + source = odd_only_remote( + tmp_path, ("config", f"remote.{NOT_UTF_8_REMOTE}.url", GITHUB_URL)) + + result, _ = planned(tmp_path, source) + + assert result.returncode == 2, result.stdout + result.stderr + said = " ".join(result.stderr.split()) + assert ("REFUSED adopt-no-org: the source names no organisation this " + "tool can read: it has no remote `origin`, and its one remote, " + ) in said, said + assert ("is not read in that one's place, as its name is not printable " + "ASCII") in said, said + assert "Traceback" not in result.stderr, result.stderr From 7629d5b84ad15bedb47713bbcc298a11e0f69762 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 11 Nov 2026 00:38:39 +0000 Subject: [PATCH 8/8] tests/test_adopt_source_remote_name.py: a source whose only remote is `upstream`, its `refs/remotes/upstream` at mode 000, is refused `source-unreadable` naming the branches of `upstream`, so main's fixed `origin` words in place of `_branches_untold` fail a row (#1276) RV1280d's SHOULD-1: no row held `_branches_untold`, and the mutant that puts main's fixed words ("the branches of the source's remote `origin`") back passed all 29 tests. The new row clones a source whose only remote is `upstream`, takes `.git/refs/remotes/upstream` to mode 000 and runs `plan`: exit 2, `REFUSED source-unreadable` naming that directory, "so the branches of the source's remote `upstream` cannot be told", and no `origin`. Under that mutant it fails on the `upstream` words. `needs_a_mode` is imported from tests/test_adopt_unsearchable_source.py, as tests/test_adopt_tracking_branch.py does, not defined a second time. adopt-project.py does not change. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_adopt_source_remote_name.py | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_adopt_source_remote_name.py b/tests/test_adopt_source_remote_name.py index e94e666c..8182cb0c 100644 --- a/tests/test_adopt_source_remote_name.py +++ b/tests/test_adopt_source_remote_name.py @@ -33,12 +33,14 @@ from __future__ import annotations import shutil +import stat import sys import pytest from conftest import ADOPT, FILE_PROTOCOL, REPO, git, make_source_repo, \ run_script +from test_adopt_unsearchable_source import needs_a_mode sys.path.insert(0, str(REPO / "scripts")) from repo_shape import load_yaml # noqa: E402 @@ -661,3 +663,28 @@ def test_an_only_remote_named_by_a_byte_that_is_not_utf_8_is_not_read( assert ("is not read in that one's place, as its name is not printable " "ASCII") in said, said assert "Traceback" not in result.stderr, result.stderr + + +@needs_a_mode +def test_the_only_remotes_refs_git_cannot_list_are_named_as_its(tmp_path): + """A source whose only remote is `upstream`, its `.git/refs/remotes/ + upstream` at mode 000: `plan` is refused `source-unreadable`, naming + that directory, and says the branches of `upstream` cannot be told. + Main's fixed words named `origin`, a remote the source does not have.""" + source = cloned_as(tmp_path, UPSTREAM) + refs = source / ".git" / "refs" / "remotes" / UPSTREAM + before = stat.S_IMODE(refs.stat().st_mode) + refs.chmod(0o000) + try: + result, out = planned(tmp_path, source) + finally: + refs.chmod(before) + + assert result.returncode == 2, result.stdout + result.stderr + said = " ".join(result.stderr.split()) + assert ("REFUSED source-unreadable: the source's " + "`refs/remotes/upstream`, ") in said, said + assert ("cannot be read (Permission denied), so the branches of the " + "source's remote `upstream` cannot be told") in said, said + assert "`origin`" not in said, said + assert not out.exists()