diff --git a/tests/conftest.py b/tests/conftest.py index 2606cc21..ea685927 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -226,10 +226,13 @@ def submodule_update_through_git(root: Path, remotes: Path, # the remote, each run `git maintenance run --auto` as they finish. Since git # 2.47 that run DETACHES, holding `objects/maintenance.lock`, and the command # returns while it goes on; since 2.54 its default strategy (`geometric`) -# repacks a repository of a few hundred loose objects and deletes the ones +# repacks once its estimate, taken from `objects/17` alone, is above 256 +# loose objects, so two loose objects there are enough, and deletes the ones # it packed. A copy taken in that moment fails `[Errno 2] No such file or -# directory` on one of them: `holder` and `the_standard_moved`, on the -# Linux runner (#645). +# directory` on one of them (`holder` and `the_standard_moved`, on the +# Linux runner, #645), and a listing of `.git/objects` taken before and +# after it differs: `test_family_bump_remove_rollback.py`'s `objects +# changed`, 37 loose objects packed into two. # # So the session turns it off, as every git command in this estate is told # to: `gc.auto=0`, for a git that runs `git gc --auto` itself (2.43's @@ -262,7 +265,9 @@ def submodule_update_through_git(root: Path, remotes: Path, # `GIT_CONFIG_GLOBAL` of a test's own, or an environment built from nothing # -- and a push made under a global file of a test's own. No repository the # suite copies is made, or pushed to, that way today. -# `tests/test_no_auto_maintenance.py` holds both scopes to account. +# `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). #: The two settings, in the order they are appended and written. NO_AUTO_MAINTENANCE = (("gc.auto", "0"), ("maintenance.auto", "false")) diff --git a/tests/test_adopt_empty_symlinks.py b/tests/test_adopt_empty_symlinks.py index 747e123a..82495d3d 100644 --- a/tests/test_adopt_empty_symlinks.py +++ b/tests/test_adopt_empty_symlinks.py @@ -35,6 +35,7 @@ from __future__ import annotations import importlib.util +import os import re import shutil import subprocess @@ -44,8 +45,8 @@ import pytest -from conftest import ADOPT, as_human, git, make_source_repo, run_script, \ - write_plan +from conftest import (ADOPT, appended_to_command_scope, as_human, git, + make_source_repo, run_script, write_plan) needs_filter_repo = pytest.mark.skipif( shutil.which("git-filter-repo") is None, @@ -68,10 +69,15 @@ #: The issue's small source: a spec, the code, and a README the root keeps. TREE = {"src/app.py": "print(1)\n", "docs/spec.md": "# Spec\n", "README.md": "# Thing\n"} -#: `core.symlinks=false` for every git a run starts, as Git for Windows has it -#: by default. -SYMLINKS_OFF = {"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.symlinks", - "GIT_CONFIG_VALUE_0": "false"} + + +def symlinks_off() -> dict: + """`core.symlinks=false` for every git a run starts, as Git for Windows + has it by default: appended, when it is called, to the git command scope + the environment holds (#758).""" + return appended_to_command_scope(os.environ, [("core.symlinks", "false")]) + + #: The issue's target (#388): 8 bytes, the first of them NUL, so Linux reads #: it as the empty target. NUL_LED = b"\0spec.md" @@ -387,8 +393,8 @@ def test_check_reports_it_where_git_makes_no_symlinks_too(tmp_path, target, so `core.symlinks=false` changes nothing it says.""" plan = planned(source_with_links(tmp_path, "docs/empty-link", target=target), - tmp_path / "plan.yaml", env=SYMLINKS_OFF) - off, on = check(plan, env=SYMLINKS_OFF), check(plan) + tmp_path / "plan.yaml", env=symlinks_off()) + off, on = check(plan, env=symlinks_off()), check(plan) assert off.returncode == on.returncode == 1 assert findings_of(off) == findings_of(on) finding, = findings_of(off) @@ -461,21 +467,22 @@ def test_execute_refuses_it_before_any_leg_exists( @needs_filter_repo @with_each_target -@pytest.mark.parametrize("env", [ - pytest.param(None, id="symlinks-on"), - pytest.param(SYMLINKS_OFF, id="symlinks-off"), +@pytest.mark.parametrize("off", [ + pytest.param(False, id="symlinks-on"), + pytest.param(True, id="symlinks-off"), ]) @pytest.mark.parametrize("link, answers", [ pytest.param("docs/empty-link", {}, id="in-docs"), pytest.param("empty-link", {"empty-link": "drop"}, id="top-level-drop"), ]) def test_execute_refuses_it_end_to_end_and_the_remote_dir_stays_empty( - tmp_path, env, link, answers, target, words): + tmp_path, off, link, answers, target, words): """The issue's runs as commands: on main, both leg repositories and exit 2 (symlinks on; with nothing said, until #171's fix said what failed), or `adoption verified` over a leg no clone with symlinks on can check out (symlinks off). Refused before a leg is made, so the EMPTY `--local-remote-dir` stays empty.""" + env = symlinks_off() if off else None source = source_with_links(tmp_path, link, target=target) plan = planned(source, tmp_path / "plan.yaml", answers, env=env) (tmp_path / REMOTES).mkdir() @@ -502,16 +509,17 @@ def test_the_link_removed_in_the_source_adopts_end_to_end(tmp_path): @needs_filter_repo -@pytest.mark.parametrize("env", [ - pytest.param(None, id="symlinks-on", marks=needs_disk_symlinks), - pytest.param(SYMLINKS_OFF, id="symlinks-off"), +@pytest.mark.parametrize("off", [ + pytest.param(False, id="symlinks-on", marks=needs_disk_symlinks), + pytest.param(True, id="symlinks-off"), ]) -def test_the_link_given_a_target_adopts_end_to_end(tmp_path, env): +def test_the_link_given_a_target_adopts_end_to_end(tmp_path, off): """The other remediation, and #388's control: a target that names something (`spec.md`, 7 bytes), read now, is no finding. With symlinks on, `execute`'s clones check the link out, so this needs a platform that makes symlinks; with them off, the clones hold it as a file. Either way the leg holds the link.""" + env = symlinks_off() if off else None source = source_with_links(tmp_path, "docs/empty-link") put_link(source, "docs/empty-link", NAMED) as_human("commit", "-qm", "Give the link a target", cwd=source) diff --git a/tests/test_adopt_git_reads.py b/tests/test_adopt_git_reads.py index 144544e3..3fc21136 100644 --- a/tests/test_adopt_git_reads.py +++ b/tests/test_adopt_git_reads.py @@ -39,7 +39,7 @@ import pytest -from conftest import REPO, git, make_source_repo +from conftest import REPO, appended_to_command_scope, git, make_source_repo from test_adopt_template_values import (ADOPT_REFUSED, PLAN_FILE, PLAN_OK, PROJECT, REMOTES, SPLIT_BRANCH, VERIFIED, WORK, adopted, answer, @@ -111,10 +111,8 @@ def command_scope(key: str, value: bytes) -> dict: The value goes in as the bytes it is, through `os.fsdecode`, which is how `sys.argv` and `os.environ` carry a byte that is not UTF-8 on POSIX. A Windows environment is text, so the caller does not use this there.""" - held = int(os.environ.get("GIT_CONFIG_COUNT") or 0) - return {"GIT_CONFIG_COUNT": str(held + 1), - f"GIT_CONFIG_KEY_{held}": key, - f"GIT_CONFIG_VALUE_{held}": os.fsdecode(value)} + return appended_to_command_scope(os.environ, + [(key, os.fsdecode(value))]) def global_user_name(base: Path, name: bytes) -> dict: @@ -200,11 +198,10 @@ def test_the_user_name_given_is_appended_to_the_config_the_environment_holds( drop both, and every test after it would run with git's own line-end and long-path defaults. The two slots here are the job's, under keys no config of a machine this runs on sets.""" - for variable, value in {"GIT_CONFIG_COUNT": "2", - "GIT_CONFIG_KEY_0": "test.heldone", - "GIT_CONFIG_VALUE_0": "one", - "GIT_CONFIG_KEY_1": "test.heldtwo", - "GIT_CONFIG_VALUE_1": "two"}.items(): + held = appended_to_command_scope( + {}, [("test.heldone", "one"), ("test.heldtwo", "two")]) + assert held["GIT_CONFIG_COUNT"] == "2", held + for variable, value in held.items(): monkeypatch.setenv(variable, value) extra = global_user_name(tmp_path, LATIN1_NAME) assert extra["GIT_CONFIG_COUNT"] == "3", extra diff --git a/tests/test_adopt_ignored_shape_files.py b/tests/test_adopt_ignored_shape_files.py index 46841f0e..29992959 100644 --- a/tests/test_adopt_ignored_shape_files.py +++ b/tests/test_adopt_ignored_shape_files.py @@ -41,9 +41,9 @@ import pytest -from conftest import (ADOPT, REPO, clear_ambient_pin_sources, - cloned_just_pushed, git, make_source_repo, resolve, - run_script, write_plan) +from conftest import (ADOPT, REPO, appended_to_command_scope, + clear_ambient_pin_sources, cloned_just_pushed, git, + make_source_repo, resolve, run_script, write_plan) sys.path.insert(0, str(REPO / "scripts")) from repo_shape import parse_yaml # noqa: E402 @@ -582,9 +582,8 @@ def command_scope(key: str, value: str) -> dict: `core.autocrlf=false` there, which outranks any config file, so a clone's own `--config` would not reach its checkout. The last entry of a key is the one git reads.""" - held = int(os.environ.get("GIT_CONFIG_COUNT") or 0) - return {**os.environ, "GIT_CONFIG_COUNT": str(held + 1), - f"GIT_CONFIG_KEY_{held}": key, f"GIT_CONFIG_VALUE_{held}": value} + return {**os.environ, + **appended_to_command_scope(os.environ, [(key, value)])} @needs_filter_repo diff --git a/tests/test_adopt_nested_attributes.py b/tests/test_adopt_nested_attributes.py index c1d382db..05e5aade 100644 --- a/tests/test_adopt_nested_attributes.py +++ b/tests/test_adopt_nested_attributes.py @@ -45,9 +45,9 @@ import pytest -from conftest import (ADOPT, REPO, clone_just_pushed, findings, git, - make_source_repo, planned, refused_before_any_leg, - run_script) +from conftest import (ADOPT, REPO, appended_to_command_scope, + clone_just_pushed, findings, git, make_source_repo, + planned, refused_before_any_leg, run_script) sys.path.insert(0, str(REPO / "scripts")) from repo_shape import parse_yaml # noqa: E402 @@ -383,9 +383,7 @@ def command_scope(key: str, value: str) -> dict: """`key=value` in git's command scope (`GIT_CONFIG_COUNT`), appended to what the environment holds: the Windows job sets `core.autocrlf` there, and the last entry of a key is the one git reads.""" - held = int(os.environ.get("GIT_CONFIG_COUNT") or 0) - return {"GIT_CONFIG_COUNT": str(held + 1), - f"GIT_CONFIG_KEY_{held}": key, f"GIT_CONFIG_VALUE_{held}": value} + return appended_to_command_scope(os.environ, [(key, value)]) def checked(plan: Path, env: dict): diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index 20f459c8..a3a51178 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -66,6 +66,7 @@ import functools import importlib.util import itertools +import os import random import re import shutil @@ -74,8 +75,9 @@ import pytest -from conftest import (ADOPT, REPO, cloned_just_pushed, git, make_source_repo, - resolve, run_script, write_plan) +from conftest import (ADOPT, REPO, appended_to_command_scope, + cloned_just_pushed, git, make_source_repo, resolve, + run_script, write_plan) sys.path.insert(0, str(REPO / "scripts")) import repo_shape # noqa: E402 @@ -941,8 +943,8 @@ def test_plan_takes_an_elector_from_git_the_manifests_quotes_read_back( result = run_script( ADOPT, "plan", "--source", str(source), PROJECT_FLAG, PROJECT_NAME, "--org", TEST_ORG, "--elected-on", ELECTION_DATE, "--out", str(plan), - env={"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "user.name", - "GIT_CONFIG_VALUE_0": DOMAIN_USER}) + env=appended_to_command_scope(os.environ, + [("user.name", DOMAIN_USER)])) assert result.returncode == 0, result.stdout + result.stderr assert '\nelected_by: "CORP\\\\tom"\n' in text_of(plan) for path, leg in ANSWERS: diff --git a/tests/test_adopt_reencoded_files.py b/tests/test_adopt_reencoded_files.py index 63bf4920..c363b052 100644 --- a/tests/test_adopt_reencoded_files.py +++ b/tests/test_adopt_reencoded_files.py @@ -39,8 +39,9 @@ import pytest -from conftest import (ADOPT, findings, git, make_source_repo, planned, - refused_before_any_leg, run_script) +from conftest import (ADOPT, appended_to_command_scope, findings, git, + make_source_repo, planned, refused_before_any_leg, + run_script) from test_adopt_nested_attributes import (EMPTY_TREE, GITHUB_ATTRIBUTES, REENCODED, ROOT, ROOT_ATTRIBUTES, SOURCE, adopted_by_execute, checked, @@ -101,12 +102,10 @@ def raw_plan(base: Path, files: dict, answers: dict) -> Path: def configure(monkeypatch: pytest.MonkeyPatch, pairs) -> None: """Each `(key, value)` of `pairs` in git's command scope, after what - the environment holds, for this process and every git it starts.""" - held = int(os.environ.get("GIT_CONFIG_COUNT") or 0) - for n, (key, value) in enumerate(pairs, held): - monkeypatch.setenv(f"GIT_CONFIG_KEY_{n}", key) - monkeypatch.setenv(f"GIT_CONFIG_VALUE_{n}", value) - monkeypatch.setenv("GIT_CONFIG_COUNT", str(held + len(pairs))) + the environment holds, for this process and every git it starts: + appended by `appended_to_command_scope` (#758).""" + for name, value in appended_to_command_scope(os.environ, pairs).items(): + monkeypatch.setenv(name, value) @pytest.fixture(scope="module") diff --git a/tests/test_adopt_root_symlinks.py b/tests/test_adopt_root_symlinks.py index cd3661ff..a8261a24 100644 --- a/tests/test_adopt_root_symlinks.py +++ b/tests/test_adopt_root_symlinks.py @@ -62,9 +62,9 @@ import pytest -from conftest import (ADOPT, REPO, clear_ambient_pin_sources, - cloned_just_pushed, git, make_source_repo, run_script, - write_plan) +from conftest import (ADOPT, REPO, appended_to_command_scope, + clear_ambient_pin_sources, cloned_just_pushed, git, + make_source_repo, run_script, write_plan) sys.path.insert(0, str(REPO / "scripts")) from shape_materialize import ( # noqa: E402 @@ -142,7 +142,6 @@ BOTH_PUSHED = "and both legs are pushed." #: The materializer's refusal of a path that follows too many symlinks. SYMLINK_LOOP = "materialize-symlink-loop" -CONFIG_VALUE = "GIT_CONFIG_VALUE_0" #: Case twins of paths the shape writes, as a macOS or Windows disk reads #: them (S1), and the rest of what several cases name. GITHUB_TWIN = ".GitHub" @@ -186,12 +185,9 @@ #: `git` told to check a symlink out as a plain file, the way Git for #: Windows' default does, or `git init` on a filesystem without symlinks. -#: `GIT_CONFIG_*` outranks every configuration file. -CONFIG_COUNT = "GIT_CONFIG_COUNT" +#: `GIT_CONFIG_*` outranks every configuration file. Set by `told_git`, after +#: the entries the environment holds. CORE_SYMLINKS = "core.symlinks" -SYMLINKS_OFF = {CONFIG_COUNT: "1", "GIT_CONFIG_KEY_0": CORE_SYMLINKS, - CONFIG_VALUE: "false"} -SYMLINKS_ON = {**SYMLINKS_OFF, CONFIG_VALUE: "true"} #: What this runner lacks, if anything, for the end-to-end tests. NO_FILTER_REPO = shutil.which("git-filter-repo") is None @@ -223,16 +219,15 @@ def told_git(monkeypatch, value: str) -> None: """`git` told `core.symlinks=` through `GIT_CONFIG_*`, after whatever entries the runner's job sets there (the Windows job sets two), which stay as they were.""" - held = int(os.environ.get(CONFIG_COUNT) or 0) - monkeypatch.setenv(CONFIG_COUNT, str(held + 1)) - monkeypatch.setenv(f"GIT_CONFIG_KEY_{held}", CORE_SYMLINKS) - monkeypatch.setenv(f"GIT_CONFIG_VALUE_{held}", value) + for name, entry in appended_to_command_scope( + os.environ, [(CORE_SYMLINKS, value)]).items(): + monkeypatch.setenv(name, entry) def links_as_files(monkeypatch) -> None: """`git` told to check a symlink out as a plain file, after the runner's own `GIT_CONFIG_*`, as Git for Windows' default does.""" - told_git(monkeypatch, SYMLINKS_OFF[CONFIG_VALUE]) + told_git(monkeypatch, "false") def no_probe(_work_root): @@ -1296,8 +1291,7 @@ def test_execute_refuses_a_link_it_would_check_out_as_a_file( and the refusal is `execute`'s own, after that list (#531 changes what a finding says, not that order).""" _, plan = github_to_ci(tmp_path) - for key, value in SYMLINKS_OFF.items(): - monkeypatch.setenv(key, value) + links_as_files(monkeypatch) checked = check(plan) assert checked.returncode == 0, checked.stderr + checked.stdout refusal = refused_by(adopter, execute_args(tmp_path, plan)) @@ -1324,8 +1318,7 @@ def test_a_case_twin_checked_out_as_a_file_is_named_as_one( disk it means, as `check`'s finding does (S1).""" _, plan = planned(tmp_path, {CI_FILE: ON_PUSH}, {GITHUB_TWIN: "ci"}, {CI_DIR: ROOT, GITHUB_TWIN: ROOT}) - for key, value in SYMLINKS_OFF.items(): - monkeypatch.setenv(key, value) + links_as_files(monkeypatch) refusal = refused_by(adopter, execute_args(tmp_path, plan)) assert refusal.code == SYMLINKS_OFF_CODE assert refusal.detail.startswith( @@ -1337,10 +1330,9 @@ def test_a_case_twin_checked_out_as_a_file_is_named_as_one( def test_the_probe_answers_what_git_would_do(adopter, tmp_path, monkeypatch): - for key, value in SYMLINKS_ON.items(): - monkeypatch.setenv(key, value) + told_git(monkeypatch, "true") assert adopter._checks_out_symlinks(tmp_path) is True - monkeypatch.setenv(CONFIG_VALUE, "false") + told_git(monkeypatch, "false") assert adopter._checks_out_symlinks(tmp_path) is False assert list(tmp_path.iterdir()) == [] @@ -1348,8 +1340,7 @@ def test_the_probe_answers_what_git_would_do(adopter, tmp_path, monkeypatch): def test_with_symlinks_on_the_same_plan_passes_the_probe(adopter, tmp_path, monkeypatch): _, plan = github_to_ci(tmp_path) - for key, value in SYMLINKS_ON.items(): - monkeypatch.setenv(key, value) + told_git(monkeypatch, "true") loaded = adopter.Plan.load(plan) opened = loaded.open_source(None, tmp_path / READ) assert adopter._refuse_symlinks_checked_out_as_files( @@ -1375,8 +1366,7 @@ def test_an_adoption_with_no_link_beneath_a_shape_path_makes_no_probe( """An ordinary adoption runs exactly as before: nothing is made in the work directory to ask `git` a question this plan does not raise.""" monkeypatch.setattr(adopter, PROBE, no_probe) - for key, value in SYMLINKS_OFF.items(): - monkeypatch.setenv(key, value) + links_as_files(monkeypatch) _, plan = planned(tmp_path, files, links, legs) loaded = adopter.Plan.load(plan) opened = loaded.open_source(None, tmp_path / READ) @@ -1545,7 +1535,7 @@ def test_each_command_asks_its_own_work_directory(adopter, tmp_path, f"\tpath = {included.as_posix()}\n", encoding=UTF8) monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) told = (git_reads_symlinks_as(off), git_reads_symlinks_as(on)) - if told != (SYMLINKS_OFF[CONFIG_VALUE], ""): + if told != ("false", ""): pytest.skip(f"git here does not tell the two directories apart: " f"{told}, from this runner's own configuration") _, at = planned(base / "at", {}, {CLAUDE: NOWHERE_MD}, {CLAUDE: ROOT}) @@ -2068,8 +2058,7 @@ def test_with_symlinks_off_execute_adopts_beside_the_file( link as the source had it, as main does. Before, `check` refused it, and so did `execute`, before any leg.""" source, plan = planned(tmp_path, files, links, legs) - for key, value in SYMLINKS_OFF.items(): - monkeypatch.setenv(key, value) + links_as_files(monkeypatch) adopted_beside(execute(tmp_path, plan), source, links) diff --git a/tests/test_adopt_split_branch.py b/tests/test_adopt_split_branch.py index ce49c85d..17d103dc 100644 --- a/tests/test_adopt_split_branch.py +++ b/tests/test_adopt_split_branch.py @@ -29,15 +29,16 @@ from __future__ import annotations import importlib.util +import os import shutil from argparse import Namespace from pathlib import Path import pytest -from conftest import ADOPT, FILE_PROTOCOL, GITMODULES, as_human, findings, \ - git, make_source_repo, planned, put_gitmodules, refused_before_any_leg, \ - run_script, spelled +from conftest import ADOPT, FILE_PROTOCOL, GITMODULES, \ + appended_to_command_scope, as_human, findings, git, make_source_repo, \ + planned, put_gitmodules, refused_before_any_leg, run_script, spelled needs_filter_repo = pytest.mark.skipif( shutil.which("git-filter-repo") is None, @@ -499,11 +500,12 @@ def github_at(root: Path, monkeypatch) -> None: `url..insteadOf`, in the environment every `git` this process starts inherits. The org is in it because git takes the LONGEST `insteadOf` that matches, and a workstation may already rewrite - `https://github.com/` (to `git@github.com:`, say) in its own config.""" - monkeypatch.setenv("GIT_CONFIG_COUNT", "1") - monkeypatch.setenv("GIT_CONFIG_KEY_0", - f"url.{(root / ORG).as_posix()}/.insteadOf") - monkeypatch.setenv("GIT_CONFIG_VALUE_0", f"https://github.com/{ORG}/") + `https://github.com/` (to `git@github.com:`, say) in its own config. The + entry is appended to the command scope the environment holds.""" + for name, value in appended_to_command_scope(os.environ, [ + (f"url.{(root / ORG).as_posix()}/.insteadOf", + f"https://github.com/{ORG}/")]).items(): + monkeypatch.setenv(name, value) def a_github(tmp_path: Path, monkeypatch, branches=(SPLIT_BRANCH,)) -> Path: diff --git a/tests/test_bootstrap_after_bump.py b/tests/test_bootstrap_after_bump.py index 8c368a89..623038f5 100644 --- a/tests/test_bootstrap_after_bump.py +++ b/tests/test_bootstrap_after_bump.py @@ -88,8 +88,8 @@ import pytest -from conftest import (ORG, PROJECT, REPO, SCAFFOLD, cloned_just_pushed, git, - run_script) +from conftest import (ORG, PROJECT, REPO, SCAFFOLD, appended_to_command_scope, + cloned_just_pushed, git, run_script) BUMP = REPO / "scripts" / "bump-leg.py" MAIN = "refs/heads/main" @@ -147,10 +147,7 @@ def configured(key: str, value: str) -> dict: and `GIT_CONFIG_VALUE_` (git 2.31), never `git config`. APPENDED to what the environment holds: the Windows job sets two entries already (`.github/workflows/tests.yml`), and a count of 1 would drop both.""" - held = int(os.environ.get("GIT_CONFIG_COUNT") or 0) - return {"GIT_CONFIG_COUNT": str(held + 1), - f"GIT_CONFIG_KEY_{held}": key, - f"GIT_CONFIG_VALUE_{held}": value} + return appended_to_command_scope(os.environ, [(key, value)]) def failing_post_checkout(tmp_path: Path) -> dict: diff --git a/tests/test_bump_leg_dry_run_truth.py b/tests/test_bump_leg_dry_run_truth.py index 2d9e280c..2f4184c2 100644 --- a/tests/test_bump_leg_dry_run_truth.py +++ b/tests/test_bump_leg_dry_run_truth.py @@ -43,13 +43,13 @@ from __future__ import annotations import re -import subprocess import sys from pathlib import Path import pytest -from conftest import FILE_PROTOCOL, ORG, PROJECT, REPO, git, run_script +from conftest import (FILE_PROTOCOL, ORG, PROJECT, REPO, cloned_just_pushed, + git, run_script) from test_bump_leg import ( BUMP, SPEC_PIN, branched, bump, commit_spec_pin, committed, reference_both_legs, spec_ref, validate, @@ -90,28 +90,21 @@ def leg_remote(scaffolded, tmp_path_factory) -> dict: own bare repositories are only ever read, so a tag pushed to this one reaches no other module's leg. - `--no-local`, as `clone_just_pushed` reads a remote a scaffold has just - written (#440): through upload-pack, which a repack does not break. + Made with `cloned_just_pushed`, which reads a remote a scaffold has just + written through upload-pack, which a repack does not break (#440, #758). """ remotes = tmp_path_factory.mktemp("w534-remotes") - proc = subprocess.run( - ["git", *FILE_PROTOCOL, "clone", "-q", "--bare", "--no-local", - str(scaffolded["remotes"] / f"{PROJECT}-spec.git"), - str(remotes / f"{PROJECT}-spec.git")], - capture_output=True, text=True, check=False) - assert proc.returncode == 0, proc.stderr - return {"remotes": remotes, "bare": remotes / f"{PROJECT}-spec.git"} + bare = cloned_just_pushed(scaffolded["remotes"] / f"{PROJECT}-spec.git", + remotes / f"{PROJECT}-spec.git", bare=True) + assert git("rev-parse", "--is-bare-repository", + cwd=bare).stdout.strip() == "true" + return {"remotes": remotes, "bare": bare} def private_work(leg_remote: dict, tmp_path_factory, name: str) -> Path: """A clone of the private remote to make commits in and push from.""" work = tmp_path_factory.mktemp(f"w534-{name}") / f"{PROJECT}-spec" - proc = subprocess.run( - ["git", *FILE_PROTOCOL, "clone", "-q", "--no-local", - str(leg_remote["bare"]), str(work)], - capture_output=True, text=True, check=False) - assert proc.returncode == 0, proc.stderr - return work + return cloned_just_pushed(leg_remote["bare"], work, recurse=False) def tagged(work: Path, name: str, target: str) -> str: diff --git a/tests/test_clone_just_pushed.py b/tests/test_clone_just_pushed.py index f85d4ed6..823e52f3 100644 --- a/tests/test_clone_just_pushed.py +++ b/tests/test_clone_just_pushed.py @@ -28,11 +28,11 @@ import pytest -from conftest import (FILE_PROTOCOL, PROJECT, assert_read_through_git, - assert_store_read_through_git, clone_just_pushed, - cloned_just_pushed, git, insteadof_args, - insteadof_entries, insteadof_prefixes, read_through_git, - submodule_update_through_git) +from conftest import (FILE_PROTOCOL, PROJECT, appended_to_command_scope, + assert_read_through_git, assert_store_read_through_git, + clone_just_pushed, cloned_just_pushed, git, + insteadof_args, insteadof_entries, insteadof_prefixes, + read_through_git, submodule_update_through_git) @pytest.fixture @@ -230,9 +230,11 @@ def test_the_environment_form_keeps_what_the_environment_holds( """APPENDED, as `appended_to_command_scope` appends the session's own settings: an entry the process already has is neither overwritten nor counted away, so the pasted line sees it as every other git does.""" - monkeypatch.setenv("GIT_CONFIG_COUNT", "1") - monkeypatch.setenv("GIT_CONFIG_KEY_0", "url.held://base/.insteadOf") - monkeypatch.setenv("GIT_CONFIG_VALUE_0", "held://") + held = appended_to_command_scope( + {}, [("url.held://base/.insteadOf", "held://")]) + assert held["GIT_CONFIG_COUNT"] == "1", held + for name, value in held.items(): + monkeypatch.setenv(name, value) entries = read_through_git(remotes) assert "GIT_CONFIG_KEY_0" not in entries, entries assert entries["GIT_CONFIG_COUNT"] == str( diff --git a/tests/test_doctor_legs_members_next.py b/tests/test_doctor_legs_members_next.py index 3bee3abc..a6391082 100644 --- a/tests/test_doctor_legs_members_next.py +++ b/tests/test_doctor_legs_members_next.py @@ -24,10 +24,11 @@ Those tests are `WINDOWS_SKIP`: `sh` is a POSIX shell, and on Windows the reader's shell is PowerShell. The text-level assertions about what the line IS run on every OS. A git paste gets the file protocol per subprocess through -`GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_0` and `GIT_CONFIG_VALUE_0` -- the -fixtures' remotes are bare repositories on disk, which git refuses as a -submodule URL by default -- and never `GIT_ALLOW_PROTOCOL`, which would -override git's `user` policy for the whole run. +git's `GIT_CONFIG_COUNT` entries -- the fixtures' remotes are bare +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. 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 @@ -48,7 +49,8 @@ import pytest -from conftest import (WINDOWS_SKIP, cloned_just_pushed, git, rmtree, +from conftest import (WINDOWS_SKIP, appended_to_command_scope, + cloned_just_pushed, git, rmtree, submodule_update_through_git) from test_shape_doctor import ( # noqa: F401 FAMILY_MEMBER, FAMILY_NAME, GIT_IDENTITY, PROJECT, commit_all, doctor, @@ -56,17 +58,28 @@ scaffolded_here, standard, ) -#: 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. -PASTE_ENV = {**{k: v for k, v in os.environ.items() if k != "PYTHONPATH"}, - **GIT_IDENTITY, - "GIT_CONFIG_COUNT": "1", - "GIT_CONFIG_KEY_0": "protocol.file.allow", - "GIT_CONFIG_VALUE_0": "always", - "PYTHONDONTWRITEBYTECODE": "1"} + +def paste_env() -> 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. + + 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` + keeps git's automatic maintenance off, #645) and the two the Windows job + sets. This was a dict built when the module was imported, at collection, + 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).""" + 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")]), + "PYTHONDONTWRITEBYTECODE": "1"} + #: The git line's own words after `git -C `: `--init` for a mount the #: clone never registered, `--recursive` for a member's own legs, and @@ -99,7 +112,7 @@ def paste(row: dict, cwd: Path) -> subprocess.CompletedProcess: 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(), stdin=subprocess.DEVNULL) @@ -440,7 +453,7 @@ def test_the_keep_line_in_the_explanation_keeps_a_detached_commit( assert keep, why_of(row) kept = subprocess.run(["sh", "-c", keep.group(0).replace("NAME", "kept")], capture_output=True, text=True, check=False, - env=PASTE_ENV) + env=paste_env()) assert kept.returncode == 0, kept.stdout + kept.stderr result = paste(row, tmp_path / "elsewhere") assert result.returncode == 0, result.stdout + result.stderr @@ -512,7 +525,7 @@ def test_a_plain_clones_empty_mounts_are_cured_by_the_pasted_line( `submodule.active`, so every other paste here would pass without it.""" plain = cloned_just_pushed( scaffolded_here["base"] / "remotes" / f"{PROJECT}.git", - tmp_path / "Plain", recurse=False, env=PASTE_ENV) + tmp_path / "Plain", recurse=False, env=paste_env()) row = row_of(standard, plain, "legs") assert row["status"] == "FINDING", row assert command_of(row).endswith("-- spec code"), row["next"] @@ -607,7 +620,7 @@ def test_the_git_line_does_not_cure_a_mount_git_cannot_read_a_head_in( result = subprocess.run( ["git", "-C", str(project), "submodule", "update", "--init", "--recursive", "--", "spec"], capture_output=True, text=True, - check=False, env=PASTE_ENV) + check=False, env=paste_env()) assert result.returncode != 0, result.stdout + result.stderr after = row_of(standard, project, "legs") assert after["status"] == "FINDING", after @@ -783,7 +796,7 @@ def test_a_pasted_members_git_line_keeps_a_detached_commit_on_the_kept_branch( keep = re.search(r"git -C \S+ branch NAME", why_of(row)) assert keep, why_of(row) subprocess.run(["sh", "-c", keep.group(0).replace("NAME", "kept")], - capture_output=True, check=True, env=PASTE_ENV) + capture_output=True, check=True, env=paste_env()) result = paste(row, tmp_path / "elsewhere") assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, root, "members")["status"] == "ok" @@ -1017,7 +1030,7 @@ def sh(line: str, cwd: Path) -> subprocess.CompletedProcess: cwd.mkdir(exist_ok=True) return subprocess.run(["sh", "-c", line], 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(), stdin=subprocess.DEVNULL) @@ -1883,6 +1896,15 @@ def test_without_it_the_legs_own_submodule_commit_is_in_no_ref( assert commit in git("reflog", cwd=nested).stdout +def clone_member(first: Path, ghost: Path) -> None: + """`ghost`, a plain clone of the member at `first`, read through git + (`cloned_just_pushed`): its own legs stay empty, as the first member's + are, which a recursive clone would fill from the urls `.gitmodules` + gives.""" + cloned_just_pushed(first, ghost, recurse=False) + assert not any((ghost / "spec").iterdir()), "the clone filled a leg" + + def with_a_second_member_off_its_gitlink(root: Path) -> tuple[Path, str]: """A second member, `Ghost`, a clone of the first, recorded at a commit that is not its pin and detached at another past that one; and the @@ -1894,8 +1916,7 @@ def with_a_second_member_off_its_gitlink(root: Path) -> tuple[Path, str]: ghost = root / "members" / "Ghost" url = git("config", "-f", ".gitmodules", "--get", f"submodule.members/{FAMILY_MEMBER}.url", cwd=root).stdout - git("-c", "protocol.file.allow=always", "clone", "-q", "--no-local", - str(first), str(ghost), cwd=root) + clone_member(first, ghost) add_member_row(root, "Ghost") git("config", "-f", ".gitmodules", "submodule.members/Ghost.path", "members/Ghost", cwd=root) @@ -1995,8 +2016,7 @@ def with_a_second_member(root: Path, at_pin: bool, section: str) -> Path: ghost = root / "members" / "Ghost" url = git("config", "-f", ".gitmodules", "--get", f"submodule.members/{FAMILY_MEMBER}.url", cwd=root).stdout - git("-c", "protocol.file.allow=always", "clone", "-q", "--no-local", - str(first), str(ghost), cwd=root) + clone_member(first, ghost) add_member_row(root, "Ghost") if section != "none": git("config", "-f", ".gitmodules", "submodule.members/Ghost.path", diff --git a/tests/test_doctor_next_is_a_command.py b/tests/test_doctor_next_is_a_command.py index 11480aad..d34530cd 100644 --- a/tests/test_doctor_next_is_a_command.py +++ b/tests/test_doctor_next_is_a_command.py @@ -66,7 +66,7 @@ import pytest -from conftest import REPO, WINDOWS_SKIP, git, rmtree +from conftest import REPO, WINDOWS_SKIP, appended_to_command_scope, git, rmtree #: The lane a lane's run sets, from the file that owns that rule. from test_commit_trailers import LANE #: The doctor's own fixtures and helpers, from the module that owns them. A @@ -849,19 +849,19 @@ def paste_into_zsh(line: str, cwd: Path, timeout: float = 120.0) -> tuple: The environment is the test's own, less `PYTHONPATH` (a NEXT carries what it needs, #238) and with file-protocol git for the one child that may clone from the suite's local remotes -- per process, never - `GIT_ALLOW_PROTOCOL` (see `tests/conftest.py`). It is read from - `os.environ` HERE, at the call, and is not doctor A's `PASTE_ENV`: that - dict is built when its module is imported, at collection, before the - session fixture puts the `gh` guard first on `PATH`, so a line pasted - with it would reach whatever `gh` the machine has. + `GIT_ALLOW_PROTOCOL` (see `tests/conftest.py`), appended to the command + scope the environment holds. It is read from `os.environ` HERE, at the + call, as doctor A's `paste_env()` is (#758): a dict built when its module + is imported, at collection, is made before the session fixture puts the + `gh` guard first on `PATH`, so a line pasted with it would reach + whatever `gh` the machine has. """ import pty # POSIX only: `tty` beneath it needs `termios` env = {name: value for name, value in os.environ.items() if name.upper() != "PYTHONPATH"} env.update({"TERM": "dumb", "PYTHONDONTWRITEBYTECODE": "1", - "GIT_CONFIG_COUNT": "1", - "GIT_CONFIG_KEY_0": "protocol.file.allow", - "GIT_CONFIG_VALUE_0": "always"}) + **appended_to_command_scope( + os.environ, [("protocol.file.allow", "always")])}) master, slave = pty.openpty() try: shell = subprocess.Popen(["zsh", "-f", "-i"], stdin=slave, diff --git a/tests/test_family.py b/tests/test_family.py index 4dbe718a..818e7d17 100644 --- a/tests/test_family.py +++ b/tests/test_family.py @@ -32,8 +32,8 @@ import pytest -from conftest import (NO_BYTECODE, REPO, cloned_just_pushed, git, rmtree, - run_script) +from conftest import (NO_BYTECODE, REPO, appended_to_command_scope, + cloned_just_pushed, git, rmtree, run_script) #: The trailer helpers and the two lines a lane's run lands, imported from #: the file that owns those rules rather than retyped — see #: `tests/test_commit_trailers.py`. @@ -71,12 +71,15 @@ #: it. Every copy of the holder a test makes stands on this branch. WORK_BRANCH = "family/members" -#: `git` reads one-off configuration out of the environment, which is how a -#: test gives a plain `git submodule update` permission to clone a local path -#: without the tool under test knowing anything about it. -ALLOW_FILE_PROTOCOL = {"GIT_CONFIG_COUNT": "1", - "GIT_CONFIG_KEY_0": "protocol.file.allow", - "GIT_CONFIG_VALUE_0": "always"} + +def allow_file_protocol() -> dict: + """`git` reads one-off configuration out of the environment, which is how + a test gives a plain `git submodule update` permission to clone a local + path without the tool under test knowing anything about it. Built at the + call, and appended to the git command scope the environment holds, so the + session's own settings (#645) still reach the child (#758).""" + return appended_to_command_scope(os.environ, + [("protocol.file.allow", "always")]) def scaffold_member(base, project: str) -> None: @@ -1425,7 +1428,7 @@ def bootstrapped(family, tmp_path_factory): target = cloned_just_pushed(family["remotes"] / f"{NAME}.git", tmp_path_factory.mktemp("family-clone") / NAME) result = run_script(target / "scripts" / "bootstrap.py", cwd=target, - env=ALLOW_FILE_PROTOCOL) + env=allow_file_protocol()) return {"root": target, "result": result} @@ -1806,7 +1809,7 @@ def estate(family, tmp_path): def siblings(holder, *extra, env=None): return run_script(holder / "scripts" / "siblings.py", *extra, cwd=holder, - env={**ALLOW_FILE_PROTOCOL, **(env or {})}) + env={**allow_file_protocol(), **(env or {})}) def layout(stdout: str) -> list[str]: @@ -1830,7 +1833,7 @@ def test_siblings_clones_each_member_beside_the_holder_on_its_branch(estate): """ proc = subprocess.run(["make", "siblings"], cwd=str(estate["holder"]), capture_output=True, text=True, check=False, - env={**os.environ, **ALLOW_FILE_PROTOCOL}) + env={**os.environ, **allow_file_protocol()}) assert proc.returncode == 0, proc.stderr + proc.stdout for project in MEMBERS: sibling = estate["folder"] / project @@ -1990,7 +1993,7 @@ def one(name: str, runner) -> dict: theirs = one("target", lambda holder: siblings(holder)) ours = one("shim", lambda holder: run_script( FAMILY, "siblings", "--family-root", str(holder), - env={**ALLOW_FILE_PROTOCOL, **NO_BYTECODE})) + env={**allow_file_protocol(), **NO_BYTECODE})) assert layout(ours["result"].stdout) == layout(theirs["result"].stdout) for project in MEMBERS: diff --git a/tests/test_family_bump_remove_rollback.py b/tests/test_family_bump_remove_rollback.py index 26587826..63838823 100644 --- a/tests/test_family_bump_remove_rollback.py +++ b/tests/test_family_bump_remove_rollback.py @@ -61,8 +61,8 @@ import pytest -from conftest import (cloned_just_pushed, git, rmtree, run_script, - submodule_update_through_git) +from conftest import (appended_to_command_scope, cloned_just_pushed, git, + rmtree, run_script, submodule_update_through_git) #: The family fixture, its names, the offline environment and the holder's #: byte-for-byte state, imported from the modules that own them rather than #: rebuilt: a holder made, or compared, any other way would test a @@ -680,10 +680,7 @@ def foreign_store(store: Path, url: str, tmp_path: Path) -> None: def configured(env: dict, key: str, value: str) -> dict: """`env` with one more entry in git's `GIT_CONFIG_COUNT` protocol.""" - count = int(env.get("GIT_CONFIG_COUNT", "0")) - return {**env, "GIT_CONFIG_COUNT": str(count + 1), - f"GIT_CONFIG_KEY_{count}": key, - f"GIT_CONFIG_VALUE_{count}": value} + return {**env, **appended_to_command_scope(env, [(key, value)])} @pytest.mark.parametrize("store", ["absent", "without the commit"]) diff --git a/tests/test_family_bump_to_checks.py b/tests/test_family_bump_to_checks.py index e096c511..d8b7652f 100644 --- a/tests/test_family_bump_to_checks.py +++ b/tests/test_family_bump_to_checks.py @@ -40,6 +40,7 @@ import pytest +from conftest import cloned_just_pushed #: The family fixture and its names, from the module that owns them. from test_family import ( # noqa: F401 NAME, ORG, family, manifest, validate, @@ -232,8 +233,7 @@ def test_bump_to_a_commit_not_pushed_says_to_push_it_first( env = isolated(offline, tmp_path) remote = a_private_remote(holder, env, tmp_path) work = tmp_path / f"{MEMBER}-work" - raw(tmp_path, env, "clone", "-q", "--no-local", "-b", "main", "--", - str(remote), str(work)) + cloned_just_pushed(remote, work, "main", recurse=False, env=env) (work / "LOCAL.md").write_bytes(b"made here, not pushed yet\n") raw(work, env, "add", "--", "LOCAL.md") raw(work, env, "commit", "-q", "-m", "A commit only the clone has") diff --git a/tests/test_family_dry_run.py b/tests/test_family_dry_run.py index ac832dc8..8d2662b6 100644 --- a/tests/test_family_dry_run.py +++ b/tests/test_family_dry_run.py @@ -49,7 +49,8 @@ import pytest -from conftest import cloned_just_pushed, git, run_script +from conftest import (appended_to_command_scope, cloned_just_pushed, git, + run_script) #: The trailer lines a lane's run lands, from the file that owns them. from test_commit_trailers import TRAILERS, trailers_of #: The family fixture, its names and the offline environment, imported from @@ -71,25 +72,37 @@ ) from test_family_member_path import isolated as member_path_env -#: GIT'S AUTOMATIC MAINTENANCE, OFF for every git this module starts, through -#: git's `GIT_CONFIG_COUNT` protocol and never a global file. A `git commit` -#: may start `git maintenance run --auto` in the background (a newer git on -#: the macOS runner does), and it took `.git/objects/maintenance.lock` while -#: a test snapshotted the holder and released it before the comparison: five -#: `objects changed` failures on PR #345's first macOS run, none of them the -#: tool's. With it off, no background job packs or locks objects mid-test. -NO_MAINTENANCE = {"GIT_CONFIG_COUNT": "3", - "GIT_CONFIG_KEY_0": "maintenance.auto", - "GIT_CONFIG_VALUE_0": "false", - "GIT_CONFIG_KEY_1": "gc.auto", "GIT_CONFIG_VALUE_1": "0", - "GIT_CONFIG_KEY_2": "gc.autoDetach", - "GIT_CONFIG_VALUE_2": "false"} +#: GIT'S AUTOMATIC MAINTENANCE, OFF for the gits `isolated()` below hands an +#: environment to: `family.py`'s children and the snapshot's gits. A `git +#: commit` may start `git maintenance run --auto` in the background (a newer +#: git on the macOS runner does), and it took `.git/objects/maintenance.lock` +#: while a test snapshotted the holder and released it before the +#: comparison: five `objects changed` failures on PR #345's first macOS run, +#: none of them the tool's. With it off, no background job packs or locks +#: objects mid-test. +#: +#: These are the `(key, value)` entries `isolated()` appends to git's command +#: scope (`GIT_CONFIG_COUNT`) at the call, after the ones the environment +#: holds. `isolated()` also names a global config of its own +#: (`member_path_env`'s `excluding()`), which takes the session's file away +#: from these gits. The session's two settings still reach them, in the +#: command scope `isolated()` appends to, so `maintenance.auto` and `gc.auto` +#: here repeat them and only `gc.autoDetach` adds a setting; the two stay so +#: that this constant alone keeps maintenance off for these gits. The +#: module's own setup commits go through `conftest.git`, which passes no +#: `env=` and inherits `os.environ`: the session fixture +#: (`no_auto_maintenance`, #645) keeps maintenance off for those, in both +#: scopes, and this constant does not reach them. +NO_MAINTENANCE = (("maintenance.auto", "false"), ("gc.auto", "0"), + ("gc.autoDetach", "false")) def isolated(offline, tmp_path: Path) -> dict: """`test_family_member_path.isolated`'s offline, identity-carrying - environment, with git's automatic maintenance off (`NO_MAINTENANCE`).""" - return {**member_path_env(offline, tmp_path), **NO_MAINTENANCE} + environment, with git's automatic maintenance off (`NO_MAINTENANCE`), + appended to the command scope `os.environ` holds when this is called.""" + return {**member_path_env(offline, tmp_path), + **appended_to_command_scope(os.environ, NO_MAINTENANCE)} #: The header `bump --dry-run` prints above the row it would write. diff --git a/tests/test_family_siblings_placement.py b/tests/test_family_siblings_placement.py index c5d6e449..a34f98d5 100644 --- a/tests/test_family_siblings_placement.py +++ b/tests/test_family_siblings_placement.py @@ -53,7 +53,7 @@ #: The family fixture, its names and the `make siblings` runner, imported #: from the module that owns them. from test_family import ( # noqa: F401 - ALLOW_FILE_PROTOCOL, FAMILY, MEMBERS, NAME, family, siblings, + FAMILY, MEMBERS, NAME, allow_file_protocol, family, siblings, ) #: The branch IRSS's remote is pointed at instead of `main`. @@ -514,7 +514,7 @@ def test_a_family_folder_reached_through_a_link_passes_and_clones_into_it( logical = link / NAME shim = run_script(FAMILY, "siblings", "--family-root", str(logical), - "--dry-run", env=ALLOW_FILE_PROTOCOL) + "--dry-run", env=allow_file_protocol()) assert shim.returncode == 0, shim.stdout + shim.stderr assert "WARNING" not in shim.stderr @@ -615,7 +615,7 @@ def test_family_py_siblings_runs_the_holders_own_copy_with_its_imports( b" Path(_shape.__file__).resolve())\n"))) result = run_script(FAMILY, "siblings", "--family-root", str(holder), - "--dry-run", env=ALLOW_FILE_PROTOCOL) + "--dry-run", env=allow_file_protocol()) assert result.returncode == 0, result.stdout + result.stderr library = (holder / "scripts" / "repo_shape.py").resolve() assert f"HOLDER COPY RAN, repo_shape from {library}" in result.stdout @@ -630,7 +630,7 @@ def test_family_py_siblings_refuses_a_holder_without_its_own_copy( (holder / "scripts" / "siblings.py").unlink() result = run_script(FAMILY, "siblings", "--family-root", str(holder), - env=ALLOW_FILE_PROTOCOL) + env=allow_file_protocol()) assert result.returncode == 2, result.stdout + result.stderr said = flat(result.stderr) assert said.startswith("REFUSED siblings-script-missing: family.py " @@ -659,7 +659,7 @@ def test_family_py_siblings_escapes_what_its_stdout_cannot_encode( of the first line, and the run goes on.""" holder = holder_in(diverged, tmp_path / NAME) result = run_script(FAMILY, "siblings", "--family-root", str(holder), - "--dry-run", env={**ALLOW_FILE_PROTOCOL, + "--dry-run", env={**allow_file_protocol(), "PYTHONIOENCODING": "ascii"}) assert result.returncode == 0, result.stdout + result.stderr assert "UnicodeEncodeError" not in result.stderr diff --git a/tests/test_local_remote_dir_urls.py b/tests/test_local_remote_dir_urls.py index 8809e3f6..2b90bcb3 100644 --- a/tests/test_local_remote_dir_urls.py +++ b/tests/test_local_remote_dir_urls.py @@ -48,7 +48,7 @@ assert_nothing_was_made, child, directory_named, family_argv, family_refusal, only_the_branch_question, scaffold_argv, scaffold_refusal) from test_family import ( # noqa: F401 - fixtures - ALLOW_FILE_PROTOCOL, FAMILY, SPARE, family, family_module) + FAMILY, SPARE, allow_file_protocol, family, family_module) from test_family_add_refusals import ( # noqa: F401 - fixtures assert_unchanged, holder, offline, snapshot) from test_scaffold_free_text_values import ( # noqa: F401 - fixtures @@ -346,6 +346,6 @@ def test_the_holders_readme_prints_a_clone_line_that_pastes_whole( assert_one_command(lines, url, quoted) paste = tmp_path / "paste" paste.mkdir() - done = pasted(lines, paste, ALLOW_FILE_PROTOCOL) + done = pasted(lines, paste, allow_file_protocol()) assert done.returncode == 0, done.stderr assert (paste / HOLDER / "family.yaml").is_file() diff --git a/tests/test_no_auto_maintenance.py b/tests/test_no_auto_maintenance.py index c7116dd0..5e4f5cce 100644 --- a/tests/test_no_auto_maintenance.py +++ b/tests/test_no_auto_maintenance.py @@ -22,11 +22,16 @@ person's own global config is still read, in git's order, beneath them; - the file is one git can read where `HOME` is unset, and where `GIT_CONFIG_GLOBAL` is empty, which is git's "no global config"; -- the fixture is in place before any session fixture builds a repository. +- the fixture is in place before any session fixture builds a repository; +- 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. """ from __future__ import annotations +import ast import json import os import subprocess @@ -35,8 +40,9 @@ import pytest from conftest import (NO_AUTO_MAINTENANCE, ORG, PROJECT, SCAFFOLD, - appended_to_command_scope, git, home_is_known, - run_script, session_global_config) + WINDOWS_SKIP, appended_to_command_scope, git, + home_is_known, run_script, session_global_config) +from test_doctor_legs_members_next import paste #: The verbs auto maintenance runs as: `git maintenance run --auto`, and #: `git gc --auto`, which a git started itself before `maintenance` existed. @@ -281,3 +287,256 @@ def test_the_settings_are_in_place_before_a_session_fixture_builds_a_repo( for name in ("GIT_CONFIG_GLOBAL", "GIT_CONFIG_COUNT")} assert seen_by_a_session_fixture["GIT_CONFIG_GLOBAL"] == \ str(no_auto_maintenance) + + +# --- the doctor tests' pasted lines, and the counts a test may not set (#758) +# +# `test_doctor_legs_members_next.py` pastes the NEXT a row prints into `sh`. +# Its environment was a copy of `os.environ` made when the module was +# imported, at collection, with `GIT_CONFIG_COUNT` set to 1: it held none of +# the session's settings, which `no_auto_maintenance` puts there at session +# start, and it dropped the entries the Windows job sets. + +@WINDOWS_SKIP +def test_a_pasted_line_keeps_the_entries_the_environment_holds_at_the_call( + no_auto_maintenance, tmp_path, monkeypatch): + """The environment holds what the Windows job sets (two entries) and what + the session fixture appends (the two settings), put there AFTER the doctor + tests were imported. `git config --show-origin --list`, pasted through + `paste()`, lists all four in git's command scope, in order, and then the + `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) + 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 == [ + "test.heldone=one", "test.heldtwo=two", + *(f"{key}={value}" for key, value in NO_AUTO_MAINTENANCE), + "protocol.file.allow=always"] + for key, value in NO_AUTO_MAINTENANCE: + assert f"file:{no_auto_maintenance}\t{key}={value}" in \ + shown.stdout.splitlines() + + +#: 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} + +COUNT = "GIT_CONFIG_COUNT" +APPENDER = "appended_to_command_scope" +#: The calls that put a value in the environment: `monkeypatch.setenv(name, +#: value)`, `env.setdefault(name, value)` and `os.putenv(name, value)`. Not +#: `env.get(name, default)`, which reads. +SETTERS = ("setenv", "setdefault", "putenv") + + +def is_the_count(node, names=frozenset()) -> bool: + """The string constant `"GIT_CONFIG_COUNT"`, or one of `names`, the names + the module binds to it.""" + return (isinstance(node, ast.Constant) and node.value == COUNT) or ( + isinstance(node, ast.Name) and node.id in names) + + +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.""" + return (isinstance(node, ast.Constant) and isinstance(node.value, str) + and (node.value.isdigit() or not node.value)) + + +def bound_names(node) -> list: + """The plain names an assignment binds: `A = B = ...`, `A: str = ...`.""" + if isinstance(node, ast.Assign): + return [target.id for target in node.targets + if isinstance(target, ast.Name)] + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + return [node.target.id] + return [] + + +def names_of_the_count(tree) -> frozenset: + """Each name `tree` binds to `"GIT_CONFIG_COUNT"`: after `CONFIG_COUNT = + "GIT_CONFIG_COUNT"`, a `{CONFIG_COUNT: "1"}` sets the count as the string + itself would.""" + return frozenset(name for node in ast.walk(tree) + if is_the_count(getattr(node, "value", None)) + for name in bound_names(node)) + + +def is_a_setter(node) -> bool: + """`setenv`, `setdefault` or `putenv`, called bare or on an object.""" + return (isinstance(node, ast.Name) and node.id in SETTERS) or ( + isinstance(node, ast.Attribute) and node.attr in SETTERS) + + +def is_the_appender(node) -> bool: + """`appended_to_command_scope`, called bare or through a module.""" + return (isinstance(node, ast.Name) and node.id == APPENDER) or ( + isinstance(node, ast.Attribute) and node.attr == APPENDER) + + +def in_a_dict(node: ast.Dict, names: frozenset) -> list: + """`{"GIT_CONFIG_COUNT": "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)] + + +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({}, ...)`.""" + lines = [keyword.value.lineno for keyword in node.keywords + if keyword.arg == COUNT and is_a_written_count(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])): + lines.append(node.lineno) + if (is_the_appender(node.func) and node.args + and isinstance(node.args[0], ast.Dict)): + lines.append(node.lineno) + return lines + + +def by_subscript(node: ast.Assign, names: frozenset) -> list: + """`env["GIT_CONFIG_COUNT"] = "1"`.""" + if not is_a_written_count(node.value): + return [] + return [target.lineno for target in node.targets + if isinstance(target, ast.Subscript) + and is_the_count(target.slice, names)] + + +#: The nodes a count can be set in, and the reader of each. +READERS = {ast.Dict: in_a_dict, ast.Call: in_a_call, ast.Assign: by_subscript} + + +def counts_set_to_a_number(source: bytes) -> list: + """The line of each place `source` sets a git command scope of its own + instead of appending to the environment's: + + - `GIT_CONFIG_COUNT` set to a number it writes out: in a dict + (`{"GIT_CONFIG_COUNT": "1"}`), as a keyword + (`dict(GIT_CONFIG_COUNT="1")`), in a call that names the variable and + 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; + - `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.""" + tree = ast.parse(source) + names = names_of_the_count(tree) + lines = [] + for node in ast.walk(tree): + reader = READERS.get(type(node)) + if reader: + lines += reader(node, names) + return sorted(lines) + + +@pytest.mark.parametrize("source", [ + '{"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "protocol.file.allow"}', + 'dict(GIT_CONFIG_COUNT="2")', + 'monkeypatch.setenv("GIT_CONFIG_COUNT", "1")', + 'setenv("GIT_CONFIG_COUNT", "1")', + 'env.setdefault("GIT_CONFIG_COUNT", "2")', + 'os.putenv("GIT_CONFIG_COUNT", "1")', + 'env["GIT_CONFIG_COUNT"] = "3"', + '{"GIT_CONFIG_COUNT": ""}', + 'CONFIG_COUNT = "GIT_CONFIG_COUNT"; SYMLINKS_OFF = {CONFIG_COUNT: "1"}', + 'COUNT: str = "GIT_CONFIG_COUNT"; monkeypatch.setenv(COUNT, "1")', + 'KEY = "GIT_CONFIG_COUNT"; env[KEY] = "2"', + 'appended_to_command_scope({}, [("k", "v")])', + 'conftest.appended_to_command_scope({"A": "1"}, [])', +], 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"]) +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] + + +@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 is 1"', + 'KEY = "GIT_CONFIG_KEY_0"; env = {KEY: "1"}', + '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 counts_set_to_a_number(source.encode("utf-8")) == [] + + +def files_off_their_count(directory: Path) -> dict: + """Each `.py` file in `directory` whose places that set a count of their + own are not as many as `OWN_COUNTS` allows it, with their lines: more + drops the entries the environment holds, and fewer is an allowance no + test needs any more.""" + found = {} + for path in sorted(directory.glob("*.py")): + lines = counts_set_to_a_number(path.read_bytes()) + if len(lines) != OWN_COUNTS.get(path.name, 0): + found[path.name] = lines + return found + + +def test_the_walk_reads_every_python_file_and_holds_each_to_its_count( + tmp_path): + """`conftest.py` and a helper module are read as a test file is, a file + 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, + "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": [], + "test_no_auto_maintenance.py": [1, 2, 3]} + + +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 + longer git's, so a session setting or a job's `core.autocrlf` does not + 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`, + `NO_MAINTENANCE`, `QUOTE_PATH_OFF`, `SYMLINKS_OFF` and eight one-off + environments each wrote 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}") diff --git a/tests/test_non_utf8_git_reads.py b/tests/test_non_utf8_git_reads.py index 85afd289..d34d2834 100644 --- a/tests/test_non_utf8_git_reads.py +++ b/tests/test_non_utf8_git_reads.py @@ -51,8 +51,8 @@ import pytest -from conftest import (ORG, PROJECT, REPO, cloned_just_pushed, git, run_script, - skip_message, +from conftest import (ORG, PROJECT, REPO, appended_to_command_scope, + cloned_just_pushed, git, run_script, skip_message, skip_where_fsencode_gives_a_surrogate_no_byte, windows_fsencode) from test_bump_leg import branched, bump @@ -82,10 +82,11 @@ UTF8 = "caf\u00e9.yml" #: `core.quotePath=false`, as command-line config: it outranks every config -#: file, and a test needs no config file of its own to say it. -QUOTE_PATH_OFF = {"GIT_CONFIG_COUNT": "1", - "GIT_CONFIG_KEY_0": "core.quotePath", - "GIT_CONFIG_VALUE_0": "false"} +#: file, and a test needs no config file of its own to say it. The `(key, +#: value)` entries `tool_env` appends to the ones the environment holds, +#: when the test runs: the environment of a session is not the one of +#: collection. +QUOTE_PATH_OFF = (("core.quotePath", "false"),) def make_file(path: Path, data: bytes = b"on: [push]\n") -> None: @@ -391,11 +392,14 @@ def leave(name: str) -> Path: assert git_bytes("status", "--porcelain", "-z", cwd=standard) == b"" -def tool_env(**extra: str) -> dict: +def tool_env(entries=(), **extra: str) -> dict: """The environment a tool under test runs in: no bytecode written into the copy (an untracked `__pycache__` would be dirt, though the standard's - own `.gitignore` names it), and `extra` on top.""" - return {"PYTHONDONTWRITEBYTECODE": "1", **extra} + own `.gitignore` names it), `entries` appended to the git command scope + the environment holds (`appended_to_command_scope`), and `extra` on + top.""" + scope = appended_to_command_scope(os.environ, entries) if entries else {} + return {"PYTHONDONTWRITEBYTECODE": "1", **scope, **extra} def revision_of(standard: Path) -> str: @@ -437,7 +441,7 @@ def init_from(standard: Path, tmp_path, env: dict, *, dry_run: bool): #: not UTF-8; and its two controls, which passed on main. DIRTY_ROWS = [ pytest.param(NOT_UTF8, QUOTE_PATH_OFF, id="not-utf8-quote-path-false"), - pytest.param(NOT_UTF8, {}, id="not-utf8-default-quoting"), + pytest.param(NOT_UTF8, (), id="not-utf8-default-quoting"), pytest.param(UTF8, QUOTE_PATH_OFF, id="utf8-quote-path-false"), ] @@ -455,7 +459,7 @@ def test_a_dirty_standard_is_warned_about_whatever_its_file_is_named( the revision the copy is at, and goes on: exit 0, and a real run makes the remotes, which the traceback never reached.""" untracked(name) - result = run(standard, tmp_path, tool_env(**config), dry_run=dry_run) + result = run(standard, tmp_path, tool_env(config), dry_run=dry_run) assert result.returncode == 0, result.stderr + result.stdout assert f"{warning}{revision_of(standard)[:12]}" in result.stderr, ( result.stderr) @@ -465,7 +469,7 @@ def test_a_dirty_standard_is_warned_about_whatever_its_file_is_named( "a dry run creates no remote and a real run does") -@pytest.mark.parametrize("config", [QUOTE_PATH_OFF, {}], +@pytest.mark.parametrize("config", [QUOTE_PATH_OFF, ()], ids=["quote-path-false", "default-quoting"]) @pytest.mark.parametrize("run, warning", TOOLS) def test_a_clean_standard_prints_no_warning(standard, tmp_path, run, warning, @@ -473,7 +477,7 @@ def test_a_clean_standard_prints_no_warning(standard, tmp_path, run, warning, """THE LAST ROW: the standard's checkout is clean, `core.quotePath=false`, and the run is exit 0 with no warning. `bool(...)` of git's bytes must still be false for an empty answer.""" - result = run(standard, tmp_path, tool_env(**config), dry_run=True) + result = run(standard, tmp_path, tool_env(config), dry_run=True) assert result.returncode == 0, result.stderr + result.stdout assert "DIRTY" not in result.stderr, result.stderr @@ -518,8 +522,8 @@ def user_name(value: str, **extra: str) -> dict: `_creator_name` passes over (and `env_commit` replaces with its own fallback, where a name of whitespace alone would reach git as it is and be refused by `git commit`).""" - return {"GIT_AUTHOR_NAME": "", "GIT_CONFIG_COUNT": "1", - "GIT_CONFIG_KEY_0": "user.name", "GIT_CONFIG_VALUE_0": value, + return {"GIT_AUTHOR_NAME": "", + **appended_to_command_scope(os.environ, [("user.name", value)]), **extra} diff --git a/tests/test_scaffold_free_text_values.py b/tests/test_scaffold_free_text_values.py index 0d8c1449..53be4cd2 100644 --- a/tests/test_scaffold_free_text_values.py +++ b/tests/test_scaffold_free_text_values.py @@ -46,8 +46,8 @@ import pytest -from conftest import (ORG, REPO, SCAFFOLD, blank_unnamed_pin_sources, - run_script) +from conftest import (ORG, REPO, SCAFFOLD, appended_to_command_scope, + blank_unnamed_pin_sources, run_script) sys.path.insert(0, str(REPO / "scripts")) from repo_shape import load_yaml # noqa: E402 @@ -219,9 +219,9 @@ def user_name(monkeypatch, value: str) -> None: (`GIT_CONFIG_COUNT`, git 2.31), which no config file overrides, and `GIT_AUTHOR_NAME` is blank so the elector falls back that far.""" monkeypatch.setenv("GIT_AUTHOR_NAME", " ") - monkeypatch.setenv("GIT_CONFIG_COUNT", "1") - monkeypatch.setenv("GIT_CONFIG_KEY_0", "user.name") - monkeypatch.setenv("GIT_CONFIG_VALUE_0", value) + for name, entry in appended_to_command_scope( + os.environ, [("user.name", value)]).items(): + monkeypatch.setenv(name, entry) CONFIG_VALUES = [ diff --git a/tests/test_scaffold_reuse_seeded_legs.py b/tests/test_scaffold_reuse_seeded_legs.py index d32eb7d0..3f77afbc 100644 --- a/tests/test_scaffold_reuse_seeded_legs.py +++ b/tests/test_scaffold_reuse_seeded_legs.py @@ -42,8 +42,9 @@ import pytest -from conftest import (ORG, PROJECT, PTY_SKIP, SCAFFOLD, WINDOWS_SKIP, git, - rmtree, run_in_a_terminal, run_script) +from conftest import (ORG, PROJECT, PTY_SKIP, SCAFFOLD, WINDOWS_SKIP, + appended_to_command_scope, git, rmtree, + run_in_a_terminal, run_script) from test_ruleset_hint_tracking_branch import refusing, template # noqa: F401 LEGS = ("spec", "code") @@ -609,11 +610,13 @@ def git_reads_github_from(base: str) -> dict: """What makes git read `https://github.com//` at `base` instead, with no credential helper and no askpass program, so git answers a leg as it would with no login of its own. The prefix is longer than an - operator's own `https://github.com/` rewrite, so it wins over one.""" - return {"GIT_CONFIG_COUNT": "2", - "GIT_CONFIG_KEY_0": f"url.{base}.insteadOf", - "GIT_CONFIG_VALUE_0": f"https://github.com/{ORG}/", - "GIT_CONFIG_KEY_1": "credential.helper", "GIT_CONFIG_VALUE_1": "", + operator's own `https://github.com/` rewrite, so it wins over one. The two + entries are appended, when this is called, to the command scope the + environment holds.""" + return {**appended_to_command_scope( + os.environ, [(f"url.{base}.insteadOf", + f"https://github.com/{ORG}/"), + ("credential.helper", "")]), "GIT_ASKPASS": ""} diff --git a/tests/test_setup_project_py.py b/tests/test_setup_project_py.py index 535aa995..92fe0310 100644 --- a/tests/test_setup_project_py.py +++ b/tests/test_setup_project_py.py @@ -37,8 +37,9 @@ import pytest -from conftest import (FILE_PROTOCOL, REPO, assert_read_through_git, - assert_store_read_through_git, git, rmtree) +from conftest import (FILE_PROTOCOL, REPO, appended_to_command_scope, + assert_read_through_git, assert_store_read_through_git, + git, rmtree) from conftest import assert_refuses_for_want_of_a_terminal @@ -1170,7 +1171,8 @@ def test_an_empty_openreposhape_repo_means_unset(tmp_path): test) was found still claiming `checked_value` refuses "an empty value" for this variable, which is never reached for one. - Offline: `GIT_CONFIG_COUNT`/`_KEY_0`/`_VALUE_0` hand this one process a + Offline: git's `GIT_CONFIG_*` entries, appended to the ones the + environment holds, hand this one process a `url..insteadOf ` rewrite, so `git clone` resolves the DEFAULT repository to THIS checkout without a network - which is what makes the echoed clone line naming the default @@ -1194,9 +1196,9 @@ def test_an_empty_openreposhape_repo_means_unset(tmp_path): cwd=outside, script=outside / "setup-project.py", extra_env={ "OPENREPOSHAPE_REPO": "", - "GIT_CONFIG_COUNT": "1", - "GIT_CONFIG_KEY_0": "url.%s.insteadOf" % REPO.as_uri(), - "GIT_CONFIG_VALUE_0": default_repo, + **appended_to_command_scope(os.environ, [ + ("url.%s.insteadOf" % REPO.as_uri(), + default_repo)]), }) assert result.returncode == 0, result.stderr + result.stdout match = re.search(r"^ git clone .*$", result.stdout, re.MULTILINE) diff --git a/tests/test_setup_sh.py b/tests/test_setup_sh.py index 312e2b71..e9c6cf63 100644 --- a/tests/test_setup_sh.py +++ b/tests/test_setup_sh.py @@ -22,7 +22,8 @@ from pathlib import Path -from conftest import REPO, WINDOWS_SKIP, assert_read_through_git, git +from conftest import (REPO, WINDOWS_SKIP, appended_to_command_scope, + assert_read_through_git, git) from conftest import assert_refuses_for_want_of_a_terminal @@ -684,7 +685,8 @@ def test_an_empty_openreposhape_repo_means_unset(tmp_path): alongside this test) was found still claiming the empty case was checked and refused rather than defaulted. - Offline: `GIT_CONFIG_COUNT`/`_KEY_0`/`_VALUE_0` hand this one process a + Offline: git's `GIT_CONFIG_*` entries, appended to the ones the + environment holds, hand this one process a `url..insteadOf ` rewrite, so `git clone` resolves the DEFAULT repository to THIS checkout without a network - which is what makes the echoed clone line naming the default @@ -704,9 +706,9 @@ def test_an_empty_openreposhape_repo_means_unset(tmp_path): cwd=outside, extra_env={ "OPENREPOSHAPE_REPO": "", - "GIT_CONFIG_COUNT": "1", - "GIT_CONFIG_KEY_0": "url.%s.insteadOf" % REPO.as_uri(), - "GIT_CONFIG_VALUE_0": default_repo, + **appended_to_command_scope(os.environ, [ + ("url.%s.insteadOf" % REPO.as_uri(), + default_repo)]), }) assert result.returncode == 0, result.stderr + result.stdout match = re.search(r"^ git clone .*$", result.stdout, re.MULTILINE) diff --git a/tests/test_siblings_family_name.py b/tests/test_siblings_family_name.py index d9cd697c..8f984e10 100644 --- a/tests/test_siblings_family_name.py +++ b/tests/test_siblings_family_name.py @@ -52,7 +52,7 @@ run, ) from test_family import ( - ALLOW_FILE_PROTOCOL, FAMILY, MEMBERS, ORG, WORK_BRANCH, scaffold_member, + FAMILY, MEMBERS, ORG, WORK_BRANCH, allow_file_protocol, scaffold_member, ) from test_stdout_encoding import child_environ, run_tool_bytes, shown @@ -96,7 +96,7 @@ def sib(holder: Path, *args: str, tmp_path: Path, standing in the holder, its stdout in `encoding`.""" return run(holder / "scripts" / "siblings.py", *args, encoding=encoding, tmp_path=tmp_path, cwd=holder, - changes={**ALLOW_FILE_PROTOCOL, **NO_CREDENTIAL, + changes={**allow_file_protocol(), **NO_CREDENTIAL, **(changes or {})}) @@ -665,7 +665,7 @@ def test_make_siblings_clones_from_the_readmes_layout(quill, tmp_path): proc = subprocess.run( [MAKE, "siblings"], cwd=str(holder), env=child_environ(tmp_path, "utf-8:strict", - {**ALLOW_FILE_PROTOCOL, **NO_CREDENTIAL}), + {**allow_file_protocol(), **NO_CREDENTIAL}), capture_output=True, stdin=subprocess.DEVNULL, check=False) out, err = (text.decode("utf-8", "replace") for text in (proc.stdout, proc.stderr))