diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py index 33997df9..1bcdec3a 100755 --- a/scripts/bump-leg.py +++ b/scripts/bump-leg.py @@ -45,9 +45,11 @@ the remote has deleted no longer counts, and moves no local branch of the leg (#898). It carries no refspec of the leg's own that can bring a tag under `origin/`, where it reads branches, so a tag never answers for - one there (#1037). Where a TAG of the remote reaches a commit the leg - holds and no branch does, or the leg has no remote named `origin`, the - refusal says so instead of "never pushed" or "fix the network". + one there (#1037), nor one that puts a branch on another branch's name + there, which failed the fetch where the leg's own works (#1066). Where + a TAG of the remote reaches a commit the leg holds and no branch does, + or the leg has no remote named `origin`, the refusal says so instead of + "never pushed" or "fix the network". * a dirty root. The commit is made with explicit pathspecs, so unrelated edits would not be swept in — but a lockstep commit that cannot be read as exactly one change is the thing the rule exists to keep readable. @@ -1052,14 +1054,15 @@ def on_a_remote_branch(submodule: Path, commit: str) -> bool: def _leg_fetch_refspecs(submodule: Path) -> list[str]: - """The refspecs the leg's fetch carries (#898, #1037): every branch of - the remote, then what the leg's own `remote.origin.fetch` keeps under - `refs/remotes/origin/`, and none of what it excludes, nor one whose - source can name a tag, nor what gives another namespace the names the - branches take there. Which of the leg's are carried is - `shape_materialize`'s `origin_fetch_refspecs`, the rule `family.py`'s - `_origin_fetch_refspecs` asks of a member's mount (#686, #905, #1009); - its docstring says which clause leaves out what, and why. + """The refspecs the leg's fetch carries (#898, #1037, #1066): every + branch of the remote, then what the leg's own `remote.origin.fetch` + keeps under `refs/remotes/origin/`, and none of what it excludes, nor + one whose source can name a tag, nor one onto the names the branches + take there, nor one that puts a branch under another name. Which of the + leg's are carried is `shape_materialize`'s `origin_fetch_refspecs`, the + rule `family.py`'s `_origin_fetch_refspecs` asks of a member's mount + (#686, #905, #1009); its docstring says which clause leaves out what, + and why. WHY THE LEG'S OWN ARE ADDED. The fetch prunes, and `--prune` deletes every ref under `refs/remotes/origin/` that no refspec on the command @@ -1093,18 +1096,42 @@ def _leg_fetch_refspecs(submodule: Path) -> list[str]: under `origin/` is pruned, and the leg's own next `git fetch origin` writes it back. + A REFSPEC THAT PUTS A BRANCH UNDER ANOTHER NAME IS LEFT OUT (#1066), + and so is every one onto the branches' names, whatever its source: a + folder of branches onto them + (`+refs/heads/release/*:refs/remotes/origin/*`), one branch onto + another's (`+refs/heads/release/x:refs/remotes/origin/x`), and every + branch into a folder (`+refs/heads/*:refs/remotes/origin/all/*`, `x` + onto `all/x`). #946's rule carried each, and this fetch with it since + #1037: where the remote had both branches, `x` and `release/x`, the + fetch exited 128 ("Cannot fetch both refs/heads/x and + refs/heads/release/x to refs/remotes/origin/x"), where the leg's own + `git fetch origin` exits 0, so both runs gave the failed-fetch note and + answered from stale refs, through `origin` and through + `--local-remote-dir` (measured, git 2.43). Left out, the first refspec + still brings each such branch under its own name, so the answer loses + nothing; what the leg kept under the other name holds the branch of + that name, or is pruned, until the leg's own next `git fetch origin`. + A NEGATIVE REFSPEC (`^refs/heads/wip`) IS SKIPPED. A narrowing refspec never decides reachability: a positive one that leaves a branch out is overridden by the first refspec, and git applies a configured negative one only to a fetch with no refspec of its own (measured, git 2.43), so the excluded branch is fetched and counts like any other. - WHERE GIT CANNOT SATISFY A REFSPEC OF THE LEG'S OWN, THE FETCH FAILS, - where the fetch of every branch alone worked: one naming a ref the - remote no longer has (`couldn't find remote ref`, as the person's own - `git fetch origin` fails). That exits 128, so the note follows, the - stale-refs sentence after it where the refusal has one, and nothing is - pruned (measured, git 2.43). + WHERE GIT CANNOT SATISFY A REFSPEC OF THE LEG'S OWN THAT IS CARRIED, THE + FETCH FAILS, where the fetch of every branch alone worked. Two ways + remain: one naming a ref the remote no longer has (`couldn't find + remote ref`, as the person's own `git fetch origin` fails), and one + carried into a folder of its own, such as pull requests in + `origin/pr/`, where a branch's name falls in that folder (`pr/7` beside + pull request 7: "Cannot fetch both refs/heads/pr/7 and + refs/pull/7/head"), which the leg's own fetch fails too wherever it has + the branches' glob, and which a leg with no such refspec of its own + fails here alone. That exits 128, so the note follows, the stale-refs + sentence after it where the refusal has one, and nothing is pruned + (measured, git 2.43). A branch put under another name was a third way + until #1066, and is left out now. READ BY A LISTING, `git config --list`, which exits 0 whether or not the leg has a refspec (an `origin` with a URL and nothing else has none, and @@ -1314,9 +1341,10 @@ def fetch_leg(submodule: Path, repository: str, commit: str, (`_leg_fetch_refspecs`), so what its configuration keeps there stays, but for what a refspec it leaves out writes, a tag under `origin/` among it (#1037), which is pruned until the leg's own next `git fetch - origin`. `--no-prune-tags` is belt and braces: git 2.43 applies no - `pruneTags` setting to a fetch with refspecs on its command line - (measured). `origin/HEAD` stays. Offline nothing is pruned. Under + origin`, as is a branch the leg keeps under another name (#1066) where + no branch has that name. `--no-prune-tags` is belt and braces: git 2.43 + applies no `pruneTags` setting to a fetch with refspecs on its command + line (measured). `origin/HEAD` stays. Offline nothing is pruned. Under `--local-remote-dir` the prune deletes the `origin/*` tracking refs the stand-in directory lacks, a branch the leg's real `origin` still has included; that is deliberate, as the fetch has always written that @@ -1332,7 +1360,8 @@ def fetch_leg(submodule: Path, repository: str, commit: str, such a branch failed the fetch with "refusing to fetch into branch". The configured refspecs that write under `origin/` are on the command line already, but for those `_leg_fetch_refspecs` leaves out, whose refs the - prune drops in any case, so nothing else is lost, and the fetch writes + prune drops in any case, or the first refspec writes with the branch of + that name (#1066), so nothing else is lost, and the fetch writes nothing outside `refs/remotes/origin/` and the tags git follows. WHERE IT CANNOT ANSWER, THE REFUSAL SAYS WHY (#898), in the code it had. diff --git a/scripts/family.py b/scripts/family.py index 52aaa6db..db710aba 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -8733,11 +8733,12 @@ def _origin_fetch_refspecs(member: Path) -> list[str]: """The refspecs `bump`'s fetch of `origin` carries (#686): every branch of `origin`, then what the mount's own `remote.origin.fetch` keeps under `refs/remotes/origin/`, and none of what it excludes, nor one whose - source can name a tag, nor what gives another namespace the names the - branches take there (#905, #1009). Which of the mount's are carried is - `shape_materialize`'s `origin_fetch_refspecs`, the one rule - `bump-leg.py` asks of a leg's fetch too (#1037); its docstring says - which clause leaves out what, and why. + source can name a tag, nor one onto the names the branches take there + (#905, #1009), nor one that puts a branch under another name, which + failed this fetch where `git fetch origin` works (#1066). Which of the + mount's are carried is `shape_materialize`'s `origin_fetch_refspecs`, + the one rule `bump-leg.py` asks of a leg's fetch too (#1037); its + docstring says which clause leaves out what, and why. WHY THE MOUNT'S OWN ARE ADDED. `--prune` deletes every ref under `refs/remotes/origin/` that no refspec on the command line maps a ref of @@ -8769,7 +8770,12 @@ def _origin_fetch_refspecs(member: Path) -> list[str]: A mount whose refspec git cannot satisfy, one that names a ref `origin` lacks or two that map to one destination, fails this fetch as it fails the person's own `git fetch origin`, and `bump` says so as it says any - failed fetch. + failed fetch. So does one carried into a folder of its own where a + branch's name falls in that folder (pull requests in `origin/pr/` + beside a branch `pr/7`), which the person's own fetch fails too + wherever the mount has the branches' glob, as `add` leaves it, and + which this fetch alone fails where it has not. A branch put under + another name failed this fetch alone until #1066, and is left out now. READ BY A LISTING, `git config --list -z`, not `--get-all`: the dry run's test holds every `git config` it runs to a listing, the one form diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 451a3d72..81fd2ad6 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -1555,7 +1555,7 @@ def readable_branch(what: str, value) -> str: # --------------------------------------------------------------------------- -# What a fetch for a branch's answer carries (#686, #898, #1037) +# What a fetch for a branch's answer carries (#686, #898, #1037, #1066) # --------------------------------------------------------------------------- # # `family.py bump` fetches a member's `origin` (#686), and `bump-leg.py` a @@ -1580,8 +1580,9 @@ def readable_branch(what: str, value) -> str: ORIGIN_BRANCHES = "+refs/heads/*:refs/remotes/origin/*" #: The names `ORIGIN_BRANCHES` gives the branches (#905): a refspec of the -#: repository's own that gives another namespace these very names is not -#: carried (`origin_fetch_refspecs`). +#: repository's own onto these very names is not carried, whether it gives +#: them another namespace's refs (#905) or a folder of branches (#1066) +#: (`origin_fetch_refspecs`). ORIGIN_BRANCH_NAMES = ORIGIN_BRANCHES.partition(":")[2] #: Where a repository keeps its tags: a refspec of the repository's own @@ -1589,6 +1590,12 @@ def readable_branch(what: str, value) -> str: #: #1009). TAGS_PREFIX = "refs/tags/" +#: Where a repository keeps its branches, the source of `ORIGIN_BRANCHES`: +#: a refspec of the repository's own whose source is here is carried only +#: where it gives each branch the name `ORIGIN_BRANCHES` gives it +#: (`gives_a_branch_another_name`, #1066). +HEADS_PREFIX = "refs/heads/" + def can_name_a_tag(source: str) -> bool: """Whether a fetch refspec's `source` can name a tag of the remote, so @@ -1629,6 +1636,44 @@ def can_name_a_tag(source: str) -> bool: return not source.startswith("refs/") +def gives_a_branch_another_name(source: str, destination: str) -> bool: + """Whether a fetch refspec from `source` to `destination` puts a branch + of the remote under a name `ORIGIN_BRANCHES` does not give it, so that + the fetch does not carry the repository's refspec that has it + (`origin_fetch_refspecs`; #1066). + + A SOURCE UNDER `refs/heads/` NAMES BRANCHES, and the first refspec + brings every branch already, under `refs/remotes/origin/` and its own + name. Such a refspec is carried only where its destination is that very + name, `refs/remotes/origin/` and the source's part after `refs/heads/`, + a pattern's `*` in the same place: one branch + (`+refs/heads/main:refs/remotes/origin/main`) or a folder of them + (`+refs/heads/release/*:refs/remotes/origin/release/*`). It maps each + branch it takes where the first refspec maps it, so it clashes with + nothing, and a branch it names that the remote lacks fails the fetch as + it fails the repository's own (`couldn't find remote ref`). + + ANY OTHER PUTS A BRANCH ON THE NAME OF ANOTHER, the name the first + refspec gives the branch of that name: a folder onto the branches' + names (`+refs/heads/release/*:refs/remotes/origin/*`, `release/x` onto + `x`), one branch onto another's + (`+refs/heads/release/x:refs/remotes/origin/x`), every branch into a + folder (`+refs/heads/*:refs/remotes/origin/all/*`, `x` onto `all/x`), + or a folder into another + (`+refs/heads/release/*:refs/remotes/origin/rel/*`). Carried, each + clashed with the first refspec wherever the remote has both branches: + the fetch exited 128 ("Cannot fetch both refs/heads/x and + refs/heads/release/x to refs/remotes/origin/x") where the repository's + own `git fetch origin`, which has no such first refspec, exits 0 + (measured, git 2.43, each). A source not under `refs/heads/` names no + branch, and is not asked here. + """ + if not source.startswith(HEADS_PREFIX): + return False + return destination != ORIGIN_BRANCH_NAMES.replace( + "*", source[len(HEADS_PREFIX):]) + + def origin_fetch_refspecs(configured: list[str]) -> list[str]: """The refspecs a fetch for a branch's answer carries: `ORIGIN_BRANCHES`, then each value of the repository's own `remote.origin.fetch` in @@ -1644,14 +1689,15 @@ def origin_fetch_refspecs(configured: list[str]) -> list[str]: remote-tracking ref only mirrors the remote, and a refspec without it would fail the fetch on a ref force-pushed. - THREE CLAUSES LEAVE A REFSPEC OUT, asked in this order, and the first + FOUR CLAUSES LEAVE A REFSPEC OUT, asked in this order, and the first that matches is the one that leaves it out: a destination OUTSIDE - `refs/remotes/origin/`, A SOURCE THAT CAN NAME A TAG, and THE BRANCHES' - GLOB. + `refs/remotes/origin/`, A SOURCE THAT CAN NAME A TAG, THE BRANCHES' + GLOB, and A BRANCH UNDER ANOTHER NAME. A DESTINATION OUTSIDE `refs/remotes/origin/`, a mirror's `+refs/*:refs/*` or `+refs/heads/*:refs/heads/*`, is never carried, so no refspec the - fetch names writes outside it. + fetch names writes outside it. The `/` is the clause's: a destination + in `refs/remotes/originx/` is outside it too. A SOURCE THAT CAN NAME A TAG IS LEFT OUT, wherever under `origin/` it writes (#905, #459's U183, RV946's S1, #1009, #1037): a tag is not a @@ -1680,20 +1726,53 @@ def origin_fetch_refspecs(configured: list[str]) -> list[str]: fetch maps, and the repository's own next `git fetch origin` writes it back. A refspec that can name no tag and writes a folder of its own under `origin/` (pull requests, `+refs/pull/*/head: - refs/remotes/origin/pr/*`) still rides along. - - THE BRANCHES' GLOB: ONE THAT GIVES ANOTHER NAMESPACE THE BRANCHES' NAMES - IS LEFT OUT (#905, #459's U183), a refspec whose destination is the - first refspec's own, `refs/remotes/origin/*`, and whose source is not - under `refs/heads/`. The tags onto the branches' names and the mirror - into `origin/` meet it too, and the clause before has left them out - already; this one alone leaves out a source that can name no tag, such - as pull requests onto the branches' names - (`+refs/pull/*/head:refs/remotes/origin/*`). Carried, that clashed with - the first refspec wherever a pull request and a branch share a name - ("Cannot fetch both refs/heads/7 and refs/pull/7/head to - refs/remotes/origin/7") where `git fetch origin` exits 0 (measured, git - 2.43). + refs/remotes/origin/pr/*`) still rides along, unless it puts a branch + under another name (below). + + THE BRANCHES' GLOB: A REFSPEC ONTO IT IS LEFT OUT, WHATEVER ITS SOURCE + (#905, #459's U183, #1066): its destination is the first refspec's own, + `refs/remotes/origin/*`, so each ref it takes lands on the name the + first refspec gives the branch of that name. From another namespace it + gives other refs the branches' names: pull requests + (`+refs/pull/*/head:refs/remotes/origin/*`) clashed with the first + refspec wherever a pull request and a branch share a name ("Cannot + fetch both refs/heads/7 and refs/pull/7/head to + refs/remotes/origin/7"). From a folder of branches it gives a branch + another's name: `+refs/heads/release/*:refs/remotes/origin/*` clashed + wherever the remote has `x` and `release/x` ("Cannot fetch both + refs/heads/x and refs/heads/release/x to refs/remotes/origin/x"). Each + exited 128 where `git fetch origin` exits 0 (measured, git 2.43). #946 + left out only a source not under `refs/heads/`, so the folder of + branches was carried, and failed both tools' fetch, until #1066. The + tags onto the branches' names and the mirror into `origin/` meet this + clause too, and the clause before has left them out already; the + branches' own glob is the first refspec. + + A BRANCH UNDER ANOTHER NAME IS LEFT OUT (#1066): a source under + `refs/heads/` whose destination is not the name the first refspec gives + each branch it takes, such as one branch onto another's + (`+refs/heads/release/x:refs/remotes/origin/x`) or every branch into a + folder (`+refs/heads/*:refs/remotes/origin/all/*`, `x` onto `all/x`). + `gives_a_branch_another_name` says which, and how each clashed. One + that gives each branch its own name, a branch or a folder of them, + still rides along. + + LEFT OUT BY EITHER OF THE LAST TWO, NOTHING IS LOST TO THE ANSWER: the + first refspec brings every branch under its own name, so a commit a + branch of the remote reaches is reached there, and the fetch works. + What the repository kept under another name is pruned, or holds the + branch of that name, until its own next `git fetch origin` writes it + back, as for a tag refspec left out; there a refspec written without + its `+` is refused as non-fast-forward where the two commits are not in + line, as one of the tags onto the branches' names has been since #905 + (measured, git 2.43). + + WHAT CAN STILL CLASH is a refspec carried into a folder of its own from + another namespace, such as the pull requests in `origin/pr/`, where the + remote has a branch whose name falls in that folder (`pr/7` beside pull + request 7). The repository's own fetch clashes there too wherever it + has the branches' glob, as a clone's default refspec is; left out, what + it keeps would be pruned on every run, which is why #686 carries it. """ refspecs = [ORIGIN_BRANCHES] for spec in (value.strip() for value in configured): @@ -1705,9 +1784,10 @@ def origin_fetch_refspecs(configured: list[str]) -> list[str]: continue if can_name_a_tag(source): continue # a tag is not a branch (RV946's S1, #1009, #1037) - if (destination == ORIGIN_BRANCH_NAMES - and not source.startswith("refs/heads/")): - continue # it gives other refs the branches' names (U183) + if destination == ORIGIN_BRANCH_NAMES: + continue # it lands on the branches' own names (U183, #1066) + if gives_a_branch_another_name(source, destination): + continue # the first refspec brings that branch already (#1066) if spec not in refspecs: refspecs.append(spec) return refspecs diff --git a/tests/test_bump_leg_fetch_refspecs.py b/tests/test_bump_leg_fetch_refspecs.py index 2f816561..25d74c18 100644 --- a/tests/test_bump_leg_fetch_refspecs.py +++ b/tests/test_bump_leg_fetch_refspecs.py @@ -29,6 +29,17 @@ is `tests/test_family_bump_fetch_refspecs.py`'s own table, asked here of `_leg_fetch_refspecs` in process: one table holds both tools to one rule. +AND #1066, WHICH THE SHARED RULE CARRIED FROM #946: a refspec of the leg's +own that puts a branch on the name `+refs/heads/*:refs/remotes/origin/*` +gives another, such as a folder of branches onto the branches' names +(`+refs/heads/release/*:refs/remotes/origin/*`). Where the remote has `x` +and `release/x` the fetch exited 128, "Cannot fetch both refs/heads/x and +refs/heads/release/x to refs/remotes/origin/x", where the leg's own `git +fetch origin` exits 0, and both runs gave the failed-fetch note. Every +refspec onto the branches' names is left out now, and every one that puts a +branch under a name not its own; `BRANCH_UNDER_ANOTHER_NAME` is the +member's rows, imported, run here end to end. + Every remote a test writes is the private bare copy `private_remote` points the leg's `origin` at. """ @@ -46,12 +57,14 @@ #: #898's helpers: the private remote, a commit made in the leg, a push, a #: ref's reach, the refusal's two lines in the tag words, and the leg's refs. from test_bump_leg_reachability_words import ( - bare_of, contains, made_in_the_leg, private_remote, pushed, refs, refusal, - runner, tag_reaches, + bare_of, made_in_the_leg, only_a_tag_reaches, private_remote, pushed, refs, + refusal, runner, tag_reaches, +) +#: The member's table of clauses, asked of the leg's fetch below, and its +#: refspecs that put a branch on another's name (#1066). +from test_family_bump_fetch_refspecs import ( + BRANCH_UNDER_ANOTHER_NAME, WHICH_CLAUSE, ) -from test_bump_leg_rollback import snapshot -#: The member's table of clauses, asked of the leg's fetch below. -from test_family_bump_fetch_refspecs import WHICH_CLAUSE sys.path.insert(0, str(REPO / "scripts")) from repo_shape import load_yaml # noqa: E402 @@ -113,35 +126,27 @@ def fetched_as_bump(leg: Path) -> None: "--refmap=", "origin", BRANCHES, cwd=leg) -@TAGS_UNDER_ORIGIN -@pytest.mark.parametrize("via", VIA) -def test_a_branch_and_a_tag_of_one_name_fail_no_fetch_of_the_leg( - project, scaffolded, via, refspec): - """The leg's only refspec puts the tags of its remote under `origin/`, - and the remote has a tag, at the pin, and a branch named where that - refspec puts the tag, holding `--to`. The leg's own `git fetch origin` - exits 0, and a fetch carrying that refspec beside `BRANCHES` exits 128, - "Cannot fetch both", so both runs gave the failed-fetch note and - answered from refs the fetch had not renewed. Left out, both runs fetch, - print no note and pin the commit, and no ref of the leg moves.""" - remotes = private_remote(project, scaffolded) - leg = project / "spec" - pin = git("rev-parse", "HEAD", cwd=leg).stdout.strip() - made = made_in_the_leg(project, "SHARED") - spec = refspec.format(tag=SHARED) - branch = where_the_tag_lands(spec, SHARED) - pushed(project, made, f"refs/heads/{branch}") - git("tag", SHARED, pin, cwd=bare_of(remotes)) - refspecs_only(leg, spec) - fetched_as_bump(leg) +def clashes(leg: Path, refspec: str, branch: str) -> None: + """The premise of a clash row: the leg's own `git fetch origin` works, + and a fetch carrying the leg's `refspec` beside `BRANCHES` exits 128, + "Cannot fetch both", over `origin/`.""" assert git(*FILE_PROTOCOL, "fetch", "--dry-run", "origin", cwd=leg, check=False).returncode == 0, ( "premise: the leg's own fetch works") clash = git(*FILE_PROTOCOL, "fetch", "--dry-run", "origin", BRANCHES, - spec, cwd=leg, check=False) + refspec, cwd=leg, check=False) assert clash.returncode == 128, "premise: the two refspecs clash" assert "Cannot fetch both " in clash.stderr assert f" to {ORIGIN}{branch}" in clash.stderr + + +def both_runs_pin(project: Path, remotes: Path, via: str, pin: str, + made: str) -> None: + """Both runs through `via`, the root branched first: the dry run prints + the move from `pin` to `made`, exit 0, and the real run pins `made`, + each with nothing on stderr (no note that the fetch failed) and no ref + of the leg moved.""" + leg = project / "spec" branched(project) run = runner(project, remotes, via) before = refs(leg) @@ -162,6 +167,58 @@ def test_a_branch_and_a_tag_of_one_name_fail_no_fetch_of_the_leg( assert refs(leg) == before, "no ref of the leg moved (real run)" +@TAGS_UNDER_ORIGIN +@pytest.mark.parametrize("via", VIA) +def test_a_branch_and_a_tag_of_one_name_fail_no_fetch_of_the_leg( + project, scaffolded, via, refspec): + """The leg's only refspec puts the tags of its remote under `origin/`, + and the remote has a tag, at the pin, and a branch named where that + refspec puts the tag, holding `--to`. The leg's own `git fetch origin` + exits 0, and a fetch carrying that refspec beside `BRANCHES` exits 128, + "Cannot fetch both", so both runs gave the failed-fetch note and + answered from refs the fetch had not renewed. Left out, both runs fetch, + print no note and pin the commit, and no ref of the leg moves.""" + remotes = private_remote(project, scaffolded) + leg = project / "spec" + pin = git("rev-parse", "HEAD", cwd=leg).stdout.strip() + made = made_in_the_leg(project, "SHARED") + spec = refspec.format(tag=SHARED) + branch = where_the_tag_lands(spec, SHARED) + pushed(project, made, f"refs/heads/{branch}") + git("tag", SHARED, pin, cwd=bare_of(remotes)) + refspecs_only(leg, spec) + fetched_as_bump(leg) + clashes(leg, spec, branch) + + both_runs_pin(project, remotes, via, pin, made) + + +@BRANCH_UNDER_ANOTHER_NAME +@pytest.mark.parametrize("via", VIA) +def test_a_branch_put_on_another_branchs_name_fails_no_fetch_of_the_leg( + project, scaffolded, via, refspec, taken, shared): + """#1066. The leg's only refspec puts a branch of its remote on the + name `BRANCHES` gives another: `taken`, at the pin, onto `shared`, a + branch holding `--to`. The leg's own `git fetch origin` exits 0, and a + fetch carrying that refspec beside `BRANCHES` exits 128, "Cannot fetch + both", so both runs gave the failed-fetch note and answered from refs + the fetch had not renewed, through the leg's `origin` and through + `--local-remote-dir`. Left out, by THE BRANCHES' GLOB or as A BRANCH + UNDER ANOTHER NAME, both runs fetch, print no note and pin the commit, + and no ref of the leg moves.""" + remotes = private_remote(project, scaffolded) + leg = project / "spec" + pin = git("rev-parse", "HEAD", cwd=leg).stdout.strip() + made = made_in_the_leg(project, "RENAMED") + pushed(project, made, f"refs/heads/{shared}") + pushed(project, pin, f"refs/heads/{taken}") + refspecs_only(leg, refspec) + fetched_as_bump(leg) + clashes(leg, refspec, shared) + + both_runs_pin(project, remotes, via, pin, made) + + @TAGS_UNDER_ORIGIN @pytest.mark.parametrize("via", VIA) def test_a_tag_the_leg_maps_into_origin_does_not_answer_for_a_branch( @@ -175,22 +232,13 @@ def test_a_tag_the_leg_maps_into_origin_does_not_answer_for_a_branch( remote reaches, with nothing in the root moved, and no ref of the leg moves.""" remotes = private_remote(project, scaffolded) - bare = bare_of(remotes) leg = project / "spec" made = made_in_the_leg(project, "TAGGED") tagged(leg, RELEASE, made) pushed(project, f"refs/tags/{RELEASE}", f"refs/tags/{RELEASE}") refspecs_only(leg, refspec.format(tag=RELEASE)) fetched_as_bump(leg) - assert not contains(bare, made, "refs/heads/"), ( - "premise: no branch of the leg's remote reaches it") - assert contains(bare, made, "refs/tags/"), ( - "premise: a tag of the leg's remote does") - assert not contains(leg, made, "refs/remotes/"), ( - "premise: no remote-tracking ref of the leg does") - branched(project) - run = runner(project, remotes, via) - before = snapshot(project) + run, before = only_a_tag_reaches(project, remotes, via, made) leg_refs = refs(leg) stderr = assert_one_refusal(project, run, made, @@ -215,9 +263,10 @@ def test_which_clause_leaves_each_refspec_out_of_the_legs_fetch( carries every branch and, where no clause of `origin_fetch_refspecs` leaves it out, `refspec`, forced. The rows and their ids are the member's (`test_family_bump_fetch_refspecs.py` says which clause each - holds), so a clause either tool gains is asked of both; on main every - row the member leaves out by a tag or the branches' glob was carried - here.""" + holds), so a clause either tool gains is asked of both; at #1037's base + every row the member leaves out by a tag or the branches' glob was + carried here, and until #1066 both tools carried the folder of branches + onto the branches' names and every `another-name` row.""" leg = tmp_path / "leg" git("init", "-q", str(leg), cwd=tmp_path) git("remote", "add", "origin", str(tmp_path / "origin.git"), cwd=leg) diff --git a/tests/test_bump_leg_reachability_words.py b/tests/test_bump_leg_reachability_words.py index 7bdf600c..4ef92881 100644 --- a/tests/test_bump_leg_reachability_words.py +++ b/tests/test_bump_leg_reachability_words.py @@ -135,6 +135,22 @@ def local_only(commit: str) -> tuple[str, str]: "of the world cannot bootstrap.") +def only_a_tag_reaches(project: Path, remotes: Path, via: str, + made: str): + """The premises of a commit `made` only a tag of the leg's remote + reaches, then the root branched: returns `(run, before)`, the runner + through `via` and the root's snapshot. `test_bump_leg_fetch_refspecs.py` + asks it too (#1068's review, NIT 3).""" + assert not contains(bare_of(remotes), made, "refs/heads/"), ( + "premise: no branch of the leg's remote reaches it") + assert contains(bare_of(remotes), made, "refs/tags/"), ( + "premise: a tag of the leg's remote does") + assert not contains(project / "spec", made, "refs/remotes/"), ( + "premise: no remote-tracking ref of the leg does") + branched(project) + return runner(project, remotes, via), snapshot(project) + + def tag_reaches(commit: str) -> tuple[str, str]: """`bump-leg-commit-local-only`'s two lines where a tag of the leg's remote reaches `commit` and no branch does (#898): no claim that nobody @@ -166,15 +182,7 @@ def test_a_commit_only_a_tag_of_the_remote_reaches_is_told_so( made = made_in_the_leg(project, "RELEASE") tag = tagged(leg, "v-release-w898", made) pushed(project, "refs/tags/v-release-w898", "refs/tags/v-release-w898") - assert not contains(bare_of(remotes), made, "refs/heads/"), ( - "premise: no branch of the leg's remote reaches it") - assert contains(bare_of(remotes), made, "refs/tags/"), ( - "premise: a tag of the leg's remote does") - assert not contains(leg, made, "refs/remotes/"), ( - "premise: no remote-tracking ref of the leg does") - branched(project) - run = runner(project, remotes, via) - before = snapshot(project) + run, before = only_a_tag_reaches(project, remotes, via, made) stderr = assert_one_refusal(project, run, made if typed == "commit" else tag, diff --git a/tests/test_family_bump_fetch_refspecs.py b/tests/test_family_bump_fetch_refspecs.py index b9c781e0..ba321dbd 100644 --- a/tests/test_family_bump_fetch_refspecs.py +++ b/tests/test_family_bump_fetch_refspecs.py @@ -41,6 +41,19 @@ `git remote get-url origin` is asked before the fetch now, and a mount with none gets #686's words with no fetch made. +AND ONE STATE #946'S OWN RULE WAS WRONG IN (#1066): A MOUNT WHOSE OWN +REFSPEC PUTS A BRANCH ON ANOTHER'S NAME, a folder of branches onto the +branches' names, `+refs/heads/release/*:refs/remotes/origin/*`. #946 +carried a refspec onto `refs/remotes/origin/*` whose source is under +`refs/heads/`, so where the remote has `x` and `release/x` the fetch exited +128, "Cannot fetch both refs/heads/x and refs/heads/release/x to +refs/remotes/origin/x", where `git fetch origin` exits 0, and `bump` said it +could not fetch the member. Every refspec onto the branches' names is left +out now, whatever its source, and so is every one that puts a branch under +a name not its own, one branch onto another's or every branch into a +folder; one that gives each branch its own name, a branch or a folder of +them, rides along. + EVERY ROW RUNS `bump --dry-run` AND `bump`, and asks the mount's refs before and after each: no ref of the mount moves under either run. To make that a statement about `bump`'s fetch alone, each state is built with the mount's @@ -110,6 +123,22 @@ ids=["tags-onto-branches", "tags-in-a-folder", "mirror-into-origin", "mirror-in-a-folder"]) +#: Each refspec of a mount's own that puts a branch of `origin` on the name +#: `BRANCHES` gives another branch (#1066), with the branch it takes and the +#: branch whose name it puts that one on, and each left out: a folder of +#: branches onto the branches' names (the issue's), by THE BRANCHES' GLOB; +#: one branch onto another's, and every branch into a folder, by A BRANCH +#: UNDER ANOTHER NAME. `bump-leg.py`'s rows for a leg import it +#: (`test_bump_leg_fetch_refspecs.py`). +BRANCH_UNDER_ANOTHER_NAME = pytest.mark.parametrize("refspec, taken, shared", [ + ("+refs/heads/release/*:refs/remotes/origin/*", "release/x-w1066", + "x-w1066"), + ("+refs/heads/release/x-w1066:refs/remotes/origin/x-w1066", + "release/x-w1066", "x-w1066"), + ("+refs/heads/*:refs/remotes/origin/all/*", "x-w1066", "all/x-w1066")], + ids=["folder-onto-branches", "one-branch-onto-another", + "branches-into-a-folder"]) + #: `caf`: Latin-1 e-acute is the one byte 0xE9, which is not UTF-8. It is #: the lone surrogate `os.fsdecode` keeps that byte as, so a byte command line #: hands git the byte itself; spelled with `chr`, as `os.fsdecode` raises on @@ -154,6 +183,20 @@ def refspecs_only(mount: Path, env: dict, *refspecs: str) -> None: raw(mount, env, "config", "--add", "remote.origin.fetch", refspec) +def clashes(mount: Path, env: dict, refspec: str, branch: str) -> None: + """The premise of a clash row: the person's own `git fetch origin` + works, and a fetch carrying the mount's `refspec` beside `BRANCHES` + exits 128, "Cannot fetch both", over `origin/`.""" + assert raw(mount, env, "fetch", "--dry-run", "origin", + check=False).returncode == 0, ( + "premise: the person's own fetch works") + clash = raw(mount, env, "fetch", "--dry-run", "origin", BRANCHES, refspec, + check=False) + assert clash.returncode == 128, "premise: the two refspecs clash" + assert "Cannot fetch both " in clash.stderr.decode() + assert f" to refs/remotes/origin/{branch}" in clash.stderr.decode() + + def both_pin(holder: Path, env: dict, made: str) -> None: """`bump --dry-run --to ` prints the row, exit 0, with nothing on stderr (no note that the fetch failed), then `bump --to ` pins it @@ -240,14 +283,34 @@ def test_tags_mapped_into_origin_fetch_a_branch_and_a_tag_of_one_name( raw(mount, env, "rev-parse", "HEAD").stdout.decode().strip()) refspecs_only(mount, env, refspec) fetched_as_bump(mount, env) - assert raw(mount, env, "fetch", "--dry-run", "origin", - check=False).returncode == 0, ( - "premise: the person's own fetch works") - clash = raw(mount, env, "fetch", "--dry-run", "origin", BRANCHES, refspec, - check=False) - assert clash.returncode == 128, "premise: the two refspecs clash" - assert "Cannot fetch both " in clash.stderr.decode() - assert f" to refs/remotes/origin/{branch}" in clash.stderr.decode() + clashes(mount, env, refspec, branch) + + both_pin(holder, env, made) + + offline.assert_left_nothing() + + +@BRANCH_UNDER_ANOTHER_NAME +def test_a_branch_put_on_another_branchs_name_fails_no_fetch( + holder, offline, tmp_path, refspec, taken, shared): + """#1066. The mount's only refspec puts a branch of `origin` on the name + `bump`'s own refspec gives another: `taken`, at the pin, onto `shared`, + a branch holding `--to`. The person's own `git fetch origin` exits 0, + and a fetch carrying that refspec beside `bump`'s own exits 128, + "Cannot fetch both", so both runs said they could not fetch the member + from its origin. Left out, by THE BRANCHES' GLOB or as A BRANCH UNDER + ANOTHER NAME (`BRANCH_UNDER_ANOTHER_NAME` says which), both runs fetch, + print no note and pin the commit, and no ref of the mount moves.""" + env = isolated(offline, tmp_path) + mount = holder / PATH + made = commit_in_mount( + holder, env, + lambda m: (m / "RENAMED.md").write_bytes(b"a branch, and its name\n")) + raw(mount, env, "push", "-q", "origin", f"{made}:refs/heads/{shared}") + raw(mount, env, "push", "-q", "origin", f"HEAD:refs/heads/{taken}") + refspecs_only(mount, env, refspec) + fetched_as_bump(mount, env) + clashes(mount, env, refspec, shared) both_pin(holder, env, made) @@ -453,11 +516,15 @@ def test_an_ordinary_mount_fetches_the_branch_and_moves_nothing_else( #: `refs/tags/`, `tag-pattern` a pattern that takes the tags too, and, no #: pattern and not under `refs/`, `tag-short-name` a name git completes, #: trying a tag before a branch, and `tag-object-id` an exact object id); -#: THE BRANCHES' GLOB; `carried` where none does. `bump-leg.py`'s fetch of a -#: leg is asked the same rows (`test_bump_leg_fetch_refspecs.py`). +#: THE BRANCHES' GLOB, whatever the source (#1066); A BRANCH UNDER ANOTHER +#: NAME (`another-name`, #1066); `carried` where none does, and `skipped` +#: for a negative refspec, which no clause is asked of. `bump-leg.py`'s +#: fetch of a leg is asked the same rows (`test_bump_leg_fetch_refspecs.py`). WHICH_CLAUSE = pytest.mark.parametrize("refspec, carried", [ pytest.param("+refs/heads/*:refs/heads/*", False, id="outside-heads"), pytest.param("+refs/*:refs/*", False, id="outside-mirror"), + pytest.param("+refs/pull/*/head:refs/remotes/originx/pr/*", False, + id="outside-origin-prefix"), pytest.param(TAGS, False, id="tag-source-onto-branches"), pytest.param("+refs/tags/*:refs/remotes/origin/tags/*", False, id="tag-source-in-a-folder"), @@ -480,11 +547,29 @@ def test_an_ordinary_mount_fetches_the_branch_and_moves_nothing_else( id="tag-object-id"), pytest.param("+refs/pull/*/head:refs/remotes/origin/*", False, id="branches-glob-pull-requests"), + pytest.param("+refs/heads/release/*:refs/remotes/origin/*", False, + id="branches-glob-folder-of-branches"), + pytest.param("refs/heads/release/*:refs/remotes/origin/*", False, + id="branches-glob-folder-of-branches-unforced"), + pytest.param(BRANCHES, False, id="branches-glob-the-first-refspec"), + pytest.param("refs/heads/*:refs/remotes/origin/*", False, + id="branches-glob-the-first-refspec-unforced"), + pytest.param("+refs/heads/release/x:refs/remotes/origin/x", False, + id="another-name-one-branch-onto-another"), + pytest.param("+refs/heads/x:refs/remotes/origin/release/x", False, + id="another-name-one-branch-into-a-folder"), + pytest.param("refs/heads/*:refs/remotes/origin/all/*", False, + id="another-name-branches-in-a-folder"), + pytest.param("+refs/heads/release/*:refs/remotes/origin/rel/*", False, + id="another-name-folder-into-another"), + pytest.param("^refs/heads/x", False, id="skipped-negative"), pytest.param(PULLS, True, id="carried-pull-requests-in-a-folder"), pytest.param("+refs/heads/main:refs/remotes/origin/main", True, id="carried-one-branch"), - pytest.param("refs/heads/*:refs/remotes/origin/all/*", True, - id="carried-branches-in-a-folder"), + pytest.param("+refs/heads/release/*:refs/remotes/origin/release/*", True, + id="carried-branches-in-their-own-folder"), + pytest.param("refs/heads/feat-*:refs/remotes/origin/feat-*", True, + id="carried-branches-of-a-pattern"), pytest.param("+refs/tagsx/*:refs/remotes/origin/tagsx/*", True, id="carried-not-refs-tags"), ]) @@ -504,16 +589,22 @@ def test_which_clause_leaves_each_refspec_out( refspec, asked of `_origin_fetch_refspecs` in process: `bump`'s fetch carries every branch of `origin` and, where no clause leaves it out, `refspec`, forced. A row no later clause meets holds its own: - `tag-source-one-tag` the source under `refs/tags/` (the pattern - `refs/tags/*` is the next way's too), `tag-pattern-mirror-in-a-folder` and - `tag-pattern-refs-t` the pattern, the `tag-short-name` rows the short - name (`heads/main`, which git completes to the branch first, holds that - every short name is left out, as a tag may answer for any of them where - the remote lacks the names git tries first), `tag-object-id` the exact - object id, and `branches-glob-pull-requests` THE BRANCHES' GLOB, which - alone leaves out a source that can name no tag. A `carried` row fails a - clause that takes too much: the pull requests in a folder and a branch, - which #946 carries, and `refs/tagsx/`, which no tag is under.""" + `outside-origin-prefix` the `/` that ends OUTSIDE's `origin/`, as its + source is no branch's (#1068's review, NIT 1), `tag-source-one-tag` the + source under `refs/tags/` (the pattern `refs/tags/*` is the next way's + too), `tag-pattern-mirror-in-a-folder` and `tag-pattern-refs-t` the + pattern, the `tag-short-name` rows the short name (`heads/main`, which + git completes to the branch first, holds that every short name is left + out, as a tag may answer for any of them where the remote lacks the + names git tries first), `tag-object-id` the exact object id, + `branches-glob-pull-requests` THE BRANCHES' GLOB, which alone leaves out + a source that can name no tag, and the `another-name` rows A BRANCH + UNDER ANOTHER NAME (#1066); the folder of branches onto the branches' + names is THE BRANCHES' GLOB's, the first that meets it, and the last + clause's too. A `carried` row fails a clause that takes too much: the + pull requests in a folder, which #946 carries, `refs/tagsx/`, which no + tag is under, and a branch, a folder of them or a pattern of them, each + under the name `BRANCHES` gives it.""" env = isolated(offline, tmp_path) for key, value in env.items(): monkeypatch.setenv(key, value)