diff --git a/adopt-project.py b/adopt-project.py index e2310e80..c43a7c9b 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -275,6 +275,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 @@ -3152,11 +3164,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 @@ -3296,14 +3311,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. @@ -3417,10 +3434,70 @@ 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 _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 _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 _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.""" + 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)}") + 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 {why}") + + +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) @@ -3429,7 +3506,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 @@ -3441,14 +3520,31 @@ 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`): `refs/remotes/origin/` 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). @@ -3458,11 +3554,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 @@ -3513,12 +3613,15 @@ def _default_branch(path: Path, instead: str | None = None) -> str: `origin/HEAD` passed over. Each is `source-unreadable` now, naming the directory (`_symbolic_ref`, `_branch_names`), and asked only where git's answer is none, so a source git reads whole costs nothing new.""" - named = _symbolic_ref(path, ORIGIN_HEAD, ORIGIN_BRANCHES) - origins = _branch_under(named, ORIGIN_BRANCHES) + remote = _source_remote(path) if remote is None else remote + named = _symbolic_ref(path, _remote_head(remote), + _remote_branches(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_REFS) branch = origins or head or "main" @@ -3528,35 +3631,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}" @@ -3570,7 +3677,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 @@ -3578,26 +3685,29 @@ 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. + 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). - `origin` IS ASKED AGAIN ONLY WHERE GIT COULD LIST ITS BRANCHES + THE REMOTE IS ASKED AGAIN ONLY WHERE GIT COULD LIST ITS BRANCHES (`_branch_names`, #1219): with the source's `refs/remotes/origin` at mode `--x`, its `origin/HEAD` read and the branch it names fetched, main said `origin` "has no branch ... either" and to ask it again, which changes nothing; that is `source-unreadable` now, naming the directory.""" - if branch not in _branch_names(path, ORIGIN_BRANCHES, branch): - return ("Remediation: its remote `origin` has no branch " + named, typed = _spelled(remote), shlex.quote(remote) + if branch not in _branch_names(path, _remote_branches(remote), branch): + 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, holding: str) -> str | None: @@ -3641,7 +3751,7 @@ def _branch_under(name: str | None, prefix: str) -> str | None: def _branch_names(path: Path, prefix: str, wanted: str | None) -> 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. @@ -3664,14 +3774,26 @@ def _branch_names(path: Path, prefix: str, wanted: str | None) -> list[str]: #: What `source-unreadable` cannot tell of a source whose refs git could not -#: list (#1219), by the refs it was asked for. +#: list (#1219), by the refs it was asked for: its own branches here, and +#: its remote's as `_branches_untold` names them. BRANCHES_UNTOLD = "the source's branches" -ORIGIN_BRANCHES_UNTOLD = "the branches of the source's remote `origin`" + + +def _branches_untold(prefix: str) -> str: + """What `source-unreadable` cannot tell of a source whose refs under + `prefix` git could not list (#1219): its own branches (`BRANCH_REFS`), + or those of the remote whose branches `prefix` holds + (`_remote_branches`), named as `_source_remote` read it, `origin` or the + one read in that one's place (#1276).""" + if prefix == BRANCH_REFS: + return BRANCHES_UNTOLD + remote = prefix.removeprefix("refs/remotes/").removesuffix("/") + return f"the branches of the source's remote {_spelled(remote)}" def _refuse_unlisted_refs(path: Path, *prefixes: str) -> None: """Raises `source-unreadable` where git, asked in the source at `path` - for its refs under one of `prefixes` (`BRANCH_REFS`, `ORIGIN_BRANCHES`), + for its refs under one of `prefixes` (`BRANCH_REFS`, `_remote_branches`), could not list a directory it reads them from (`_unlisted_ref_directory`): the directory named, as a part of the source's refs and whole, with the operating system's reason and the @@ -3688,8 +3810,7 @@ def _refuse_unlisted_refs(path: Path, *prefixes: str) -> None: raise _source_unreadable( f"the source's {_spelled(part.as_posix())}, " f"{_spelled(str(common.joinpath(*part.parts)))}", exc, - BRANCHES_UNTOLD if prefix == BRANCH_REFS - else ORIGIN_BRANCHES_UNTOLD) + _branches_untold(prefix)) def _unlisted_ref_directory(common: Path, prefix: str @@ -5578,15 +5699,74 @@ 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. -ORIGIN_BRANCHES = "refs/remotes/origin/" + + +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. + 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.") + passed = _remotes_of(source.path)[1] + if passed: + return f"{first} {_ways_through_remotes_passed_over(passed)}" + 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.") + + #: How many branches a finding names; past it, the rest are counted. BRANCHES_NAMED = 8 @@ -5596,15 +5776,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)) @@ -5623,7 +5806,9 @@ 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). A BRANCH GIT COULD NOT LIST IS NOT ONE THE SOURCE LACKS (#1219): before it is said, whether git could list each directory of both kinds of refs @@ -5633,10 +5818,13 @@ def _tracking_branch_problem(source: Source, what: str, whose, branches = _branches_of(source) if tracking in branches: return None - _refuse_unlisted_refs(source.path, ORIGIN_BRANCHES, BRANCH_REFS) + _refuse_unlisted_refs(source.path, _remote_branches(source.remote), + BRANCH_REFS) + 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 " @@ -5655,7 +5843,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)]) def _tracking_branch_notes(args, source: Source) -> list[str]: @@ -5675,28 +5863,39 @@ 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. -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) -> 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) -> 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.") def _refuse_a_tracking_branch_the_remote_lacks(urls: dict, tracking: str, - local: bool) -> None: + local: bool, + 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). @@ -5717,6 +5916,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"]) @@ -5748,7 +5949,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 @@ -5933,29 +6135,34 @@ 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 _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) -> 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`).""" + 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, @@ -5997,8 +6204,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 @@ -6202,8 +6409,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: @@ -6238,12 +6446,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: @@ -6762,7 +6979,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 @@ -10920,28 +11137,41 @@ 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," + +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). + `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 " + "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.{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. -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.") +#: split branch, for a source whose remote is `origin`. +SPLIT_BRANCH_REMEDIATION = _split_branch_remediation() #: `execute`'s remediation for `plan-split-branch-taken`. SPLIT_BRANCH_TAKEN_REMEDIATION = ( @@ -11269,9 +11499,11 @@ def _places_check_asks(plan: Plan, source: Source, extracting: bool, source.path, extracting, branch) 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), branch))) return places @@ -11291,7 +11523,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, branch), source) - return found, [SPLIT_BRANCH_REMEDIATION] if found else [] + return found, ([_split_branch_remediation(_fetchable(source))] + if found else []) def _a_leg_may_be_on_disk(urls: dict) -> bool: @@ -17809,7 +18042,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). @@ -19269,9 +19503,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 " @@ -19436,12 +19670,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, @@ -20124,10 +20362,14 @@ 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 " - "source whose `origin` is a path, or that has " - "none, names no organisation: `plan` refuses " - "it as `adopt-no-org` unless --org is given. " + "one a local source's `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 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. " 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 550eb0f7..338c5feb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -494,8 +494,12 @@ 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` + `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 + 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 diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py index 9122f59b..e362edc6 100644 --- a/scripts/shape_advisory.py +++ b/scripts/shape_advisory.py @@ -629,6 +629,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. @@ -636,6 +760,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 @@ -662,7 +792,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 f6c4b40a..534db8a4 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -11559,9 +11559,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): @@ -11649,11 +11660,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_adopt_source_remote_name.py b/tests/test_adopt_source_remote_name.py new file mode 100644 index 00000000..8182cb0c --- /dev/null +++ b/tests/test_adopt_source_remote_name.py @@ -0,0 +1,690 @@ +# 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 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 + +#: 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" +#: 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`") + + +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 + + +@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`, 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) + + 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 = (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 (f"(`git fetch {typed}`), and then `check` is run again.") in \ + checked.stderr, checked.stderr + + +@needs_filter_repo +@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`, + 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, remote) + _, 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 (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() + + +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() + + +@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`, 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", 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) + + 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 (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() + + +@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`, 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 " + 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", [ + ("/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 + # 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, + 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) + _, 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 + + +#: 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, 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, 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) + + 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 + 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 + 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 + `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 + + +#: 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 + + +@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() 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( diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py index 9fd7562e..c55acf67 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.