diff --git a/tests/conftest.py b/tests/conftest.py index df1e4fde..1ddd93cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -267,7 +267,8 @@ def submodule_update_through_git(root: Path, remotes: Path, # suite copies is made, or pushed to, that way today. # `tests/test_no_auto_maintenance.py` holds both scopes to account, and fails a # test file that sets `GIT_CONFIG_COUNT` to a number of its own instead of -# appending (#758). +# appending (#758), or works one out by hand (#835). The one place that does +# is `appended_to_command_scope`, below. #: The two settings, in the order they are appended and written. NO_AUTO_MAINTENANCE = (("gc.auto", "0"), ("maintenance.auto", "false")) @@ -295,6 +296,47 @@ def appended_to_command_scope(environ: Mapping[str, str], return added +#: The two entries a Windows job holds, under keys no config of a machine this +#: runs on sets. +JOB_ENTRIES = [("test.heldone", "one"), ("test.heldtwo", "two")] + + +def put_job_and_session_entries(monkeypatch) -> None: + """The environment holds, for the rest of the test, what the Windows job + sets (`JOB_ENTRIES`) and what the session fixture appends after them (the + two settings of `NO_AUTO_MAINTENANCE`), as a test that reads the + environment at its call finds it. `GIT_CONFIG_PARAMETERS` is taken away + first: git lists its entries after the count's, and a run started under + `git -c` holds some.""" + monkeypatch.delenv("GIT_CONFIG_PARAMETERS", raising=False) + held = appended_to_command_scope({}, JOB_ENTRIES) + for name, value in {**held, **appended_to_command_scope( + held, NO_AUTO_MAINTENANCE)}.items(): + monkeypatch.setenv(name, value) + + +def command_line_entries(listing: str) -> list: + """`key=value` of each entry in the command scope of a `git config + --show-origin --list` listing, in git's order.""" + return [line.split("\t", 1)[1] for line in listing.splitlines() + if line.startswith("command line:")] + + +def command_scope_at(environ: Mapping[str, str], cwd: Path) -> list: + """The entries git reads from its command scope under `environ` laid over + this process's own (`command_line_entries` of `git config --show-origin + --list`, run in `cwd` and read, never written). `GIT_CONFIG_PARAMETERS` + is taken away, as `put_job_and_session_entries` does.""" + laid = {name: value for name, value in os.environ.items() + if name != "GIT_CONFIG_PARAMETERS"} + shown = subprocess.run( + ["git", "config", "--show-origin", "--list"], cwd=str(cwd), + env={**laid, **environ}, capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) + assert shown.returncode == 0, shown.stderr + shown.stdout + return command_line_entries(shown.stdout) + + def home_is_known() -> bool: """Whether git can expand a `~/` path: `HOME` is set, or this is Windows, where Git for Windows finds a home of its own.""" diff --git a/tests/test_adopt_reencoded_files.py b/tests/test_adopt_reencoded_files.py index c363b052..98985e4e 100644 --- a/tests/test_adopt_reencoded_files.py +++ b/tests/test_adopt_reencoded_files.py @@ -423,15 +423,19 @@ def env_steering(tmp_path: Path, steer: dict) -> subprocess.CompletedProcess: @pytest.mark.parametrize("steer", [ - pytest.param({"GIT_ATTR_SOURCE": "HEAD"}, id="git-attr-source-head"), - pytest.param({"GIT_ATTR_SOURCE": EMPTY_TREE}, + pytest.param(lambda: {"GIT_ATTR_SOURCE": "HEAD"}, + id="git-attr-source-head"), + pytest.param(lambda: {"GIT_ATTR_SOURCE": EMPTY_TREE}, id="git-attr-source-the-empty-tree"), - pytest.param(command_scope("attr.tree", EMPTY_TREE), + pytest.param(lambda: command_scope("attr.tree", EMPTY_TREE), id="attr-tree-in-the-command-scope")]) def test_no_tree_this_machine_names_steers_the_question(tmp_path, steer): """The throwaway repository has no commit for `HEAD` to name, and the - empty tree would make git read no rule: neither is inherited.""" - result = env_steering(tmp_path, steer) + empty tree would make git read no rule: neither is inherited. `steer` + is built here, at the test: `command_scope` appends to the entries the + environment holds WHEN IT IS CALLED, and at collection those are not the + session's (#835).""" + result = env_steering(tmp_path, steer()) assert "REFUSED" not in result.stderr, result.stderr assert f"FINDING {REENCODED_FILES}: {KEPT_GROUP}" in result.stderr, \ result.stderr diff --git a/tests/test_clone_just_pushed.py b/tests/test_clone_just_pushed.py index 823e52f3..8e29ac19 100644 --- a/tests/test_clone_just_pushed.py +++ b/tests/test_clone_just_pushed.py @@ -90,15 +90,13 @@ def test_config_and_env_reach_the_one_command_and_nothing_is_stored( config=["-c", "clone.defaultRemoteName=by-config"]) assert_store_read_through_git(by_config, "the clone made with `config`") assert git("remote", cwd=by_config).stdout.split() == ["by-config"] - # APPENDED to what the environment holds, as `command_scope` does in - # `test_adopt_ignored_shape_files.py`: the Windows job sets two entries - # of its own there, and `GIT_CONFIG_COUNT=1` would drop them. - held = int(os.environ.get("GIT_CONFIG_COUNT") or 0) + # APPENDED to what the environment holds (`appended_to_command_scope`): + # the Windows job sets two entries of its own, and `GIT_CONFIG_COUNT=1` + # would drop them. by_env = cloned_just_pushed( remote, tmp_path / "by-env", recurse=False, - env={"GIT_CONFIG_COUNT": str(held + 1), - f"GIT_CONFIG_KEY_{held}": "clone.defaultRemoteName", - f"GIT_CONFIG_VALUE_{held}": "by-env"}) + env=appended_to_command_scope( + os.environ, [("clone.defaultRemoteName", "by-env")])) assert_store_read_through_git(by_env, "the clone made with `env`") assert git("remote", cwd=by_env).stdout.split() == ["by-env"] stored = git("config", "--local", "--list", cwd=by_env).stdout diff --git a/tests/test_command_scopes_built_at_the_test.py b/tests/test_command_scopes_built_at_the_test.py new file mode 100644 index 00000000..51b31d59 --- /dev/null +++ b/tests/test_command_scopes_built_at_the_test.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A command scope a parameter hands a test is built at the test (#835, #818's +second review). + +`appended_to_command_scope(os.environ, ...)` appends to the entries the +environment holds WHEN IT IS CALLED. A `pytest.param(command_scope(...))` is +called at collection, before the session fixture (`no_auto_maintenance`) has +put its `gc.auto=0` and `maintenance.auto=false` in the environment. Laid over +the environment at the test, that count drops the two: on a Linux run git read +only `user.name=Ada Lovelace`, or only `attr.tree=...`. The session's global +file still kept maintenance off, so no test failed. + +Two parameters were built so (`test_adopt_reencoded_files.py`'s `attr.tree` +and `test_family_init_asks_every_value.py`'s `user.name`). Each is a `lambda` +now, called by its test, as `test_adopt_nested_attributes.py` already did. The +parameters are read off the tests' own marks and asked of git, which reads +the command scope and writes none. +""" + +from __future__ import annotations + +import pytest + +from conftest import NO_AUTO_MAINTENANCE, command_scope_at +import test_adopt_reencoded_files as reencoded +import test_family_init_asks_every_value as family_init +from test_adopt_nested_attributes import EMPTY_TREE + +#: The parametrized tests that hand a test a command scope: the module, the +#: test, its parameter, and the entry each param that names one holds. +SITES = { + "attr-tree": (reencoded, + "test_no_tree_this_machine_names_steers_the_question", + "steer", + {"attr-tree-in-the-command-scope": + f"attr.tree={EMPTY_TREE}"}), + "user-name": (family_init, + "test_each_exit_of_the_variables_remedy_goes_on_to_a_holder", + "env", {"unset-it": "user.name=Ada Lovelace"}), +} + + +def params_of(module, name: str, argument: str) -> list: + """The `pytest.param`s a test's `parametrize` of `argument` holds.""" + marks = [mark for mark in getattr(module, name).pytestmark + if mark.name == "parametrize" and mark.args[0] == argument] + assert len(marks) == 1, marks + return marks[0].args[1] + + +@pytest.mark.parametrize("site", SITES) +def test_each_param_is_built_at_the_test_beside_the_sessions_settings( + site, tmp_path): + module, name, argument, entries = SITES[site] + seen = {} + for param in params_of(module, name, argument): + value = param.values[0] + seen[param.id] = command_scope_at( + value() if callable(value) else value, tmp_path) + assert set(entries) <= set(seen), sorted(seen) + for param_id, read in seen.items(): + for key, value in NO_AUTO_MAINTENANCE: + assert f"{key}={value}" in read, (param_id, read) + for param_id, entry in entries.items(): + assert entry in seen[param_id], (param_id, seen[param_id]) diff --git a/tests/test_doctor_legs_members_next.py b/tests/test_doctor_legs_members_next.py index 38daf4fa..1b4b1d9c 100644 --- a/tests/test_doctor_legs_members_next.py +++ b/tests/test_doctor_legs_members_next.py @@ -28,7 +28,9 @@ repositories on disk, which git refuses as a submodule URL by default -- appended at the call to the entries the environment holds (`paste_env`), and never through `GIT_ALLOW_PROTOCOL`, which would override git's `user` policy -for the whole run. +for the whole run. Where it may clone a mount, it also gets the rewrite every +other clone of the suite carries, so that the remote is read through git and +not through its files (`paste(row, cwd, remotes)`, #835). WHAT IS ASSERTED IS WHAT THE COMMAND DOES, and the explanation beside it is read through `why_of` alone, so that moving the ` # ` tail into a field of @@ -50,7 +52,7 @@ import pytest from conftest import (WINDOWS_SKIP, appended_to_command_scope, - cloned_just_pushed, git, rmtree, + cloned_just_pushed, git, insteadof_entries, rmtree, submodule_update_through_git) from test_doctor_gitmodules_not_utf8 import listed_locale from test_shape_doctor import ( # noqa: F401 @@ -60,13 +62,24 @@ ) -def paste_env() -> dict: +def paste_env(remotes: Path | None = None) -> dict: """What every pasted git line runs under: the identity a commit needs (this container has none, and neither does a fresh CI runner), the file protocol for ONE process tree, and no `PYTHONPATH` from the person running the suite -- a template line that needed one from the reader is exactly what these tests are not allowed to depend on. + With `remotes`, the directory the fixtures' bare repositories are in, the + `url..insteadOf` entries every other read of them carries + (`insteadof_entries`, #662) follow the file protocol in the same count: + for the directory resolved, which is how `.gitmodules` records each url. + A mount a pasted `git submodule update --init` clones from a plain path + gets git's LOCAL clone, which links the remote's loose objects one file + at a time (#440) and starts `upload-pack` only to list the refs. With the + rewrite the objects arrive as a pack (`pack-objects`, `index-pack`) that + `upload-pack` reads from the object store, and the clone's `origin` keeps + the plain path. + BUILT AT THE CALL, from the `os.environ` of that moment, and the file protocol is APPENDED to the `GIT_CONFIG_COUNT` entries it holds (`appended_to_command_scope`): the session's own (`no_auto_maintenance` @@ -75,10 +88,12 @@ def paste_env() -> dict: before the session fixture ran, so it held none of those, and its own `GIT_CONFIG_COUNT` of 1 would have dropped the ones the environment did hold (#758).""" + entries = [("protocol.file.allow", "always")] + if remotes is not None: + entries += insteadof_entries(remotes) return {**{k: v for k, v in os.environ.items() if k != "PYTHONPATH"}, **GIT_IDENTITY, - **appended_to_command_scope( - os.environ, [("protocol.file.allow", "always")]), + **appended_to_command_scope(os.environ, entries), "PYTHONDONTWRITEBYTECODE": "1"} @@ -107,13 +122,25 @@ def row_of(standard: Path, root: Path, row_id: str) -> dict: return rows_of(doctor(standard, root, "--json"))[row_id] -def paste(row: dict, cwd: Path) -> subprocess.CompletedProcess: +def remotes_of(fixture: dict) -> Path: + """The directory a session fixture (`scaffolded_here`, `holder`) + scaffolded its bare repositories into.""" + return fixture["base"] / "remotes" + + +def paste(row: dict, cwd: Path, + remotes: Path | None = None) -> subprocess.CompletedProcess: """The row's NEXT exactly as it was printed, in a POSIX shell, from a - directory that is not the root.""" + directory that is not the root, under `paste_env(remotes)`: every + caller that pastes a root made from the fixtures hands `remotes`, as + `remotes_of(holder)` or `remotes_of(scaffolded_here)` on a fixture its + test takes, and `tests/test_doctor_paste_through_git.py` holds each of + them to that.""" cwd.mkdir(exist_ok=True) return subprocess.run(["sh", "-c", row["next"]], cwd=str(cwd), capture_output=True, text=True, encoding="utf-8", - errors="replace", check=False, env=paste_env(), + errors="replace", check=False, + env=paste_env(remotes), stdin=subprocess.DEVNULL) @@ -420,7 +447,7 @@ def test_the_bootstrap_sentence_is_said_only_where_the_script_can_run( "bootstrap-absent", "repo-shape-absent"]) def test_a_pasted_legs_next_exits_zero_and_the_legs_row_is_ok( - standard, project, tmp_path, scenario): + standard, project, scaffolded_here, tmp_path, scenario): """The point of the whole change: paste what the row printed, from somewhere else, and the row is green. Four of the five roots of `CHECKOUT_SCENARIOS`; the detached one has a commit to keep first and is @@ -428,7 +455,7 @@ def test_a_pasted_legs_next_exits_zero_and_the_legs_row_is_ok( made = CHECKOUT_SCENARIOS[scenario](project) row = row_of(standard, project, "legs") assert row["status"] == "FINDING", row - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert "can't open file" not in result.stdout + result.stderr after = row_of(standard, project, "legs") @@ -443,7 +470,7 @@ def test_a_pasted_legs_next_exits_zero_and_the_legs_row_is_ok( @WINDOWS_SKIP def test_the_keep_line_in_the_explanation_keeps_a_detached_commit( - standard, project, tmp_path): + standard, project, scaffolded_here, tmp_path): """The note's own advice is pasted too: run the `git -C branch NAME` it names BEFORE the line, and the commit is on that branch after the leg has moved. Without it the commit is reachable from no branch and @@ -456,7 +483,7 @@ def test_the_keep_line_in_the_explanation_keeps_a_detached_commit( capture_output=True, text=True, check=False, env=paste_env()) assert kept.returncode == 0, kept.stdout + kept.stderr - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, project, "legs")["status"] == "ok" assert "kept" in git("branch", "--contains", made["commit"], @@ -465,11 +492,12 @@ def test_the_keep_line_in_the_explanation_keeps_a_detached_commit( @WINDOWS_SKIP def test_without_the_keep_line_the_detached_commit_is_in_no_branch( - standard, project, tmp_path): + standard, project, scaffolded_here, tmp_path): """The other half of the note, so it is true and not merely printed: the row is cured and the commit is reachable from no branch.""" made = make_detached_past_the_pin(project) - result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere") + result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere", + remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, project, "legs")["status"] == "ok" assert git("branch", "--contains", made["commit"], @@ -483,7 +511,7 @@ def test_without_the_keep_line_the_detached_commit_is_in_no_branch( @WINDOWS_SKIP @pytest.mark.parametrize("mode", ["merge", "rebase", "none"]) def test_a_configured_update_mode_cannot_turn_the_line_into_a_merge( - standard, project, tmp_path, mode): + standard, project, scaffolded_here, tmp_path, mode): """`git submodule update` obeys `submodule..update`: under `merge` or `rebase` the same line WRITES a merge or a rebased commit into the leg and the row stays red, and under `none` it skips the mount, each @@ -508,7 +536,7 @@ def test_a_configured_update_mode_cannot_turn_the_line_into_a_merge( row = row_of(standard, project, "legs") assert row["status"] == "FINDING", row assert "--checkout" in command_of(row), row["next"] - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, project, "legs")["status"] == "ok", ( result.stdout, result.stderr) @@ -530,7 +558,7 @@ def test_a_plain_clones_empty_mounts_are_cured_by_the_pasted_line( row = row_of(standard, plain, "legs") assert row["status"] == "FINDING", row assert command_of(row).endswith("-- spec code"), row["next"] - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert "not initialized" not in result.stdout + result.stderr after = row_of(standard, plain, "legs") @@ -590,7 +618,7 @@ def test_the_lockstep_line_names_the_template_copy_with_its_environment( @WINDOWS_SKIP @pytest.mark.parametrize("own_copy", [True, False]) def test_a_pasted_lockstep_line_runs_and_prints_the_gate_finding( - standard, project, tmp_path, own_copy): + standard, project, scaffolded_here, tmp_path, own_copy): """Pasted from elsewhere, with no `PYTHONPATH` in the environment: the gate runs (the template's line carries what it needs), exits 1, and says which fact is wrong. It is a finding, as it was before the paste -- no @@ -600,7 +628,7 @@ def test_a_pasted_lockstep_line_runs_and_prints_the_gate_finding( git("rm", "-q", "scripts/validate-pins.py", cwd=project) make_gitlink_moved(project) row = row_of(standard, project, "legs") - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(scaffolded_here)) output = result.stdout + result.stderr assert result.returncode == 1, output assert "pin-gitlink-mismatch" in output, output @@ -711,7 +739,7 @@ def test_a_pasted_members_next_with_the_holders_bootstrap_cures_the_row( root = holder_copy(holder, tmp_path) member_unbootstrapped(root) row = row_of(standard, root, "members") - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(holder)) assert result.returncode == 0, result.stdout + result.stderr after = row_of(standard, root, "members") assert after["status"] == "ok", (after, result.stdout, result.stderr) @@ -748,7 +776,7 @@ def test_a_pasted_members_git_line_cures_the_row(standard, holder, tmp_path): holder_without(root, "bootstrap.py") member_unbootstrapped(root) row = row_of(standard, root, "members") - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(holder)) assert result.returncode == 0, result.stdout + result.stderr assert "can't open file" not in result.stdout + result.stderr after = row_of(standard, root, "members") @@ -798,7 +826,7 @@ def test_a_pasted_members_git_line_keeps_a_detached_commit_on_the_kept_branch( assert keep, why_of(row) subprocess.run(["sh", "-c", keep.group(0).replace("NAME", "kept")], capture_output=True, check=True, env=paste_env()) - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(holder)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, root, "members")["status"] == "ok" assert "kept" in git("branch", "--contains", commit, cwd=member).stdout @@ -976,7 +1004,7 @@ def test_a_pasted_family_gate_line_runs_and_prints_its_finding( make_member_pin_not_a_commit(root) git("rm", "-q", "scripts/validate-family.py", cwd=root) row = row_of(standard, root, "members") - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(holder)) output = result.stdout + result.stderr assert result.returncode in (1, 2), output assert "can't open file" not in output @@ -1113,14 +1141,14 @@ def test_413a_a_kept_repository_on_a_branch_or_at_the_gitlink_is_not_named( @WINDOWS_SKIP def test_413a_the_keep_line_keeps_the_emptied_legs_commit_through_the_paste( - standard, project, tmp_path): + standard, project, scaffolded_here, tmp_path): commit = emptied_with_its_repository_kept(project, "spec") row = row_of(standard, project, "legs") keep = KEPT_LINE.search(why_of(row)) assert keep, why_of(row) kept = sh(keep.group(0).replace("NAME", "kept"), tmp_path / "keep") assert kept.returncode == 0, kept.stdout + kept.stderr - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, project, "legs")["status"] == "ok" assert "kept" in git("branch", "--contains", commit, @@ -1129,9 +1157,10 @@ def test_413a_the_keep_line_keeps_the_emptied_legs_commit_through_the_paste( @WINDOWS_SKIP def test_413a_without_the_keep_line_the_emptied_legs_commit_is_in_no_ref( - standard, project, tmp_path): + standard, project, scaffolded_here, tmp_path): commit = emptied_with_its_repository_kept(project, "spec") - result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere") + result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere", + remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, project, "legs")["status"] == "ok" spec = project / "spec" @@ -1143,7 +1172,7 @@ def test_413a_without_the_keep_line_the_emptied_legs_commit_is_in_no_ref( @WINDOWS_SKIP def test_413a_with_the_mount_directory_gone_the_keep_line_still_runs( - standard, project, tmp_path): + standard, project, scaffolded_here, tmp_path): """The kept repository's own `core.worktree` names the mount, and with that directory gone git refuses every command in the repository, the keep line included (`cannot chdir`, exit 128). The line then carries a @@ -1156,7 +1185,7 @@ def test_413a_with_the_mount_directory_gone_the_keep_line_still_runs( assert "--work-tree=" in keep.group(0), why_of(row) kept = sh(keep.group(0).replace("NAME", "kept"), tmp_path / "keep") assert kept.returncode == 0, kept.stdout + kept.stderr - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, project, "legs")["status"] == "ok" assert "kept" in git("branch", "--contains", commit, @@ -1236,7 +1265,7 @@ def test_413b_the_keep_line_keeps_a_members_commit_through_the_bootstrap( assert "LEG" not in keep.group(0), why_of(row) kept = sh(keep.group(0).replace("NAME", "kept"), tmp_path / "keep") assert kept.returncode == 0, kept.stdout + kept.stderr - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(holder)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, root, "members")["status"] == "ok" assert "kept" in git("branch", "--contains", commit, cwd=member).stdout @@ -1248,7 +1277,8 @@ def test_413b_without_it_the_holders_bootstrap_leaves_the_commit_in_no_ref( root = holder_copy(holder, tmp_path) member = detached_member(root) commit = commit_in(member) - result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere") + result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere", + remotes_of(holder)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, root, "members")["status"] == "ok" assert git("for-each-ref", "--contains", commit, @@ -1301,7 +1331,7 @@ def test_413c_the_leg_keep_line_keeps_a_members_leg_commit_through_the_paste( kept = sh(keep.group(0).replace("LEG", module.quote_arg(leg.resolve())) .replace("NAME", "kept"), tmp_path / "keep") assert kept.returncode == 0, kept.stdout + kept.stderr - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(holder)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, root, "members")["status"] == "ok" assert "kept" in git("branch", "--contains", commit, cwd=leg).stdout @@ -1313,7 +1343,8 @@ def test_413c_without_it_the_members_leg_commit_is_in_no_ref( root = holder_copy(holder, tmp_path) holder_without(root, "bootstrap.py") leg, commit = made_in_a_members_own_leg(root, holder) - result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere") + result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere", + remotes_of(holder)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, root, "members")["status"] == "ok" assert git("for-each-ref", "--contains", commit, @@ -1354,9 +1385,10 @@ def test_414_1_a_mount_with_no_git_whose_repository_is_kept_gets_the_line( @WINDOWS_SKIP def test_414_1_pasted_the_line_links_the_mount_again_and_the_row_is_ok( - standard, project, tmp_path): + standard, project, scaffolded_here, tmp_path): (project / "spec" / ".git").unlink() - result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere") + result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere", + remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr after = row_of(standard, project, "legs") assert after["status"] == "ok", (after, result.stdout, result.stderr) @@ -1398,7 +1430,8 @@ def test_414_1_pasted_the_holders_bootstrap_links_the_member_again( standard, holder, tmp_path): root = holder_copy(holder, tmp_path) (root / "members" / FAMILY_MEMBER / ".git").unlink() - result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere") + result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere", + remotes_of(holder)) assert result.returncode == 0, result.stdout + result.stderr after = row_of(standard, root, "members") assert after["status"] == "ok", (after, result.stdout, result.stderr) @@ -1581,10 +1614,11 @@ def test_668_with_none_in_the_working_tree_the_first_copy_alone_is_read( @WINDOWS_SKIP def test_414_3_pasted_there_the_line_runs_and_the_row_is_ok( - standard, project, tmp_path): + standard, project, scaffolded_here, tmp_path): unbootstrap(project, "spec") (project / ".gitmodules").unlink() - result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere") + result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere", + remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, project, "legs")["status"] == "ok" @@ -1674,7 +1708,8 @@ def test_414_3_there_the_bootstrap_cures_nothing_and_the_git_line_cures_one( assert "git stopped at members/Ghost" in output, output assert "No url found" in output, output assert not any((tried / "members" / FAMILY_MEMBER).iterdir()), output - result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere") + result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere", + remotes_of(holder)) assert result.returncode == 0, result.stdout + result.stderr again = row_of(standard, root, "members") assert again["next"] is None, again @@ -1714,10 +1749,11 @@ def test_414_4_a_leg_the_line_checks_out_with_a_lockstep_fact_says_run_again( @WINDOWS_SKIP def test_414_4_pasted_the_line_runs_and_the_rerun_names_the_pin( - standard, project, tmp_path): + standard, project, scaffolded_here, tmp_path): make_pin_not_a_commit_in_an_emptied_mount(project) module = doctor_module(standard) - result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere") + result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere", + remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr again = row_of(standard, project, "legs") assert again["status"] == "FINDING", again @@ -1781,10 +1817,11 @@ def test_414_3_a_url_the_roots_config_keeps_still_gets_the_line( @WINDOWS_SKIP @pytest.mark.parametrize("how", ["past-the-pin", "emptied-by-rm"]) -def test_414_3_pasted_there_the_line_cures_the_row(standard, project, - tmp_path, how): +def test_414_3_pasted_there_the_line_cures_the_row( + standard, project, scaffolded_here, tmp_path, how): make_url_in_config_only(project, how) - result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere") + result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere", + remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr after = row_of(standard, project, "legs") assert after["status"] == "ok", (after, result.stdout, result.stderr) @@ -1816,7 +1853,8 @@ def test_414_3_pasted_the_holders_bootstrap_cures_that_member( standard, holder, tmp_path): root = holder_copy(holder, tmp_path) member_url_in_config_only(root) - result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere") + result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere", + remotes_of(holder)) assert result.returncode == 0, result.stdout + result.stderr after = row_of(standard, root, "members") assert after["status"] == "ok", (after, result.stdout, result.stderr) @@ -1943,7 +1981,7 @@ def with_a_submodule_in_the_leg(project: Path, tmp_path: Path) -> Path: @WINDOWS_SKIP def test_a_legs_own_submodule_keep_line_keeps_its_commit_through_the_paste( - standard, project, tmp_path): + standard, project, scaffolded_here, tmp_path): commit = with_a_submodule_in_the_leg(project, tmp_path) module = doctor_module(standard) row = row_of(standard, project, "legs") @@ -1954,7 +1992,7 @@ def test_a_legs_own_submodule_keep_line_keeps_its_commit_through_the_paste( "LEG/SUB", module.quote_arg(nested.resolve())).replace( "NAME", "kept"), tmp_path / "keep") assert kept.returncode == 0, kept.stdout + kept.stderr - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, project, "legs")["status"] == "ok" assert "kept" in git("branch", "--contains", commit, cwd=nested).stdout @@ -1962,10 +2000,11 @@ def test_a_legs_own_submodule_keep_line_keeps_its_commit_through_the_paste( @WINDOWS_SKIP def test_without_it_the_legs_own_submodule_commit_is_in_no_ref( - standard, project, tmp_path): + standard, project, scaffolded_here, tmp_path): commit = with_a_submodule_in_the_leg(project, tmp_path) nested = project / "spec" / "vendor" / "lib" - result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere") + result = paste(row_of(standard, project, "legs"), tmp_path / "elsewhere", + remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, project, "legs")["status"] == "ok" assert git("for-each-ref", "--contains", commit, @@ -2036,7 +2075,7 @@ def test_413b_pasted_the_bootstrap_moves_that_member_off_its_commit( assert keep, why_of(row) kept = sh(keep.group(0).replace("NAME", "kept"), tmp_path / "keep") assert kept.returncode == 0, kept.stdout + kept.stderr - paste(row, tmp_path / "elsewhere") + paste(row, tmp_path / "elsewhere", remotes_of(holder)) assert git("rev-parse", "HEAD", cwd=ghost).stdout.strip() != stranded holding = git("for-each-ref", "--contains", stranded, "--format=%(refname:short)", cwd=ghost).stdout.split() @@ -2160,7 +2199,8 @@ def test_pasted_there_the_git_line_fills_the_member_the_bootstrap_would_not( names Ghost alone, for the gate.""" root = holder_copy(holder, tmp_path) beside_a_member_git_finds_no_url_for(root, section, at_pin) - result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere") + result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere", + remotes_of(holder)) assert result.returncode == 0, result.stdout + result.stderr assert any((root / "members" / FAMILY_MEMBER).iterdir()) after = row_of(standard, root, "members") @@ -2225,7 +2265,7 @@ def spelled_filled(module, repository: Path, sub: str, root: Path) -> str: @WINDOWS_SKIP def test_an_emptied_legs_own_submodule_is_kept_through_its_repository( - standard, project, tmp_path): + standard, project, scaffolded_here, tmp_path): """`deinit` empties the leg and takes its submodule's directory with it, so `git -C LEG/SUB branch NAME` has nowhere to run and exits 128. The repository git kept for the leg keeps its submodule's under @@ -2242,7 +2282,7 @@ def test_an_emptied_legs_own_submodule_is_kept_through_its_repository( kept = sh(spelled_filled(module, kept_repository(resolved, "spec"), "vendor/lib", resolved), tmp_path / "keep") assert kept.returncode == 0, kept.stdout + kept.stderr - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, project, "legs")["status"] == "ok" nested = project / "spec" / "vendor" / "lib" @@ -2271,7 +2311,7 @@ def test_an_emptied_members_own_leg_is_kept_through_its_repository( kept = sh(spelled_filled(module, repository, "spec", resolved), tmp_path / "keep") assert kept.returncode == 0, kept.stdout + kept.stderr - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(holder)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, root, "members")["status"] == "ok" assert git("rev-parse", "HEAD", cwd=leg).stdout.strip() != commit @@ -2280,7 +2320,7 @@ def test_an_emptied_members_own_leg_is_kept_through_its_repository( @WINDOWS_SKIP def test_a_leg_past_a_commit_that_dropped_its_submodule_still_names_it( - standard, project, tmp_path): + standard, project, scaffolded_here, tmp_path): """The leg walks past its pin at a commit that removes its submodule, `.gitmodules` included, so its working tree carries none. The gitlink's commit still mounts it, and `--recursive` links the repository the leg @@ -2304,7 +2344,7 @@ def test_a_leg_past_a_commit_that_dropped_its_submodule_still_names_it( kept = sh(spelled_filled(module, repository, "vendor/lib", resolved), tmp_path / "keep") assert kept.returncode == 0, kept.stdout + kept.stderr - result = paste(row, tmp_path / "elsewhere") + result = paste(row, tmp_path / "elsewhere", remotes_of(scaffolded_here)) assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, project, "legs")["status"] == "ok" nested = spec / "vendor" / "lib" diff --git a/tests/test_doctor_paste_through_git.py b/tests/test_doctor_paste_through_git.py new file mode 100644 index 00000000..ade99201 --- /dev/null +++ b/tests/test_doctor_paste_through_git.py @@ -0,0 +1,271 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A line the doctor tests paste reads a just-pushed remote through git, as +every other clone of the suite does (#835). + +`paste()` runs a row's NEXT exactly as printed, and the NEXT of a `legs` or +`members` row is `git -C submodule update --init --recursive +--checkout -- `, which CLONES a mount that is empty. The URL +`.gitmodules` records for it is the plain path of a bare repository the +fixtures pushed into, so git's clone of it is the LOCAL clone: it links the +remote's `objects/` one file at a time, which is the walk #440's race needs +(#645 keeps maintenance from starting, so nothing breaks today, and #662 puts +every other read of these remotes through git as a second guard). + +WHAT IS MEASURED, on a pasted `submodule update --init` of an empty mount: + +- with no rewrite, the clone's store holds the remote's 8 loose objects, each + with a link count of 2, and no pack. `GIT_TRACE` shows `upload-pack`, which a + local clone starts to list the refs, and so tells the two apart no better + than `git clone` does: what it does not show is `pack-objects`; +- with `read_through_git`'s `url..insteadOf` entries, the clone's + store holds one pack and no loose object, and `GIT_TRACE` shows + `pack-objects` and `index-pack` as well. The clone's `origin`, and the + `submodule..url` the superproject records, keep the plain path. + +Each test here asks one of those of git, and the last holds every `paste` of +`test_doctor_legs_members_next.py` to handing the remotes of a fixture its test +takes: a call of `remotes_of` on `holder` or `scaffolded_here`, not a count of +the arguments. +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest + +from conftest import (NO_AUTO_MAINTENANCE, WINDOWS_SKIP, + assert_read_through_git, assert_store_read_through_git, + command_line_entries, git, insteadof_entries, + put_job_and_session_entries) +from test_doctor_legs_members_next import ( + FAMILY_MEMBER, holder_copy, holder_without, make_empty_mount, + member_unbootstrapped, paste, remotes_of, row_of) +from test_shape_doctor import ( # noqa: F401 + holder, project, scaffolded_here, standard) + +#: What `GIT_TRACE` prints for a child git runs: the line names the command +#: after `run_command:`. +PACK_OBJECTS = re.compile(r"run_command: git pack-objects ") +INDEX_PACK = re.compile(r"run_command: git index-pack ") + + +def assert_the_trace_shows_a_transfer(result) -> None: + """The pasted line fetched a pack: `pack-objects` ran in the remote and + `index-pack` in the clone. `upload-pack` alone is a local clone's ref + listing.""" + assert result.returncode == 0, result.stdout + result.stderr + assert PACK_OBJECTS.search(result.stderr), result.stderr[-2000:] + assert INDEX_PACK.search(result.stderr), result.stderr[-2000:] + + +def assert_no_rewrite_is_stored(root: Path, leg: Path) -> None: + """The rewrite was for the one run: neither the superproject's config + nor the clone's holds an `insteadOf`.""" + for repo in (root, leg): + stored = git("config", "--local", "--list", cwd=repo).stdout + assert "insteadof" not in stored.lower(), stored + + +@WINDOWS_SKIP +def test_a_pasted_update_of_an_empty_mount_reads_the_remote_through_git( + standard, project, scaffolded_here, tmp_path, monkeypatch): + made = make_empty_mount(project) + row = row_of(standard, project, "legs") + monkeypatch.setenv("GIT_TRACE", "1") + result = paste(row, tmp_path / "elsewhere", remotes_of(scaffolded_here)) + assert_the_trace_shows_a_transfer(result) + leg = project / made["paths"][0] + assert_store_read_through_git(leg, f"the {leg.name} leg the line cloned") + plain = git("config", "--file", ".gitmodules", + f"submodule.{leg.name}.url", cwd=project).stdout.strip() + assert git("remote", "get-url", "origin", + cwd=leg).stdout.strip() == plain + assert not plain.startswith("file:"), plain + assert_no_rewrite_is_stored(project, leg) + + +@WINDOWS_SKIP +def test_a_pasted_update_of_a_members_mount_reads_it_and_its_legs_through_git( + standard, holder, tmp_path, monkeypatch): + """`--recursive` clones the member's own legs too, from the plain paths + the member's `.gitmodules` records.""" + root = holder_copy(holder, tmp_path) + holder_without(root, "bootstrap.py") + member_unbootstrapped(root) + row = row_of(standard, root, "members") + monkeypatch.setenv("GIT_TRACE", "1") + result = paste(row, tmp_path / "elsewhere", remotes_of(holder)) + assert_the_trace_shows_a_transfer(result) + assert_read_through_git(root / "members" / FAMILY_MEMBER) + assert_no_rewrite_is_stored(root, root / "members" / FAMILY_MEMBER) + + +@WINDOWS_SKIP +def test_a_paste_without_the_remotes_is_the_local_clone_it_was( + standard, project, tmp_path, monkeypatch): + """The measurement the others stand on: handed no remotes, the same line + leaves the mount's store as the remote's own files, loose and linked, + and `pack-objects` is never run. A test that asked for none of this + would pass on the plain path as it does on the rewrite.""" + made = make_empty_mount(project) + row = row_of(standard, project, "legs") + monkeypatch.setenv("GIT_TRACE", "1") + result = paste(row, tmp_path / "elsewhere") + assert result.returncode == 0, result.stdout + result.stderr + assert not PACK_OBJECTS.search(result.stderr), result.stderr[-2000:] + with pytest.raises(AssertionError, match="loose objects"): + assert_store_read_through_git(project / made["paths"][0], "the leg") + + +@WINDOWS_SKIP +def test_the_rewrite_follows_the_entries_the_environment_holds( + scaffolded_here, tmp_path, monkeypatch): + """The Windows job's two entries and the session's two settings, put in + the environment after the doctor tests were imported, come first; then + `protocol.file.allow`; then each `insteadOf` entry `read_through_git` + would add, in its order. (`put_job_and_session_entries` puts the first + four there.)""" + put_job_and_session_entries(monkeypatch) + remotes = remotes_of(scaffolded_here) + shown = paste({"next": "git config --show-origin --list"}, + tmp_path / "elsewhere", remotes) + assert shown.returncode == 0, shown.stderr + shown.stdout + + def as_listed(key: str, value: str) -> str: + """git lists the variable name lower-cased, and the URL in the + subsection as it was written.""" + section, _, name = key.rpartition(".") + return f"{section}.{name.lower()}={value}" + + assert command_line_entries(shown.stdout) == [ + "test.heldone=one", "test.heldtwo=two", + *(f"{key}={value}" for key, value in NO_AUTO_MAINTENANCE), + "protocol.file.allow=always", + *(as_listed(key, prefix) + for key, prefix in insteadof_entries(remotes))] + + +#: The session fixtures whose remotes a pasted root was made from. +REMOTE_FIXTURES = ("holder", "scaffolded_here") + + +def remotes_fixture(call: ast.Call, params: frozenset): + """The fixture a `paste` call hands the remotes of: the name `remotes_of` + is called on in its third argument, or its `remotes=`, where that name is + one of `REMOTE_FIXTURES` and a parameter of the function the call is in. + None for anything else: no third argument, `None`, another call, an + expression on the call, or a name the test does not take.""" + given = call.args[2:3] or [keyword.value for keyword in call.keywords + if keyword.arg == "remotes"] + if not given or not isinstance(given[0], ast.Call): + return None + handed = given[0] + if not (isinstance(handed.func, ast.Name) + and handed.func.id == "remotes_of" + and len(handed.args) == 1 and not handed.keywords + and isinstance(handed.args[0], ast.Name)): + return None + fixture = handed.args[0].id + if fixture in REMOTE_FIXTURES and fixture in params: + return fixture + return None + + +def pastes_in(path: Path) -> list: + """Each call of `paste` in the Python file at `path`, as `(line, + fixture)` (`remotes_fixture`), in the order of the lines.""" + found = [] + + def visit(node, params: frozenset) -> None: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + params = frozenset( + arg.arg for arg in (*node.args.posonlyargs, *node.args.args, + *node.args.kwonlyargs)) + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == "paste"): + found.append((node.lineno, remotes_fixture(node, params))) + for child in ast.iter_child_nodes(node): + visit(child, params) + + visit(ast.parse(path.read_bytes()), frozenset()) + return sorted(found, key=lambda each: each[0]) + + +def pastes_without_remotes(path: Path) -> list: + """The line of each `paste` in the file at `path` that is not handed the + remotes of a fixture its test takes (`remotes_fixture`).""" + return [line for line, fixture in pastes_in(path) if fixture is None] + + +def test_every_paste_of_the_doctor_tests_is_handed_the_remotes(): + """`paste(row, cwd, remotes)`: a pasted line is run exactly as printed, + so the rewrite is the only way its clone of a mount is read through git, + and a call that leaves it out is not told apart from one that is by any + test of the line. Every `paste` in `test_doctor_legs_members_next.py` + names the remotes of a fixture its test takes, `remotes_of(holder)` or + `remotes_of(scaffolded_here)`: a count of its arguments would let `None` + through.""" + doctor = (Path(__file__).resolve().parent + / "test_doctor_legs_members_next.py") + assert pastes_in(doctor), ( + "no `paste` call was found: the walk reads the wrong file") + assert pastes_without_remotes(doctor) == [] + + +def test_the_walk_lists_the_pastes_that_are_not_handed_the_remotes(tmp_path): + walked = tmp_path / "walked.py" + walked.write_bytes( + b"def test_a(holder):\n" + b" paste(row, cwd, remotes_of(holder))\n" + b" paste(row, cwd, None)\n" + b" paste(row, cwd)\n") + assert pastes_without_remotes(walked) == [3, 4] + + +#: `paste` calls in a test that takes `holder`, `scaffolded_here` and +#: `tmp_path`, as `(source, what the walk names)`. +HANDS = [ + ("paste(row, cwd)", None), + ("paste(row, cwd, None)", None), + ("paste(row, cwd, remotes=None)", None), + ("paste(row, cwd, remotes_of(holder))", "holder"), + ("paste(row, cwd, remotes=remotes_of(scaffolded_here))", + "scaffolded_here"), + ("x = paste(row_of(a, b, 'legs'), cwd,\n remotes_of(holder))", + "holder"), + ("paste(row, cwd, remotes)", None), + ("paste(row, cwd, other(holder))", None), + ("paste(row, cwd, remotes_of(holder) / 'x')", None), + ("paste(row, cwd, remotes_of(holder, 1))", None), + ("paste(row, cwd, remotes_of(tmp_path))", None), + ("paste(row, cwd, remotes_of(project))", None), +] + + +@pytest.mark.parametrize("hand, expected", HANDS, ids=[ + "without", "none", "keyword-none", "holder", "keyword", "over-two-lines", + "a-name", "another-call", "an-expression", "two-arguments", + "a-parameter-that-is-not-a-remote-fixture", + "a-name-that-is-not-a-remote-fixture"]) +def test_the_walk_names_the_fixture_each_paste_is_handed( + tmp_path, hand, expected): + walked = tmp_path / "walked.py" + walked.write_bytes(("def test_x(holder, scaffolded_here, tmp_path):\n" + " " + hand + "\n").encode("utf-8")) + assert pastes_in(walked) == [(2, expected)] + + +@pytest.mark.parametrize("source, expected", [ + ("pasted(row, cwd)", []), + ("paste(row, cwd, remotes_of(holder))", [(1, None)]), + ("def a(holder):\n def b(scaffolded_here):\n" + " paste(r, c, remotes_of(holder))\n" + " paste(r, c, remotes_of(holder))\n", [(3, None), (4, "holder")]), +], ids=["other-name", "outside-a-function", "the-nearest-function"]) +def test_the_walk_reads_the_function_a_paste_is_in(tmp_path, source, expected): + walked = tmp_path / "walked.py" + walked.write_bytes(source.encode("utf-8")) + assert pastes_in(walked) == expected diff --git a/tests/test_family_add_spellings_remedies.py b/tests/test_family_add_spellings_remedies.py index fe5cb06c..ba8e95a1 100644 --- a/tests/test_family_add_spellings_remedies.py +++ b/tests/test_family_add_spellings_remedies.py @@ -38,7 +38,7 @@ import pytest -from conftest import cloned_just_pushed, git, rmtree +from conftest import appended_to_command_scope, cloned_just_pushed, git, rmtree from test_family import NAME, SPARE, family # noqa: F401 from test_family_add_member_variant import ( OTHER_ROW, OTHER_VARIANT, REMOVE, ROW, VARIANT, VARIANT_SHOWN, @@ -134,10 +134,9 @@ def typed_with_local_remotes(monkeypatch, root: Path, said: str) -> list: alone: a `git submodule update --recursive` the remedy prints may clone a member's legs, whose urls are the fixture's local paths.""" with monkeypatch.context() as patched: - count = int(os.environ.get("GIT_CONFIG_COUNT", "0")) - patched.setenv("GIT_CONFIG_COUNT", str(count + 1)) - patched.setenv(f"GIT_CONFIG_KEY_{count}", "protocol.file.allow") - patched.setenv(f"GIT_CONFIG_VALUE_{count}", "always") + for name, value in appended_to_command_scope( + os.environ, [("protocol.file.allow", "always")]).items(): + patched.setenv(name, value) return typed_as_printed(root, said) diff --git a/tests/test_family_bootstrap_gate_order.py b/tests/test_family_bootstrap_gate_order.py index 57e007b6..fc3a7caa 100644 --- a/tests/test_family_bootstrap_gate_order.py +++ b/tests/test_family_bootstrap_gate_order.py @@ -56,7 +56,8 @@ import pytest -from conftest import clone_just_pushed, git, run_script +from conftest import (appended_to_command_scope, clone_just_pushed, git, + run_script) from repo_shape import PYTHON from test_family import NAME, SPARE, family, validate # noqa: F401 from test_family_add_refusals import holder, offline # noqa: F401 @@ -131,10 +132,9 @@ def with_config(env: dict, key: str, value: str) -> dict: """`env` with `key` set to `value`, one more `GIT_CONFIG_*` entry after - the ones it already holds, for every git the run starts.""" - index = int(env.get("GIT_CONFIG_COUNT", "0")) - return {**env, "GIT_CONFIG_COUNT": str(index + 1), - f"GIT_CONFIG_KEY_{index}": key, f"GIT_CONFIG_VALUE_{index}": value} + the ones it already holds (`appended_to_command_scope`), for every git + the run starts.""" + return {**env, **appended_to_command_scope(env, [(key, value)])} def fetching(env: dict) -> dict: diff --git a/tests/test_family_config_appends.py b/tests/test_family_config_appends.py new file mode 100644 index 00000000..fe85f22c --- /dev/null +++ b/tests/test_family_config_appends.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The family tests' three helpers that add one `GIT_CONFIG_*` entry to git's +command scope build it with `appended_to_command_scope` (#835). + +`on_disk` (`test_family_head_lost_gitmodules.py`), `with_config` +(`test_family_bootstrap_gate_order.py`) and `typed_with_local_remotes` +(`test_family_add_spellings_remedies.py`) each worked their count out by hand +from the environment's own, and were right for a count the Windows job sets. +`test_no_auto_maintenance.py`'s scan refuses that sum in a test file now, so +none of them repeats it, and these tests hold what each returns: + +- under the two entries the Windows job sets (`GIT_CONFIG_COUNT=2`), `git + config --show-origin --list` lists both, in order, and then the new one; +- under an EMPTY `GIT_CONFIG_COUNT`, which git reads as no entries and + `appended_to_command_scope` counts as none, the new entry is the first: + the sum written out by hand died on `int("")`. +""" + +from __future__ import annotations + +import os + +import pytest + +from conftest import JOB_ENTRIES, appended_to_command_scope, command_scope_at +import test_family_add_spellings_remedies as spellings +from test_family_bootstrap_gate_order import with_config +from test_family_head_lost_gitmodules import on_disk + +#: The two entries a job holds (`JOB_ENTRIES`), and an empty count. Each is +#: built on purpose, to see that the helper keeps what it is given +#: (`OWN_COUNTS` allows this file the two). +HELD = appended_to_command_scope({}, JOB_ENTRIES) +EMPTY = {"GIT_CONFIG_COUNT": ""} + +#: The helpers that take an environment and return it with one more entry: +#: each as a call of `env`, and the `key=value` it adds. `on_disk` is given +#: both values the disks of the family tests (`DISKS`) give it. +ADDERS = { + "on_disk": (lambda env: on_disk(env, "true"), "core.ignorecase=true"), + "on_disk-false": (lambda env: on_disk(env, "false"), + "core.ignorecase=false"), + "with_config": (lambda env: with_config(env, "test.added", "yes"), + "test.added=yes"), +} + +#: What `typed_with_local_remotes` adds. +FILE_ALLOWED = "protocol.file.allow=always" + + +@pytest.mark.parametrize("helper", ADDERS) +def test_a_helper_adds_one_entry_after_the_two_a_job_holds(helper, tmp_path): + adder, added = ADDERS[helper] + assert HELD["GIT_CONFIG_COUNT"] == "2", HELD + env = adder(HELD) + assert env["GIT_CONFIG_COUNT"] == "3", env + assert command_scope_at(env, tmp_path) == [ + "test.heldone=one", "test.heldtwo=two", added] + + +@pytest.mark.parametrize("helper", ADDERS) +def test_a_helper_counts_an_empty_count_as_none(helper): + adder, added = ADDERS[helper] + env = adder(EMPTY) + key, value = added.split("=") + assert (env["GIT_CONFIG_COUNT"], env["GIT_CONFIG_KEY_0"], + env["GIT_CONFIG_VALUE_0"]) == ("1", key, value), env + + +@pytest.mark.parametrize("held", ["two", "empty"]) +def test_the_typing_helper_adds_one_entry_for_the_typing_alone( + held, monkeypatch, tmp_path): + """`typed_with_local_remotes` sets each entry for the typing and puts + the environment back: what git reads while `typed_as_printed` runs is + asked from inside it.""" + monkeypatch.delenv("GIT_CONFIG_PARAMETERS", raising=False) + for name, value in (HELD if held == "two" else EMPTY).items(): + monkeypatch.setenv(name, value) + before = dict(os.environ) + seen = {} + + def typing(root, said): + seen.update(os.environ) + return ["typed"] + + monkeypatch.setattr(spellings, "typed_as_printed", typing) + assert spellings.typed_with_local_remotes( + monkeypatch, tmp_path, "said") == ["typed"] + assert command_scope_at(seen, tmp_path) == ( + ["test.heldone=one", "test.heldtwo=two", FILE_ALLOWED] + if held == "two" else [FILE_ALLOWED]) + assert dict(os.environ) == before diff --git a/tests/test_family_head_lost_gitmodules.py b/tests/test_family_head_lost_gitmodules.py index 50584dc9..09ccbe74 100644 --- a/tests/test_family_head_lost_gitmodules.py +++ b/tests/test_family_head_lost_gitmodules.py @@ -57,7 +57,7 @@ import pytest -from conftest import clone_just_pushed, run_script +from conftest import appended_to_command_scope, clone_just_pushed, run_script from repo_shape import PYTHON #: The family fixture, its names, the offline environment and the readings #: of a holder, imported from the modules that own them rather than rebuilt. @@ -93,11 +93,10 @@ def on_disk(env: dict, ignorecase: str) -> dict: """`env` with `core.ignorecase` set to `ignorecase`, one more - `GIT_CONFIG_*` entry after the ones it already holds.""" - index = int(env.get("GIT_CONFIG_COUNT", "0")) - return {**env, "GIT_CONFIG_COUNT": str(index + 1), - f"GIT_CONFIG_KEY_{index}": "core.ignorecase", - f"GIT_CONFIG_VALUE_{index}": ignorecase} + `GIT_CONFIG_*` entry after the ones it already holds + (`appended_to_command_scope`).""" + return {**env, **appended_to_command_scope( + env, [("core.ignorecase", ignorecase)])} def url(project: str) -> str: diff --git a/tests/test_family_init_asks_every_value.py b/tests/test_family_init_asks_every_value.py index 6f63f5a2..d457468b 100644 --- a/tests/test_family_init_asks_every_value.py +++ b/tests/test_family_init_asks_every_value.py @@ -245,8 +245,8 @@ def test_the_blank_creator_remedy_goes_on_to_a_holder(tmp_path, dry_run): @pytest.mark.parametrize("env", [ - pytest.param({"GIT_AUTHOR_NAME": "Ada Lovelace"}, id="correct-it"), - pytest.param(user_name("Ada Lovelace"), id="unset-it"), + pytest.param(lambda: {"GIT_AUTHOR_NAME": "Ada Lovelace"}, id="correct-it"), + pytest.param(lambda: user_name("Ada Lovelace"), id="unset-it"), ]) @DRY_RUN def test_each_exit_of_the_variables_remedy_goes_on_to_a_holder( @@ -254,8 +254,10 @@ def test_each_exit_of_the_variables_remedy_goes_on_to_a_holder( """THE VARIABLE'S REMEDY, MEASURED: a GIT_AUTHOR_NAME no file can hold is told to correct it, or unset it; a corrected name, and no name with a good `user.name` behind it, are runs that initialise. It no longer says - `--created-by`, which the next test shows leads nowhere.""" - result = init(tmp_path, env, dry_run=dry_run) + `--created-by`, which the next test shows leads nowhere. `env` is built + here, at the test, where `user_name` appends to the entries the + session's fixture has put in the environment (#835).""" + result = init(tmp_path, env(), dry_run=dry_run) initialised(result, tmp_path, dry_run) diff --git a/tests/test_no_auto_maintenance.py b/tests/test_no_auto_maintenance.py index 5e4f5cce..baacf670 100644 --- a/tests/test_no_auto_maintenance.py +++ b/tests/test_no_auto_maintenance.py @@ -26,7 +26,8 @@ - a line pasted through the doctor tests' `paste()` runs under the entries the environment holds when it is pasted, with `protocol.file.allow` appended (#758), and no test file sets `GIT_CONFIG_COUNT` to a number of its own, - which would drop the entries the session and the Windows job set. + which would drop the entries the session and the Windows job set, or works + one out by hand beside `appended_to_command_scope` (#835). """ from __future__ import annotations @@ -40,8 +41,10 @@ import pytest from conftest import (NO_AUTO_MAINTENANCE, ORG, PROJECT, SCAFFOLD, - WINDOWS_SKIP, appended_to_command_scope, git, - home_is_known, run_script, session_global_config) + WINDOWS_SKIP, appended_to_command_scope, + command_line_entries, command_scope_at, git, + home_is_known, put_job_and_session_entries, run_script, + session_global_config) from test_doctor_legs_members_next import paste #: The verbs auto maintenance runs as: `git maintenance run --auto`, and @@ -307,21 +310,12 @@ def test_a_pasted_line_keeps_the_entries_the_environment_holds_at_the_call( `protocol.file.allow` the paste adds for its own sake, and reads the two settings from the session's global file too. On the base it listed that one alone, and no global file: the import-time copy held none of it. - `GIT_CONFIG_PARAMETERS` is taken away first: git lists its entries - after the count's, and a run started under `git -c` holds some.""" - monkeypatch.delenv("GIT_CONFIG_PARAMETERS", raising=False) - held = appended_to_command_scope( - {}, [("test.heldone", "one"), ("test.heldtwo", "two")]) - for name, value in {**held, **appended_to_command_scope( - held, NO_AUTO_MAINTENANCE)}.items(): - monkeypatch.setenv(name, value) + (`put_job_and_session_entries` puts them there.)""" + put_job_and_session_entries(monkeypatch) shown = paste({"next": "git config --show-origin --list"}, tmp_path / "elsewhere") assert shown.returncode == 0, shown.stderr + shown.stdout - from_the_command_line = [ - line.split("\t", 1)[1] for line in shown.stdout.splitlines() - if line.startswith("command line:")] - assert from_the_command_line == [ + assert command_line_entries(shown.stdout) == [ "test.heldone=one", "test.heldtwo=two", *(f"{key}={value}" for key, value in NO_AUTO_MAINTENANCE), "protocol.file.allow=always"] @@ -330,15 +324,37 @@ def test_a_pasted_line_keeps_the_entries_the_environment_holds_at_the_call( shown.stdout.splitlines() +def test_the_helpers_put_git_config_parameters_aside(monkeypatch, tmp_path): + """A run started under `git -c` holds `GIT_CONFIG_PARAMETERS`, which git + lists after the count's entries: the control is git, listing it. The + helper that puts the job's and the session's entries in the environment + takes it away, and so does the one that reads the command scope.""" + monkeypatch.setenv("GIT_CONFIG_PARAMETERS", "'probe.param'='p'") + listed = git("config", "--show-origin", "--list", cwd=tmp_path).stdout + assert "probe.param=p" in command_line_entries(listed) + assert "probe.param=p" not in command_scope_at({}, tmp_path) + put_job_and_session_entries(monkeypatch) + assert "GIT_CONFIG_PARAMETERS" not in os.environ + + #: The files beside this one (every `.py`, `conftest.py` and the helper -#: modules too) that may set a count of their own, and how many times. Each -#: is a test that BUILDS an environment holding entries, to see that the code -#: under test keeps them: this file's test of `appended_to_command_scope` -#: writes its two out, and the test above starts from none; -#: `test_adopt_git_reads.py` and `test_clone_just_pushed.py` do the same for -#: `command_scope` and for `read_through_git`. -OWN_COUNTS = {"test_no_auto_maintenance.py": 2, - "test_adopt_git_reads.py": 1, "test_clone_just_pushed.py": 1} +#: modules too) that may set a count of their own, and how many times: a count +#: written out as a number, worked out (`str(held + 1)`), or counted from +#: nothing (`appended_to_command_scope({}, ...)`). +#: - `conftest.py` works its count out ONCE, in `appended_to_command_scope`: +#: the one place that does, so that no other has to (#835). It counts from +#: nothing once too, for the job's two entries +#: (`put_job_and_session_entries`). +#: - The rest are tests that BUILD an environment holding entries, to see that +#: the code under test keeps them: this file's test of +#: `appended_to_command_scope` writes its two out; +#: `test_adopt_git_reads.py` and `test_clone_just_pushed.py` do the same for +#: `command_scope` and for `read_through_git`, and +#: `test_family_config_appends.py` for the family tests' three helpers, from +#: a job's two entries and from an empty count. +OWN_COUNTS = {"conftest.py": 2, "test_no_auto_maintenance.py": 1, + "test_adopt_git_reads.py": 1, "test_clone_just_pushed.py": 1, + "test_family_config_appends.py": 2} COUNT = "GIT_CONFIG_COUNT" APPENDER = "appended_to_command_scope" @@ -358,11 +374,32 @@ def is_the_count(node, names=frozenset()) -> bool: def is_a_written_count(node) -> bool: """A string constant git reads as a count: digits alone (`"1"`), or nothing (`""`), which git reads as no entries at all (2.43 and 2.47.1). - Constants only: a count built by a call (`str(1)`) is not read.""" + Constants only: a count built by a call (`str(1)`) is not a constant, and + is `is_a_worked_out_count`'s.""" return (isinstance(node, ast.Constant) and isinstance(node.value, str) and (node.value.isdigit() or not node.value)) +def is_a_worked_out_count(node) -> bool: + """A count built where it is set, which is an append done by hand: a + `str(...)` call (`str(held + 1)`), an f-string (`f"{held + 1}"`), a + `.format(...)` call (`"{}".format(held + 1)`), or arithmetic on the way to + a string (`"%d" % (held + 1)`). Read by the node's shape, never evaluated: + a value held in a name (`count`), read by subscript + (`added["GIT_CONFIG_COUNT"]`) or returned by any other call + (`env.get("GIT_CONFIG_COUNT")`) is none of these, and is not read.""" + if isinstance(node, ast.Call): + return ((isinstance(node.func, ast.Name) and node.func.id == "str") + or (isinstance(node.func, ast.Attribute) + and node.func.attr == "format")) + return isinstance(node, (ast.BinOp, ast.JoinedStr)) + + +def sets_a_count_of_its_own(node) -> bool: + """A value that is a number written out, or one worked out.""" + return is_a_written_count(node) or is_a_worked_out_count(node) + + def bound_names(node) -> list: """The plain names an assignment binds: `A = B = ...`, `A: str = ...`.""" if isinstance(node, ast.Assign): @@ -395,19 +432,22 @@ def is_the_appender(node) -> bool: def in_a_dict(node: ast.Dict, names: frozenset) -> list: - """`{"GIT_CONFIG_COUNT": "1"}`: the line of each such key.""" + """`{"GIT_CONFIG_COUNT": "1"}`, `{"GIT_CONFIG_COUNT": str(held + 1)}`: the + line of each such key.""" return [key.lineno for key, value in zip(node.keys, node.values) - if is_the_count(key, names) and is_a_written_count(value)] + if is_the_count(key, names) and sets_a_count_of_its_own(value)] def in_a_call(node: ast.Call, names: frozenset) -> list: """`dict(GIT_CONFIG_COUNT="1")`, `monkeypatch.setenv("GIT_CONFIG_COUNT", - "1")` and `appended_to_command_scope({}, ...)`.""" + "1")` (or `str(held + 1)` for the `"1"`) and + `appended_to_command_scope({}, ...)`.""" lines = [keyword.value.lineno for keyword in node.keywords - if keyword.arg == COUNT and is_a_written_count(keyword.value)] + if keyword.arg == COUNT + and sets_a_count_of_its_own(keyword.value)] if (is_a_setter(node.func) and len(node.args) >= 2 and is_the_count(node.args[0], names) - and is_a_written_count(node.args[1])): + and sets_a_count_of_its_own(node.args[1])): lines.append(node.lineno) if (is_the_appender(node.func) and node.args and isinstance(node.args[0], ast.Dict)): @@ -416,8 +456,8 @@ def in_a_call(node: ast.Call, names: frozenset) -> list: def by_subscript(node: ast.Assign, names: frozenset) -> list: - """`env["GIT_CONFIG_COUNT"] = "1"`.""" - if not is_a_written_count(node.value): + """`env["GIT_CONFIG_COUNT"] = "1"`, or `= str(held + 1)`.""" + if not sets_a_count_of_its_own(node.value): return [] return [target.lineno for target in node.targets if isinstance(target, ast.Subscript) @@ -438,17 +478,33 @@ def counts_set_to_a_number(source: bytes) -> list: gives its value (`monkeypatch.setenv("GIT_CONFIG_COUNT", "1")`), or by subscript (`env["GIT_CONFIG_COUNT"] = "1"`), the variable spelled out or held in a name the module binds to it; + - the same four places, where the value is WORKED OUT + (`is_a_worked_out_count`): `{"GIT_CONFIG_COUNT": str(held + 1)}` is the + append done by hand, counted from the environment's own count and right + today, and `appended_to_command_scope` does it once, for every test + (`OWN_COUNTS` lets `conftest.py` hold that one); - `appended_to_command_scope` handed a dict written out (`{}`), where the environment goes: it counts from nothing, and the entries the environment holds are no longer in the count it returns. - A count worked out from the one the environment holds is not one of - them. The scan reads the count's value as a constant only: a value built - by a call (`str(len(pairs))`), or held in a variable, is not read.""" + The scan reads the SHAPE of the count's value and never evaluates it: a + constant is a number written out, `str(...)`, an f-string, a `.format` + call or arithmetic is one worked out, and a value held in a name, read by + subscript or returned by any other call is not read. The read half of an + append (`int(env.get("GIT_CONFIG_COUNT", "0"))`) sets nothing and is not + one of them; the count it works out is. Nor is a dict that is an operand + of a comparison (`assert added == {"GIT_CONFIG_COUNT": str(held + 1)}`): + it is the value a test expects, and it sets nothing.""" tree = ast.parse(source) names = names_of_the_count(tree) + expected = {id(operand) for node in ast.walk(tree) + if isinstance(node, ast.Compare) + for operand in (node.left, *node.comparators) + if isinstance(operand, ast.Dict)} lines = [] for node in ast.walk(tree): + if id(node) in expected: + continue reader = READERS.get(type(node)) if reader: lines += reader(node, names) @@ -469,27 +525,92 @@ def counts_set_to_a_number(source: bytes) -> list: 'KEY = "GIT_CONFIG_COUNT"; env[KEY] = "2"', 'appended_to_command_scope({}, [("k", "v")])', 'conftest.appended_to_command_scope({"A": "1"}, [])', + '{"GIT_CONFIG_COUNT": str(held + 1)}', + 'dict(GIT_CONFIG_COUNT=str(held + 1))', + 'monkeypatch.setenv("GIT_CONFIG_COUNT", str(held + 1))', + 'monkeypatch.setenv("GIT_CONFIG_COUNT", str(held))', + 'env["GIT_CONFIG_COUNT"] = str(index + 1)', + 'env.setdefault("GIT_CONFIG_COUNT", f"{held + 1}")', + '{"GIT_CONFIG_COUNT": "{}".format(held + 1)}', + '{"GIT_CONFIG_COUNT": "%d" % (held + 1)}', + 'CONFIG_COUNT = "GIT_CONFIG_COUNT"; env[CONFIG_COUNT] = str(held + 1)', + 'assert run(env={"GIT_CONFIG_COUNT": "1"}).returncode == 0', + 'assert run({"GIT_CONFIG_COUNT": str(held + 1)})', + 'sent = {"GIT_CONFIG_COUNT": "1"}; assert env == sent', + 'assert x == {"GIT_CONFIG_COUNT": "1"} or run({"GIT_CONFIG_COUNT": "2"})', ], ids=["dict", "keyword", "setenv", "a-bare-setenv", "setdefault", "putenv", "subscript", "nothing", "a-name-bound-to-it", "an-annotated-name", - "a-name-by-subscript", "appended-to-nothing", "through-a-module"]) + "a-name-by-subscript", "appended-to-nothing", "through-a-module", + "worked-out", "worked-out-keyword", "worked-out-setenv", + "str-in-a-setenv", "worked-out-subscript", "an-f-string", + "a-format-call", "a-percent-format", "worked-out-in-a-bound-name", + "a-dict-in-a-call-in-an-assert", "a-dict-in-an-assert-not-compared", + "a-dict-set-before-the-comparison", "the-dict-that-is-not-compared"]) def test_the_scan_sees_each_way_a_test_can_set_a_count(source): assert counts_set_to_a_number(source.encode("utf-8")) == [1] +#: The three helpers #835 replaced, as they were: each took an environment +#: and returned it with one more entry, counted from the environment's own +#: count, and was right today. The scan names the line of each count. +HAND_WORKED = { + "on_disk": (""" +def on_disk(env, ignorecase): + index = int(env.get("GIT_CONFIG_COUNT", "0")) + return {**env, "GIT_CONFIG_COUNT": str(index + 1), + f"GIT_CONFIG_KEY_{index}": "core.ignorecase", + f"GIT_CONFIG_VALUE_{index}": ignorecase} +""", [4]), + "with_config": (""" +def with_config(env, key, value): + index = int(env.get("GIT_CONFIG_COUNT", "0")) + return {**env, "GIT_CONFIG_COUNT": str(index + 1), + f"GIT_CONFIG_KEY_{index}": key, f"GIT_CONFIG_VALUE_{index}": value} +""", [4]), + "typed_with_local_remotes": (""" +def typed_with_local_remotes(monkeypatch, root, said): + with monkeypatch.context() as patched: + count = int(os.environ.get("GIT_CONFIG_COUNT", "0")) + patched.setenv("GIT_CONFIG_COUNT", str(count + 1)) + patched.setenv(f"GIT_CONFIG_KEY_{count}", "protocol.file.allow") + patched.setenv(f"GIT_CONFIG_VALUE_{count}", "always") + return typed_as_printed(root, said) +""", [5]), +} + + +@pytest.mark.parametrize("name", HAND_WORKED) +def test_the_scan_names_each_helper_that_worked_its_count_out(name): + source, lines = HAND_WORKED[name] + assert counts_set_to_a_number(source.encode("utf-8")) == lines + + @pytest.mark.parametrize("source", [ - '{"GIT_CONFIG_COUNT": str(held + 1)}', '{**held, "GIT_CONFIG_KEY_0": "k"}', - 'monkeypatch.setenv("GIT_CONFIG_COUNT", str(held))', 'index = int(env.get("GIT_CONFIG_COUNT", "0"))', 'env["GIT_CONFIG_COUNT"] = count', + '{"GIT_CONFIG_COUNT": count}', + '{"GIT_CONFIG_COUNT": added["GIT_CONFIG_COUNT"]}', + '{"GIT_CONFIG_COUNT": env.get("GIT_CONFIG_COUNT", "0")}', '"GIT_CONFIG_COUNT is 1"', 'KEY = "GIT_CONFIG_KEY_0"; env = {KEY: "1"}', + '{"GIT_CONFIG_KEY_0": str(held + 1)}', + '{"GIT_CONFIG_COUNT": count_of(env)}', 'appended_to_command_scope(os.environ, [("k", "v")])', 'appended_to_command_scope(held, [("k", "v")])', -], ids=["worked-out", "a-splat", "a-call", "a-read-with-a-default", "a-name", - "a-sentence", "a-name-bound-to-another-key", - "appended-to-the-environment", "appended-to-a-name"]) -def test_the_scan_leaves_a_count_that_is_worked_out(source): + 'assert added == {"GIT_CONFIG_COUNT": str(held + 1), "A": "b"}', + 'assert {"GIT_CONFIG_COUNT": "2"} == added', + 'same = env != {"GIT_CONFIG_COUNT": str(saved)}', +], ids=["a-splat", "a-read-with-a-default", "a-name", "a-name-in-a-dict", + "a-subscript", "a-get-call", "a-sentence", + "a-name-bound-to-another-key", "arithmetic-for-another-key", + "a-call-by-a-bare-name", "appended-to-the-environment", + "appended-to-a-name", "an-expected-dict-worked-out", + "an-expected-dict-on-the-left", "a-dict-compared-with-not-equal"]) +def test_the_scan_leaves_what_it_does_not_read(source): + """A value held in a name, read by subscript or returned by a call that + does not build a string is not read, and nor is a key that is not the + count: the scan names the count's own value, never a guess at one.""" assert counts_set_to_a_number(source.encode("utf-8")) == [] @@ -512,17 +633,35 @@ def test_the_walk_reads_every_python_file_and_holds_each_to_its_count( that is not Python is not read, and a file `OWN_COUNTS` allows a count is named when it sets fewer than that as well as more.""" one = b'{"GIT_CONFIG_COUNT": "1"}\n' - for name, source in {"conftest.py": one, "gh_guard.py": one, - "notes.txt": one, "test_adopt_git_reads.py": one, + for name, source in {"gh_guard.py": one, "notes.txt": one, + "test_adopt_git_reads.py": one, "test_clone_just_pushed.py": b"", "test_no_auto_maintenance.py": one * 3}.items(): (tmp_path / name).write_bytes(source) assert files_off_their_count(tmp_path) == { - "conftest.py": [1], "gh_guard.py": [1], - "test_clone_just_pushed.py": [], + "gh_guard.py": [1], "test_clone_just_pushed.py": [], "test_no_auto_maintenance.py": [1, 2, 3]} +def test_the_walk_lets_conftest_hold_its_two_counts_and_no_one_else_a_sum( + tmp_path): + """`conftest.py` holds the one place a count is worked out + (`appended_to_command_scope`) and the one that counts from nothing + (`put_job_and_session_entries`): a third is named, and so is the file + that loses either, and a helper module that works one out is named too.""" + worked = b'{"GIT_CONFIG_COUNT": str(held + 1)}\n' + from_nothing = b'appended_to_command_scope({}, [])\n' + (tmp_path / "conftest.py").write_bytes(worked + from_nothing) + (tmp_path / "gh_guard.py").write_bytes(worked) + assert files_off_their_count(tmp_path) == {"gh_guard.py": [1]} + (tmp_path / "conftest.py").write_bytes(worked + from_nothing + worked) + assert files_off_their_count(tmp_path)["conftest.py"] == [1, 2, 3] + (tmp_path / "conftest.py").write_bytes(worked) + assert files_off_their_count(tmp_path)["conftest.py"] == [1] + (tmp_path / "conftest.py").write_bytes(b"") + assert files_off_their_count(tmp_path)["conftest.py"] == [] + + def test_no_test_sets_a_git_config_count_of_its_own(): """A `GIT_CONFIG_COUNT` of 1, laid over `os.environ`, replaces the one the session fixture and the Windows job set: the entries it counted are no @@ -530,13 +669,18 @@ def test_no_test_sets_a_git_config_count_of_its_own(): reach the git the test starts. Every test gives git a setting by appending to the entries the environment holds, built at the call by `appended_to_command_scope` (`conftest.py`, which is read too, and - works its count out). Before #758 `PASTE_ENV`, `ALLOW_FILE_PROTOCOL`, + works its count out, once). Before #758 `PASTE_ENV`, `ALLOW_FILE_PROTOCOL`, `NO_MAINTENANCE`, `QUOTE_PATH_OFF`, `SYMLINKS_OFF` and eight one-off - environments each wrote one.""" + environments each wrote one. Before #835 `on_disk`, `with_config`, + `typed_with_local_remotes` and one clone's `env` each worked one out from + the environment's own count: right today, and the next test that did the + same sum would have passed this one.""" found = files_off_their_count(Path(__file__).resolve().parent) assert not found, ( "a `GIT_CONFIG_COUNT` written as a number, or `appended_to_command_" "scope` handed a dict written out, replaces the entries the " - "environment holds; build it with `appended_to_command_scope` and " - "`os.environ` instead, or, for a test that builds an environment " - f"on purpose, change `OWN_COUNTS`: {found}") + "environment holds, and one worked out by hand (`str(held + 1)`) is " + "an append `appended_to_command_scope` already does; build it with " + "`appended_to_command_scope` and `os.environ` instead, or, for a " + "test that builds an environment on purpose, change " + f"`OWN_COUNTS`: {found}")