From e2110f8f5697f6601be781fa332e1ce4a0f8474b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 12:12:32 +0000 Subject: [PATCH 1/6] tests/conftest.py: the session turns git's automatic maintenance off, in command scope and in a global config that includes the person's own, so a `copytree` of a fixture no longer meets a detached repack deleting its loose objects (#645) `shutil.copytree` walks a repository's `objects/` one file at a time, and the suite copies a repository a fixture has just committed in, fetched into or pushed to in 139 places: `project`, `test_family.py`'s `holder`, ten copies of bare remotes and two of a `.git` directory. Every `git commit`, `merge` and `fetch`, and the `receive-pack` a push starts in the remote, runs `git maintenance run --auto` as it finishes. Since git 2.47 that run detaches and holds `objects/maintenance.lock`; since git 2.54 its default `geometric` strategy repacks a small repository and deletes the loose objects it packed. A copy taken in that moment dies with `[Errno 2]`, as `holder` did on the Linux runner. A session autouse fixture, `no_auto_maintenance`, sets `gc.auto=0` and `maintenance.auto=false` in two scopes: - command scope (`GIT_CONFIG_COUNT`), appended after the entries the environment already holds: every git the suite or a tool under test starts inherits it, a `git submodule`'s children too, and it outranks every config file; - a global config file (`GIT_CONFIG_GLOBAL`) that includes the global config git would read without it and sets the two keys after it, because git starts the `receive-pack` of a push to a local path or a `file://` URL with `GIT_CONFIG_COUNT` and `GIT_CONFIG_PARAMETERS` removed. `tests/test_no_auto_maintenance.py` asks git what it starts and reads, through trace2 events and a `post-receive` hook: a commit, a fetch and a merge start no maintenance, with the session's global file and with a test's own; the scaffold and the remotes it pushes to start none; a remote's `receive-pack` reads both settings off; the entries are appended; and a person's own global config is still read beneath them. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 108 +++++++++++++++- tests/test_no_auto_maintenance.py | 204 ++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 tests/test_no_auto_maintenance.py diff --git a/tests/conftest.py b/tests/conftest.py index 4bc356f8..d357227c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -82,7 +82,10 @@ def clone_just_pushed(remote: Path, dest: Path, loose objects and deletes them mid-walk, and the clone dies `failed to copy file to '/.git/objects/..': No such file or directory` (the macOS runner, twice in 90 minutes on 2026-10-12). Upload-pack reads the - object store, which a repack does not break. + object store, which a repack does not break. The session now keeps that + maintenance from starting (`no_auto_maintenance` below, #645), and the + clone still reads through git: a test that gives git a global config of + its own takes the session's away from the pushes it makes. `--no-local` does that for the root. The two leg clones `--recurse-submodules` makes are git's own, from the plain path @@ -135,6 +138,109 @@ def cloned_just_pushed(remote: Path, dest: Path, branch: str | None = None, return Path(dest) +# --- git's automatic maintenance, OFF for every git the session starts (#645) +# +# A COPY IS A WALK OF THE FILES TOO. `shutil.copytree` reads `objects/` one +# file at a time, as git's local clone does (`clone_just_pushed` above), and +# this suite copies a repository a fixture has just committed in, fetched +# into or pushed to in over a hundred places: `project`, `test_family.py`'s +# `holder`, a bare remote. +# `git commit`, `merge` and `fetch`, and the `receive-pack` a push starts in +# 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 +# it packed. A copy taken in that moment fails `[Errno 2] No such file or +# directory` on one of them: `holder`, on the Linux runner (#645). +# +# 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 +# `receive-pack` does), and `maintenance.auto=false`. In TWO scopes, because +# neither reaches every git alone: +# +# - COMMAND SCOPE (`GIT_CONFIG_COUNT`), APPENDED to what the environment +# holds (`appended_to_command_scope`; the Windows job holds two): every git +# this process and every tool under test starts inherits it, and so does +# each git a `git submodule` starts. It outranks every config file, a +# repository's own and a test's own global file included. +# - A GLOBAL FILE (`GIT_CONFIG_GLOBAL`), for the one git that never sees +# command scope: a push to a local path or a `file://` URL starts +# `receive-pack` in the remote with `GIT_CONFIG_COUNT` and +# `GIT_CONFIG_PARAMETERS` removed, and that process runs the remote's +# maintenance. The file INCLUDES the global config git would read without +# it -- the file `GIT_CONFIG_GLOBAL` already named, or +# `$XDG_CONFIG_HOME/git/config` then `~/.gitconfig` -- so a person's own +# settings still apply, and sets the two keys after them. +# +# WHAT IT LEAVES: a git whose environment takes away both -- a +# `GIT_CONFIG_COUNT` overwritten rather than appended to, together with a +# `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. + +#: The two settings, in the order they are appended and written. +NO_AUTO_MAINTENANCE = (("gc.auto", "0"), ("maintenance.auto", "false")) + +#: The session's global config file, directly under the basetemp. +GLOBAL_CONFIG_NAME = "gitconfig" + + +def _config_quoted(path: str) -> str: + """`path` as a double-quoted git config value, in which a backslash and a + double quote are escapes.""" + return '"' + path.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def appended_to_command_scope(environ: Mapping[str, str], + entries=NO_AUTO_MAINTENANCE) -> dict: + """The `GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_` and `GIT_CONFIG_VALUE_` + that put `entries` in git's command scope AFTER the ones `environ` holds, + which stay as they are: a later entry of a key is the one git reads.""" + held = int(environ.get("GIT_CONFIG_COUNT") or 0) + added = {"GIT_CONFIG_COUNT": str(held + len(entries))} + for index, (key, value) in enumerate(entries, held): + added[f"GIT_CONFIG_KEY_{index}"] = key + added[f"GIT_CONFIG_VALUE_{index}"] = value + return added + + +def session_global_config(named: str | None) -> bytes: + """The global config file `no_auto_maintenance` writes: an include of + what git reads as its global config without one -- `named`, the file + `GIT_CONFIG_GLOBAL` held, or else git's two default files in git's own + order -- and then `NO_AUTO_MAINTENANCE`. LF, and every path POSIX-style: + `~/` is expanded by git, from the `HOME` of the git that reads it.""" + if named: + included = [Path(os.path.abspath(named)).as_posix()] + else: + xdg = os.environ.get("XDG_CONFIG_HOME") + included = [(Path(xdg) / "git" / "config").as_posix() if xdg + else "~/.config/git/config", "~/.gitconfig"] + lines = ["[include]"] + lines += [f"\tpath = {_config_quoted(path)}" for path in included] + for key, value in NO_AUTO_MAINTENANCE: + section, name = key.split(".") + lines += [f"[{section}]", f"\t{name} = {value}"] + return ("\n".join(lines) + "\n").encode("utf-8") + + +@pytest.fixture(scope="session", autouse=True) +def no_auto_maintenance(tmp_path_factory): + """Git's automatic maintenance off in `os.environ` for the whole session, + in both scopes the block above describes; returns the global file. Autouse + and session scope, so it is in place before any session or module fixture + builds a repository, and put back as it was at the end.""" + config = tmp_path_factory.getbasetemp() / GLOBAL_CONFIG_NAME + config.write_bytes(session_global_config( + os.environ.get("GIT_CONFIG_GLOBAL"))) + with pytest.MonkeyPatch.context() as patch: + for name, value in {**appended_to_command_scope(os.environ), + "GIT_CONFIG_GLOBAL": str(config)}.items(): + patch.setenv(name, value) + yield config + + def load_without_bytecode(path: Path, name: str): """`path` run as a module called `name`, and no bytecode written for it (#544; it is `load()` of `test_bootstrap_spelling.py`, #456, moved here). diff --git a/tests/test_no_auto_maintenance.py b/tests/test_no_auto_maintenance.py new file mode 100644 index 00000000..33d55c11 --- /dev/null +++ b/tests/test_no_auto_maintenance.py @@ -0,0 +1,204 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Git's automatic maintenance is off for every git the suite starts (#645). + +The suite copies a repository file by file (`shutil.copytree`) a moment +after a fixture committed in it, fetched into it or pushed to it, and the +maintenance git starts as each of those finishes can be packing that +repository in the background, deleting the loose objects the copy is +reading. `tests/conftest.py`'s `no_auto_maintenance` turns `gc.auto` and +`maintenance.auto` off for the session, in git's command scope and in a +global config file. Each test here asks git what it starts or reads, so +none of them waits for a race and each holds on any git: + +- a commit, a fetch and a merge run as a fixture runs them start no `git + maintenance` and no `git gc`, with the session's global file and with a + test's own (git's trace2 events name every child a git starts); +- `scaffold-project.py` into local bare remotes, a tool that starts its own + gits, starts no `git maintenance` anywhere, the `receive-pack` its pushes + start in the remotes included; +- the `receive-pack` a push into a local remote starts, which never sees + command scope, reads both settings off: a hook in the remote reports them; +- the two are appended to the command scope the environment holds, and a + person's own global config is still read, in git's order, beneath them. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from conftest import (NO_AUTO_MAINTENANCE, ORG, PROJECT, SCAFFOLD, + appended_to_command_scope, git, run_script, + session_global_config) + +#: The verbs auto maintenance runs as: `git maintenance run --auto`, and +#: `git gc --auto`, which a git started itself before `maintenance` existed. +MAINTENANCE_VERBS = ("maintenance", "gc") + +#: The two settings as a `receive-pack` must read them, one `key=value` a +#: line: spelled out, not read from `NO_AUTO_MAINTENANCE`, so a change to +#: that table is a change to this test too. +BOTH_OFF = b"gc.auto=0\nmaintenance.auto=false\n" + + +def traced(monkeypatch, tmp_path: Path) -> Path: + """The file git's trace2 events go to for the rest of the test, from + every git the test starts and every git those start: one JSON object a + line.""" + trace = tmp_path / "trace2.json" + monkeypatch.setenv("GIT_TRACE2_EVENT", str(trace)) + return trace + + +def events(trace: Path) -> list: + return [json.loads(line) + for line in trace.read_bytes().decode("utf-8").splitlines() + if line.strip()] + + +def ran(trace: Path) -> set: + """The verb of every git that wrote to `trace`.""" + return {event["name"] for event in events(trace) + if event.get("event") == "cmd_name"} + + +def started(trace: Path, verbs=MAINTENANCE_VERBS) -> list: + """The argv of every git child a git in `trace` started as one of + `verbs`: `["git", "maintenance", "run", "--auto", ...]`.""" + found = [] + for event in events(trace): + argv = event.get("argv") or [] + if event.get("event") == "child_start" and argv[1:2] and \ + argv[1] in verbs: + found.append(argv) + return found + + +def asked(*args: str, cwd: Path) -> str: + """`git config `'s answer, outside any repository.""" + return git("config", *args, cwd=cwd, check=False).stdout.strip() + + +@pytest.fixture +def behind(tmp_path) -> Path: + """A clone of a local bare remote that has moved on by a commit, as a + fixture leaves one: `fetch` brings something and `merge` makes a + merge commit.""" + remote, work, other = (tmp_path / "remote.git", tmp_path / "work", + tmp_path / "other") + git("init", "-q", "--bare", "-b", "main", str(remote), cwd=tmp_path) + git("init", "-q", "-b", "main", str(other), cwd=tmp_path) + git("commit", "-q", "--allow-empty", "-m", "one", cwd=other) + git("push", "-q", str(remote), "main", cwd=other) + git("clone", "-q", "--no-local", str(remote), str(work), cwd=tmp_path) + git("commit", "-q", "--allow-empty", "-m", "two", cwd=other) + git("push", "-q", str(remote), "main", cwd=other) + return work + + +@pytest.mark.parametrize("own_global", [False, True], + ids=["the-session-global", "a-tests-own-global"]) +def test_a_commit_a_fetch_and_a_merge_start_no_maintenance( + behind, tmp_path, monkeypatch, own_global): + """On the base each of the three started `git maintenance run --auto` + (git 2.43). With a global config of the test's own, the command scope + alone keeps it off.""" + if own_global: + empty = tmp_path / "own.gitconfig" + empty.write_bytes(b"") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(empty)) + trace = traced(monkeypatch, tmp_path) + git("commit", "-q", "--allow-empty", "-m", "three", cwd=behind) + git("fetch", "-q", "origin", cwd=behind) + git("merge", "-q", "--no-ff", "-m", "merge", "origin/main", cwd=behind) + assert {"commit", "fetch", "merge"} <= ran(trace), ran(trace) + assert started(trace) == [] + + +def test_a_tool_and_the_remotes_it_pushes_to_start_no_maintenance( + tmp_path, monkeypatch): + """The scaffold commits in three working trees and pushes each into a + bare remote, where `receive-pack` runs that remote's maintenance. On the + base its commits started `git maintenance run --auto`. A `git gc --auto` + child is not counted: 2.43's `receive-pack` starts one whatever the + settings say, and `gc.auto=0`, which the next test shows it reads, makes + it do nothing.""" + trace = traced(monkeypatch, tmp_path) + result = run_script( + SCAFFOLD, "--org", ORG, "--project", PROJECT, + "--elected-by", "Test Human", "--elected-on", "2026-09-02", + "--local-remote-dir", str(tmp_path / "remotes"), + "--work-dir", str(tmp_path / "work")) + assert result.returncode == 0, result.stderr + result.stdout + assert {"commit", "receive-pack"} <= ran(trace), ran(trace) + assert started(trace, ("maintenance",)) == [] + + +def test_the_receive_pack_of_a_push_to_a_local_remote_reads_both_off( + tmp_path): + """Git starts `receive-pack` for a push to a path with no command scope, + so only the session's global file reaches it. Its `post-receive` hook + runs under it and writes down what git reads there. On the base it read + neither.""" + remote, work = tmp_path / "remote.git", tmp_path / "work" + git("init", "-q", "--bare", "-b", "main", str(remote), cwd=tmp_path) + hook = remote / "hooks" / "post-receive" + hook.write_bytes( + b"#!/bin/sh\nfor key in gc.auto maintenance.auto; do\n" + b' printf "%s=%s\\n" "$key" "$(git config --get "$key")"\n' + b"done > read-by-receive-pack\n") + hook.chmod(0o755) + git("init", "-q", "-b", "main", str(work), cwd=tmp_path) + git("commit", "-q", "--allow-empty", "-m", "one", cwd=work) + git("push", "-q", str(remote), "main", cwd=work) + assert (remote / "read-by-receive-pack").read_bytes() == BOTH_OFF + + +def test_the_two_are_appended_to_the_command_scope_the_environment_holds( + tmp_path): + """The Windows job holds two entries in `GIT_CONFIG_COUNT` for the whole + run, and a count that started again at 0 would drop both.""" + held = {"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"} + added = appended_to_command_scope(held) + assert added["GIT_CONFIG_COUNT"] == "4", added + env = {**os.environ, **held, **added} + lines = [subprocess.run(["git", "config", "--get", key], cwd=str(tmp_path), + capture_output=True, text=True, env=env, + check=False).stdout + for key in ("test.heldone", "test.heldtwo", + *(key for key, _ in NO_AUTO_MAINTENANCE))] + assert lines == ["one\n", "two\n", + *(f"{value}\n" for _, value in NO_AUTO_MAINTENANCE)] + + +@pytest.mark.parametrize("included", ["~/.gitconfig", "~/.config/git/config"]) +def test_a_persons_own_global_config_is_still_read_beneath_the_two( + included, no_auto_maintenance, tmp_path, monkeypatch): + """The session's file includes the global config git would read without + it, `~/` expanded from the `HOME` of the git that reads it, and sets the + two settings after it: a person's own values are read, and their own + `maintenance.auto = true` does not turn maintenance back on. A case is + skipped where the file includes another path in its place, which a test + cannot write: the session began with `GIT_CONFIG_GLOBAL` set, or, for + the second, with `XDG_CONFIG_HOME` set.""" + if no_auto_maintenance.read_bytes() != session_global_config(None): + pytest.skip("the session began with GIT_CONFIG_GLOBAL set, and its " + "file includes that one") + if included.startswith("~/.config/") and os.environ.get("XDG_CONFIG_HOME"): + pytest.skip("XDG_CONFIG_HOME is set, and the file includes the git " + "config under it") + monkeypatch.setenv("HOME", str(tmp_path)) + own = tmp_path.joinpath(*included[2:].split("/")) + own.parent.mkdir(parents=True, exist_ok=True) + own.write_bytes(b"[openreposhape]\n\tprobe = " + included.encode() + + b"\n[maintenance]\n\tauto = true\n[gc]\n\tauto = 6700\n") + assert asked("--get", "openreposhape.probe", cwd=tmp_path) == included + for key, value in NO_AUTO_MAINTENANCE: + assert asked("--global", "--includes", "--get", key, + cwd=tmp_path) == value, key From accc7d2afe0610db22ae4886c43adc7c6e09d495 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 12:33:34 +0000 Subject: [PATCH 2/6] tests/conftest.py: the note on the session's maintenance settings names `test_doctor_pin_restore_merges.py`'s `the_standard_moved` beside `holder`, and says a `git config --global` read without `--includes` sees the two alone (#645) Lane 2 met the same race on #623's Linux run (run 38895011713): a setup error in `test_copies_neither_side_edited_are_taken_whole_with_the_pin`, `shutil.Error ... standard5/openRepoShape/.git/objects/1e ... [Errno 2]`. `the_standard_moved` (`tests/test_doctor_pin_restore_merges.py:99`) copies the session's `standard`, which `test_shape_doctor.py` commits in, and at :102 copies `moved` just after `moved_on` commits in it. Both commits run under `{**os.environ, ...}`, so the session's command scope already turns their maintenance off; the note now names the site. `git config --global ` reads the global file without following its includes unless `--includes` is given, so under the session's file it sees `gc.auto` and `maintenance.auto` and nothing of `~/.gitconfig`. The one such read in this repository is `setup-project.py`'s Windows-only `core.autocrlf` warning, and no test reads that from a person's own config. A read with no scope, the kind git itself makes, follows the include. Comment only. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d357227c..5960f06b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -144,14 +144,16 @@ def cloned_just_pushed(remote: Path, dest: Path, branch: str | None = None, # file at a time, as git's local clone does (`clone_just_pushed` above), and # this suite copies a repository a fixture has just committed in, fetched # into or pushed to in over a hundred places: `project`, `test_family.py`'s -# `holder`, a bare remote. +# `holder`, `test_doctor_pin_restore_merges.py`'s copies of the session's +# `standard` (`the_standard_moved`), a bare remote. # `git commit`, `merge` and `fetch`, and the `receive-pack` a push starts in # 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 # it packed. A copy taken in that moment fails `[Errno 2] No such file or -# directory` on one of them: `holder`, on the Linux runner (#645). +# directory` on one of them: `holder` and `the_standard_moved`, on the +# Linux runner (#645). # # 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 @@ -170,7 +172,10 @@ def cloned_just_pushed(remote: Path, dest: Path, branch: str | None = None, # maintenance. The file INCLUDES the global config git would read without # it -- the file `GIT_CONFIG_GLOBAL` already named, or # `$XDG_CONFIG_HOME/git/config` then `~/.gitconfig` -- so a person's own -# settings still apply, and sets the two keys after them. +# settings still apply, and sets the two keys after them. A `git config +# --global ` read without `--includes` sees the two alone: in this +# repository that is `setup-project.py`'s Windows-only `core.autocrlf` +# warning, which no test reads from a person's own config. # # WHAT IT LEAVES: a git whose environment takes away both -- a # `GIT_CONFIG_COUNT` overwritten rather than appended to, together with a From 4149cbb5fce2ccef18a88896f0bccb093a058665 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 17:22:19 +0000 Subject: [PATCH 3/6] tests/conftest.py and four test files: one helper builds the `url..insteadOf` rewrite that `clone_just_pushed`, `read_through_git` and the adopt tests' `pasted` each built, and the `--init` of a mount git keeps no repository for goes through it, so a submodule's clone no longer walks a just-pushed remote's files (#662) `insteadof_prefixes(remotes, typed=False)` is the one list of prefixes: the directory resolved, in both of a Windows path's spellings, each with a separator after it, and the directory as typed first when asked. `insteadof_entries` pairs each with the one `url..insteadOf` key, and `insteadof_args` is the same as the `-c` arguments of one git command. `clone_just_pushed` passes the arguments, `read_through_git` appends the entries to the `GIT_CONFIG_*` the environment already holds (as `appended_to_command_scope` does for the session's own, so a pasted line keeps `no_auto_maintenance` in command scope; it overwrote the count before), and `pasted` in `test_adopt_e2e.py`, a third copy of the rule, takes the arguments too. `submodule_update_through_git(root, remotes, *options)` is the wrapper beside `clone_just_pushed` for the `--init` of an empty mount. A trace of each `submodule update` site (GIT_TRACE, before and after) shows which ones clone: `test_family_bump_remove_rollback.py` at 337 and 908 (both legs of a member), `test_doctor_legs_members_next.py` at 631 (the `code` leg, its store removed by `unbootstrap`), 987 (the member and its two legs) and 1054 (the member's two legs, through `with_its_own_legs`) ran `git clone` of a plain path and no `pack-objects`; they now run the transport (`pack-objects` and `index-pack` once per clone). `test_doctor_gitlink_repairs.py:343` and `test_doctor_unfinished_merge.py:145,171` clone and fetch nothing, so they are unchanged, and so is the deliberate failure probe at `test_doctor_legs_members_next.py:607`. `tests/test_clone_just_pushed.py` holds the helper to its list for a typed and a resolved directory, given with and without a trailing separator, asks git (`ls-remote --get-url`) what the `-c` form and the environment form do to a plain path and to a neighbour that starts with the same text, shows the environment form keeps the entries the environment holds, and reads an `--init` through the wrapper as one pack against a plain `--init` that leaves the remote's loose objects loose. The change is test only. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- tests/conftest.py | 116 ++++++++++++---- tests/test_adopt_e2e.py | 24 ++-- tests/test_clone_just_pushed.py | 153 +++++++++++++++++++++- tests/test_doctor_legs_members_next.py | 38 +++--- tests/test_family_bump_remove_rollback.py | 10 +- 5 files changed, 280 insertions(+), 61 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5960f06b..81446dc0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,6 +68,60 @@ FILE_PROTOCOL = ["-c", "protocol.file.allow=always"] +# --- one rewrite, for every read of a just-pushed local remote (#440, #662) -- +# +# A clone of a plain path is git's local clone, which walks the source's +# `objects/` one file at a time; a clone of a `file://` URL asks upload-pack, +# which reads the object store (`clone_just_pushed` says why that matters). +# What turns the first into the second for the legs a submodule clones from +# the plain paths `.gitmodules` records is a `url..insteadOf` +# rewrite, and it is written HERE, once, in three forms: the prefixes, the +# `(key, prefix)` entries, and the `-c` arguments of one git command. +# `read_through_git` below puts the entries in the environment instead. + +def insteadof_prefixes(remotes: Path | str, *, + typed: bool = False) -> list[str]: + """The prefixes a `url..insteadOf` rewrite names so that git + reads the bare remotes under `remotes` through the transport and not + through their files: the directory with a separator after it, so a + rewrite of `/` does not reach a neighbour called + `-other`, and a `remotes` given with or without a trailing + separator gives the same list. + + RESOLVED, always: `scaffold-project.py` writes `.gitmodules`'s leg URLs + from the directory resolved, so that is where a submodule's clone starts, + spelled with the system's separator and with a slash, which are two + prefixes on Windows and one elsewhere. TYPED, on request: the directory + as the caller spelled it, made absolute and a symlink in it kept, which is + how a line a tool prints names a remote; it comes first. None is listed + twice.""" + resolved = Path(remotes).resolve() + directories = [Path(os.path.abspath(remotes)), resolved] if typed \ + else [resolved] + return list(dict.fromkeys( + spelling for directory in directories + for spelling in (os.path.join(str(directory), ""), + directory.as_posix().rstrip("/") + "/"))) + + +def insteadof_entries(remotes: Path | str, *, + typed: bool = False) -> list[tuple[str, str]]: + """`(key, prefix)` for each of `insteadof_prefixes`: the key is the one + `url..insteadOf` that sends them all + to the same `file://` URL.""" + uri = Path(remotes).resolve().as_uri().rstrip("/") + "/" + return [(f"url.{uri}.insteadOf", prefix) + for prefix in insteadof_prefixes(remotes, typed=typed)] + + +def insteadof_args(remotes: Path | str, *, typed: bool = False) -> list[str]: + """`insteadof_entries` as the `-c =` pairs of one git + command. A `-c` is not stored: a clone made with it keeps the plain path + it was given as its `origin`, and a submodule its `.gitmodules` URL.""" + return [arg for key, prefix in insteadof_entries(remotes, typed=typed) + for arg in ("-c", f"{key}={prefix}")] + + def clone_just_pushed(remote: Path, dest: Path, branch: str | None = None, *, recurse: bool = True, bare: bool = False, config: Sequence[str] = (), @@ -93,7 +147,9 @@ def clone_just_pushed(remote: Path, dest: Path, `url..insteadOf` for the one command: a `-c` is not stored, so the clone's own `origin` and `submodule..url` keep the plain paths. The prefix is the directory RESOLVED, as `scaffold-project.py` writes it into - `.gitmodules`, in both of a Windows path's spellings. + `.gitmodules`, in both of a Windows path's spellings + (`insteadof_prefixes`, shared with `read_through_git` and + `submodule_update_through_git`). `branch` is `-b `: `adopt-project.py execute` pushes its split branch into the SOURCE, a plain repository, which is then `remote`. @@ -110,12 +166,7 @@ def clone_just_pushed(remote: Path, dest: Path, names a path, and a project's name can be any text. """ remote = Path(remote) - remotes = remote.parent.resolve() - uri = remotes.as_uri().rstrip("/") + "/" - prefixes = dict.fromkeys([os.path.join(str(remotes), ""), - remotes.as_posix().rstrip("/") + "/"]) - rewrites = [arg for prefix in prefixes - for arg in ("-c", f"url.{uri}.insteadOf={prefix}")] + rewrites = insteadof_args(remote.parent) if bare: mode = ["--bare"] else: @@ -138,6 +189,30 @@ def cloned_just_pushed(remote: Path, dest: Path, branch: str | None = None, return Path(dest) +def submodule_update_through_git(root: Path, remotes: Path, + *options: str + ) -> subprocess.CompletedProcess: + """`git submodule update ` in `root`, failing the test where it + fails (`git` below), with each mount it CLONES read through git and not + through the remote's files (#662, #440). + + An `--init` of a mount git keeps no repository for is git's local clone of + the plain path `.gitmodules` records for it: the walk of the remote's + `objects/` that `clone_just_pushed` keeps off, taken by a submodule. + So the plain paths under `remotes` are rewritten to `file://` for the one + command, as `clone_just_pushed` rewrites them for its legs, and the file + transport is allowed, as every test that clones a local path allows it. + `options` follow `update`: `("-q", "--init")`, `("--init", "--recursive", + "--", "code")`. + + An `update` over a mount whose repository git already keeps FETCHES, and + a fetch asks upload-pack whatever the URL is: a call that only brings + such a mount to its gitlink needs neither the rewrite nor this wrapper. + """ + return git(*FILE_PROTOCOL, *insteadof_args(remotes), + "submodule", "update", *options, cwd=root) + + # --- git's automatic maintenance, OFF for every git the session starts (#645) # # A COPY IS A WALK OF THE FILES TOO. `shutil.copytree` reads `objects/` one @@ -1503,21 +1578,18 @@ def read_through_git(remotes: Path) -> dict: """The environment that makes every git a pasted line starts read the bare repositories in `remotes` through git, not through their files (#440): `clone_just_pushed`'s `url..insteadOf` rewrites, as - `GIT_CONFIG_COUNT` entries, because the line under test is run exactly - as it was printed and a submodule's own clone inherits the environment. - The directory as typed and as resolved, so the root's URL and the legs' - `.gitmodules` URLs are both rewritten.""" - uri = remotes.resolve().as_uri().rstrip("/") + "/" - prefixes = list(dict.fromkeys( - spelling for directory in (Path(os.path.abspath(remotes)), - remotes.resolve()) - for spelling in (os.path.join(str(directory), ""), - directory.as_posix().rstrip("/") + "/"))) - env = {"GIT_CONFIG_COUNT": str(len(prefixes))} - for index, prefix in enumerate(prefixes): - env[f"GIT_CONFIG_KEY_{index}"] = f"url.{uri}.insteadOf" - env[f"GIT_CONFIG_VALUE_{index}"] = prefix - return env + `GIT_CONFIG_*` entries, because the line under test is run exactly as it + was printed and a submodule's own clone inherits the environment. The + directory as typed and as resolved (`insteadof_entries`), so the root's + URL and the legs' `.gitmodules` URLs are both rewritten. + + The entries are APPENDED to the ones `os.environ` holds + (`appended_to_command_scope`), which every caller lays this over, so the + session's own (`no_auto_maintenance`'s, and the two the Windows job sets) + still reach the pasted line, as they reach every other git a test + starts.""" + return appended_to_command_scope( + os.environ, insteadof_entries(remotes, typed=True)) def pasted(lines, cwd: Path, diff --git a/tests/test_adopt_e2e.py b/tests/test_adopt_e2e.py index 8295c4ae..9072892d 100644 --- a/tests/test_adopt_e2e.py +++ b/tests/test_adopt_e2e.py @@ -11,7 +11,6 @@ from __future__ import annotations import importlib.util -import os import re import shlex import shutil @@ -21,8 +20,8 @@ import pytest -from conftest import (ADOPT, REPO, cloned_just_pushed, git, make_source_repo, - resolve, run_script, write_plan) +from conftest import (ADOPT, REPO, cloned_just_pushed, git, insteadof_args, + make_source_repo, resolve, run_script, write_plan) sys.path.insert(0, str(REPO / "scripts")) from repo_shape import load_yaml # noqa: E402 @@ -243,21 +242,16 @@ def pasted(line: str, cwd: Path, remotes: Path) -> subprocess.CompletedProcess: object files one by one, and maintenance a push left running can pack them away mid-copy (`failed to copy file`, on the macOS runner). So `--no-local` reads the root through git, and each leg's plain path under - `remotes`, as `.gitmodules` records it (the directory resolved, in both - of a Windows path's spellings), is read as its `file://` URI. Every - printed argument is kept as printed, `-c protocol.file.allow=always` - among them; a URI is the `file` transport too, so a line without it is - refused here as it is refused pasted.""" + `remotes`, as `.gitmodules` records it (`insteadof_args`: the directory + resolved, in both of a Windows path's spellings), is read as its + `file://` URI. Every printed argument is kept as printed, `-c + protocol.file.allow=always` among them; a URI is the `file` transport + too, so a line without it is refused here as it is refused pasted.""" argv = shlex.split(line) - resolved = remotes.resolve() - uri = resolved.as_uri().rstrip("/") + "/" - prefixes = dict.fromkeys([os.path.join(str(resolved), ""), - resolved.as_posix().rstrip("/") + "/"]) at = argv.index("clone") return subprocess.run( - [*argv[:at], *(arg for prefix in prefixes - for arg in ("-c", f"url.{uri}.insteadOf={prefix}")), - "clone", "--no-local", *argv[at + 1:]], + [*argv[:at], *insteadof_args(remotes), "clone", "--no-local", + *argv[at + 1:]], cwd=str(cwd), capture_output=True, encoding="utf-8", errors="replace", check=False) diff --git a/tests/test_clone_just_pushed.py b/tests/test_clone_just_pushed.py index 8cd617eb..a4bdb997 100644 --- a/tests/test_clone_just_pushed.py +++ b/tests/test_clone_just_pushed.py @@ -10,17 +10,29 @@ no loose object, which a clone that walked the remote's own files would not (`assert_store_read_through_git`). The option itself is asked of git beside it, and the last case is the clone that fails. + +#662 wrote the rewrite those clones carry once (`insteadof_prefixes`, its +`(key, prefix)` entries and its `-c` arguments) and gave its other two users +the same list: `read_through_git`, which puts it in the environment of a +pasted line, and `submodule_update_through_git`, for the `--init` of a mount a +test brings up after the clone. The cases at the end ask git what each form +does to a plain path, with `git ls-remote --get-url`, which expands a URL as a +fetch would and talks to nothing. """ from __future__ import annotations import os +import subprocess +from pathlib import Path import pytest -from conftest import (PROJECT, assert_read_through_git, +from conftest import (FILE_PROTOCOL, PROJECT, assert_read_through_git, assert_store_read_through_git, clone_just_pushed, - cloned_just_pushed, git) + cloned_just_pushed, git, insteadof_args, + insteadof_entries, insteadof_prefixes, read_through_git, + submodule_update_through_git) @pytest.fixture @@ -100,3 +112,140 @@ def test_a_clone_that_fails_fails_the_test_and_says_why(tmp_path): assert clone_just_pushed(missing, tmp_path / "clone").returncode != 0 with pytest.raises(AssertionError, match="git clone of .* failed"): cloned_just_pushed(missing, tmp_path / "clone") + + +# --- the rewrite, written once (#662) ---------------------------------------- + +def spellings(directory: Path) -> set[str]: + """The prefixes of an existing `directory` as a person would write them: + the system's separator after it, and a slash. One on POSIX, two on + Windows. Written out here and not read from the helper.""" + return {str(directory) + os.sep, directory.as_posix() + "/"} + + +def get_url(url: str, *config: str, env: dict | None = None, + cwd: Path) -> str: + """What git makes of `url` as a fetch would, `insteadOf` rewrites + included, and talks to nothing.""" + return subprocess.run(["git", *config, "ls-remote", "--get-url", url], + cwd=str(cwd), capture_output=True, text=True, + check=True, env=env).stdout.strip() + + +@pytest.fixture +def remotes(tmp_path) -> Path: + directory = tmp_path / "remotes" + directory.mkdir() + return directory + + +@pytest.mark.parametrize("typed", [False, True], ids=["resolved", "typed"]) +@pytest.mark.parametrize("trailing", ["", "/", os.sep], + ids=["none", "slash", "separator"]) +def test_a_directory_is_named_with_a_separator_after_it_given_or_not( + remotes, typed, trailing): + """The prefix ends with a separator whether the directory was given with + one or without, so a rewrite of `/` never reaches a neighbour + that begins with the same text, and each spelling is listed once. A + directory with no link in its path is typed and resolved at once.""" + prefixes = insteadof_prefixes(str(remotes) + trailing, typed=typed) + assert set(prefixes) == spellings(remotes.resolve()), prefixes + assert len(prefixes) == len(set(prefixes)), prefixes + assert prefixes == insteadof_prefixes(remotes, typed=typed), prefixes + + +def test_a_typed_directory_adds_the_spelling_it_was_given_first(tmp_path): + """TYPED: a symlink kept, as a line a tool prints names a remote. The + resolved directory is the one `.gitmodules` records, and stays.""" + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + try: + link.symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("this account cannot make a symlink") + resolved = spellings(real.resolve()) + typed = spellings(Path(os.path.abspath(link))) + assert typed.isdisjoint(resolved), "premise: a link that is not its target" + assert set(insteadof_prefixes(link)) == resolved + prefixes = insteadof_prefixes(link, typed=True) + assert set(prefixes[:len(typed)]) == typed, prefixes + assert set(prefixes) == typed | resolved, prefixes + assert len(prefixes) == len(set(prefixes)), prefixes + # Both go to the one URL: the resolved directory's, where the legs are. + uri = real.resolve().as_uri().rstrip("/") + "/" + assert {key for key, _ in insteadof_entries(link, typed=True)} == { + f"url.{uri}.insteadOf"} + + +def test_every_entry_sends_its_prefix_to_the_resolved_directorys_uri( + remotes): + uri = remotes.resolve().as_uri().rstrip("/") + "/" + entries = insteadof_entries(remotes, typed=True) + assert {key for key, _ in entries} == {f"url.{uri}.insteadOf"}, entries + assert [prefix for _, prefix in entries] == insteadof_prefixes( + remotes, typed=True) + assert insteadof_args(remotes, typed=True) == [ + arg for key, prefix in entries for arg in ("-c", f"{key}={prefix}")] + + +def test_the_arguments_and_the_environment_send_a_plain_path_to_its_uri( + remotes, tmp_path): + """Both forms of the one rewrite do to a path under `remotes` what + `clone_just_pushed` needs, and leave a neighbour's path as it is.""" + plain = str(remotes / f"{PROJECT}.git") + neighbour = str(remotes) + f"-other/{PROJECT}.git" + uri = (remotes.resolve() / f"{PROJECT}.git").as_uri() + environ = {**os.environ, **read_through_git(remotes)} + assert get_url(plain, *insteadof_args(remotes), cwd=tmp_path) == uri + assert get_url(plain, env=environ, cwd=tmp_path) == uri + assert get_url(neighbour, *insteadof_args(remotes), + cwd=tmp_path) == neighbour + assert get_url(neighbour, env=environ, cwd=tmp_path) == neighbour + + +def test_the_environment_form_keeps_what_the_environment_holds( + remotes, tmp_path, monkeypatch): + """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://") + entries = read_through_git(remotes) + assert "GIT_CONFIG_KEY_0" not in entries, entries + assert entries["GIT_CONFIG_COUNT"] == str( + 1 + len(insteadof_prefixes(remotes, typed=True))), entries + environ = {**os.environ, **entries} + assert get_url("held://x", env=environ, cwd=tmp_path) == "held://base/x" + assert get_url(str(remotes / "A.git"), env=environ, cwd=tmp_path) == ( + remotes.resolve() / "A.git").as_uri() + + +def test_an_init_of_an_empty_mount_is_read_through_git_by_the_wrapper( + scaffolded, remote, tmp_path): + """#662. The mount is empty and git keeps no repository for it, so its + `--init` is a clone of the plain path `.gitmodules` records. Through + `submodule_update_through_git` the leg arrives as one pack with no loose + object, and the `submodule..url` git registers is still the plain + path, as `-c` stores nothing. The same `--init` of the other leg, run + plain, is git's local clone and leaves the remote's loose objects loose: + the control that makes the first half mean something.""" + clone = cloned_just_pushed(remote, tmp_path / "clone", recurse=False) + assert not any((clone / "spec").iterdir()), "premise: an empty mount" + assert not any((clone / "code").iterdir()), "premise: an empty mount" + + submodule_update_through_git(clone, scaffolded["remotes"], "-q", "--init", + "--", "spec") + assert any((clone / "spec").iterdir()), "the leg was not checked out" + assert_store_read_through_git(clone / "spec", "the leg, via the wrapper") + registered = git("config", "--local", "--get", "submodule.spec.url", + cwd=clone).stdout.strip() + assert registered.endswith(f"{PROJECT}-spec.git"), registered + assert not registered.startswith("file:"), registered + + git(*FILE_PROTOCOL, "submodule", "update", "-q", "--init", "--", "code", + cwd=clone) + assert any((clone / "code").iterdir()), "the control was not checked out" + with pytest.raises(AssertionError, match="loose objects"): + assert_store_read_through_git(clone / "code", "the leg, plain") diff --git a/tests/test_doctor_legs_members_next.py b/tests/test_doctor_legs_members_next.py index 8c908562..619034eb 100644 --- a/tests/test_doctor_legs_members_next.py +++ b/tests/test_doctor_legs_members_next.py @@ -48,7 +48,8 @@ import pytest -from conftest import WINDOWS_SKIP, cloned_just_pushed, git, rmtree +from conftest import (WINDOWS_SKIP, 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, doctor_module, holder, project, record_gitlink, rows_of, @@ -614,7 +615,7 @@ def test_the_git_line_does_not_cure_a_mount_git_cannot_read_a_head_in( def test_a_checkout_wins_over_a_lockstep_fact_and_a_rerun_names_the_other( - standard, project): + standard, project, scaffolded_here): """One leg a checkout cures (an empty mount) and one it does not (a gitlink that is not the pin): the line is the checkout, over the first leg only, and it says to run the doctor again. The re-run names the @@ -628,8 +629,8 @@ def test_a_checkout_wins_over_a_lockstep_fact_and_a_rerun_names_the_other( ), first["next"] assert "doctor again" in why_of(first), why_of(first) # The fix, applied by hand (no sh needed): the empty leg is now cured. - git("-c", "protocol.file.allow=always", "submodule", "update", "--init", - "--recursive", "--", "code", cwd=project) + submodule_update_through_git(project, scaffolded_here["base"] / "remotes", + "--init", "--recursive", "--", "code") second = row_of(standard, project, "legs") assert second["status"] == "FINDING", second assert command_of(second) == pins_gate(module, standard, project, True), \ @@ -984,8 +985,9 @@ def test_an_empty_member_with_no_pin_is_still_a_checkout_first( assert command_of(row) == ( f"git -C {module.quote_arg(root.resolve())} {UPDATE} -- " f"members/{FAMILY_MEMBER}"), row["next"] - git("-c", "protocol.file.allow=always", "submodule", "update", "--init", - "--recursive", "--", f"members/{FAMILY_MEMBER}", cwd=root) + submodule_update_through_git(root, holder["base"] / "remotes", "--init", + "--recursive", "--", + f"members/{FAMILY_MEMBER}") again = row_of(standard, root, "members") assert again["status"] == "FINDING", again assert "validate-family.py" in command_of(again), again["next"] @@ -1047,12 +1049,14 @@ def detached_member(root: Path) -> Path: return member -def with_its_own_legs(root: Path) -> Path: +def with_its_own_legs(root: Path, holder: dict) -> Path: """`family.py add` does not recurse, so the fixture's member has empty - legs: fill them, as a recursive clone of the holder does. Each is then - detached at the commit the MEMBER records for it.""" - git("-c", "protocol.file.allow=always", "submodule", "update", "--init", - "--recursive", "--", f"members/{FAMILY_MEMBER}", cwd=root) + legs: fill them, as a recursive clone of the holder does, from the + remotes the `holder` fixture made. Each is then detached at the commit + the MEMBER records for it.""" + submodule_update_through_git(root, holder["base"] / "remotes", "--init", + "--recursive", "--", + f"members/{FAMILY_MEMBER}") return root / "members" / FAMILY_MEMBER @@ -1238,11 +1242,11 @@ def test_413b_without_it_the_holders_bootstrap_leaves_the_commit_in_no_ref( assert commit in git("reflog", cwd=member).stdout -def made_in_a_members_own_leg(root: Path) -> tuple[Path, str]: +def made_in_a_members_own_leg(root: Path, holder: dict) -> tuple[Path, str]: """A commit in the member, so the row is red, and one in the member's own detached `spec` leg, which the row never reads: the leg and the second commit's sha.""" - member = with_its_own_legs(root) + member = with_its_own_legs(root, holder) git("checkout", "-q", "--detach", "HEAD", cwd=member) commit_in(member, "work in the member") leg = member / "spec" @@ -1256,7 +1260,7 @@ def test_413c_the_members_git_line_says_it_checks_out_each_members_legs( in general words, with the keep line to run in that leg first.""" root = holder_copy(holder, tmp_path) holder_without(root, "bootstrap.py") - made_in_a_members_own_leg(root) + made_in_a_members_own_leg(root, holder) module = doctor_module(standard) row = row_of(standard, root, "members") assert command_of(row) == ( @@ -1275,7 +1279,7 @@ def test_413c_the_leg_keep_line_keeps_a_members_leg_commit_through_the_paste( standard, holder, tmp_path): root = holder_copy(holder, tmp_path) holder_without(root, "bootstrap.py") - leg, commit = made_in_a_members_own_leg(root) + leg, commit = made_in_a_members_own_leg(root, holder) module = doctor_module(standard) row = row_of(standard, root, "members") keep = re.search(r"git -C LEG branch NAME", why_of(row)) @@ -1294,7 +1298,7 @@ def test_413c_without_it_the_members_leg_commit_is_in_no_ref( standard, holder, tmp_path): root = holder_copy(holder, tmp_path) holder_without(root, "bootstrap.py") - leg, commit = made_in_a_members_own_leg(root) + leg, commit = made_in_a_members_own_leg(root, holder) result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere") assert result.returncode == 0, result.stdout + result.stderr assert row_of(standard, root, "members")["status"] == "ok" @@ -2078,7 +2082,7 @@ def test_an_emptied_members_own_leg_is_kept_through_its_repository( kept for the member keeps a commit made in its own `spec` leg.""" root = holder_copy(holder, tmp_path) holder_without(root, "bootstrap.py") - leg, commit = made_in_a_members_own_leg(root) + leg, commit = made_in_a_members_own_leg(root, holder) git("submodule", "deinit", "-q", "-f", "--", f"members/{FAMILY_MEMBER}", cwd=root) module = doctor_module(standard) diff --git a/tests/test_family_bump_remove_rollback.py b/tests/test_family_bump_remove_rollback.py index 3dcfe47c..f3fb458a 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 (FILE_PROTOCOL, cloned_just_pushed, git, rmtree, - run_script) +from conftest import (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 @@ -334,8 +334,8 @@ def test_a_remove_puts_back_the_members_legs_it_had_checked_out( from the stores inside the member's own, and the holder's work tree is byte for byte what it was, theirs included.""" env = isolated(offline, tmp_path) - git(*FILE_PROTOCOL, "submodule", "update", "-q", "--init", - cwd=holder / PATH) + submodule_update_through_git(holder / PATH, family["remotes"], "-q", + "--init") legs = sorted(name for name in ("spec", "code") if (holder / PATH / name / ".git").exists()) assert legs == ["code", "spec"], "premise: both legs checked out" @@ -905,7 +905,7 @@ def test_a_leg_no_store_holds_is_left_out_not_cloned( its checkout comes back only from its remote.""" env = isolated(offline, tmp_path) mount = holder / PATH - git(*FILE_PROTOCOL, "submodule", "update", "-q", "--init", cwd=mount) + submodule_update_through_git(mount, family["remotes"], "-q", "--init") assert git("config", "-f", ".gitmodules", "submodule.spec.path", cwd=mount).stdout.strip() == "spec", "fixture drift" leg_store = Path(git("rev-parse", "--absolute-git-dir", From 8cfc8eb17d620154050aa06db90f4add2f73d1c4 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 18:39:42 +0000 Subject: [PATCH 4/6] tests/conftest.py and tests/test_no_auto_maintenance.py: the session's global file includes a `~/` path only where `HOME` is set and includes nothing over an empty `GIT_CONFIG_GLOBAL`, a test holds the fixture to session scope, and the receive-pack test names its own hooks, so a runner with no `HOME` no longer dies on every git (#645) The review of #713 (rv645) found two things worth fixing and three small ones: - With `HOME` unset, the include of `~/.config/git/config` and `~/.gitconfig` made every git the session started exit 128 ("could not expand include path"). `session_global_config` now writes a `~/` path only where `home_is_known()` (`HOME` is set, or this is Windows), and leaves out the `[include]` header when nothing is included. An `XDG_CONFIG_HOME` that is set is still included. The two `test_a_persons_own_global_config` cases skip where `HOME` was unset. - Nothing held `no_auto_maintenance` to session scope: at function scope all seven tests still passed. A new test reads the two variables from a session fixture built first, and compares them with what the test sees. - An empty `GIT_CONFIG_GLOBAL=` is git's "no global config"; the fixture treated it as unset and included `~/.gitconfig` again. It now includes nothing. - The receive-pack test set no `core.hooksPath` of its own, so a person's global one hid the remote's `post-receive` hook. It now sets the remote's own `hooks` directory in the remote's local config, which outranks a global one. Two tests are new for the `HOME` and empty-value cases, with an XDG variant. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- tests/conftest.py | 38 +++++++++---- tests/test_no_auto_maintenance.py | 89 ++++++++++++++++++++++++++++--- 2 files changed, 111 insertions(+), 16 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5960f06b..434ee808 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -175,7 +175,11 @@ def cloned_just_pushed(remote: Path, dest: Path, branch: str | None = None, # settings still apply, and sets the two keys after them. A `git config # --global ` read without `--includes` sees the two alone: in this # repository that is `setup-project.py`'s Windows-only `core.autocrlf` -# warning, which no test reads from a person's own config. +# warning, which no test reads from a person's own config. An EMPTY +# `GIT_CONFIG_GLOBAL` is git's "no global config", and nothing is included. +# A `~/` path is written only where `HOME` is set, or on Windows, where Git +# for Windows sets it: with `HOME` unset git cannot expand one, and every +# git then dies `could not expand include path`. # # WHAT IT LEAVES: a git whose environment takes away both -- a # `GIT_CONFIG_COUNT` overwritten rather than appended to, together with a @@ -210,19 +214,33 @@ def appended_to_command_scope(environ: Mapping[str, str], return added +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.""" + return bool(os.environ.get("HOME")) or os.name == "nt" + + def session_global_config(named: str | None) -> bytes: """The global config file `no_auto_maintenance` writes: an include of - what git reads as its global config without one -- `named`, the file - `GIT_CONFIG_GLOBAL` held, or else git's two default files in git's own - order -- and then `NO_AUTO_MAINTENANCE`. LF, and every path POSIX-style: - `~/` is expanded by git, from the `HOME` of the git that reads it.""" - if named: - included = [Path(os.path.abspath(named)).as_posix()] + what git reads as its global config without one, and then + `NO_AUTO_MAINTENANCE`. LF, and every path POSIX-style: `~/` is expanded by + git, from the `HOME` of the git that reads it. + + `named` is what `GIT_CONFIG_GLOBAL` held. A name is the file included. An + empty value is git's "no global config" (2.43), so nothing is included. + None, unset, is git's two default files in git's own order, the + `~/` ones only where `home_is_known()`: with `HOME` unset git dies + `could not expand include path` over one, in every command it runs.""" + if named is not None: + included = [Path(os.path.abspath(named)).as_posix()] if named else [] else: + homed = (["~/.config/git/config", "~/.gitconfig"] + if home_is_known() else []) xdg = os.environ.get("XDG_CONFIG_HOME") - included = [(Path(xdg) / "git" / "config").as_posix() if xdg - else "~/.config/git/config", "~/.gitconfig"] - lines = ["[include]"] + # `XDG_CONFIG_HOME` stands in for the first, and needs no `HOME`. + included = ([(Path(xdg) / "git" / "config").as_posix(), *homed[1:]] + if xdg else homed) + lines = ["[include]"] if included else [] lines += [f"\tpath = {_config_quoted(path)}" for path in included] for key, value in NO_AUTO_MAINTENANCE: section, name = key.split(".") diff --git a/tests/test_no_auto_maintenance.py b/tests/test_no_auto_maintenance.py index 33d55c11..8eefa140 100644 --- a/tests/test_no_auto_maintenance.py +++ b/tests/test_no_auto_maintenance.py @@ -19,7 +19,10 @@ - the `receive-pack` a push into a local remote starts, which never sees command scope, reads both settings off: a hook in the remote reports them; - the two are appended to the command scope the environment holds, and a - person's own global config is still read, in git's order, beneath them. + 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. """ from __future__ import annotations @@ -32,8 +35,8 @@ import pytest from conftest import (NO_AUTO_MAINTENANCE, ORG, PROJECT, SCAFFOLD, - appended_to_command_scope, git, run_script, - session_global_config) + appended_to_command_scope, git, home_is_known, + run_script, session_global_config) #: The verbs auto maintenance runs as: `git maintenance run --auto`, and #: `git gc --auto`, which a git started itself before `maintenance` existed. @@ -143,9 +146,11 @@ def test_the_receive_pack_of_a_push_to_a_local_remote_reads_both_off( """Git starts `receive-pack` for a push to a path with no command scope, so only the session's global file reaches it. Its `post-receive` hook runs under it and writes down what git reads there. On the base it read - neither.""" + neither. The remote's own `core.hooksPath` keeps the hook found where a + person's global config sets one of its own.""" remote, work = tmp_path / "remote.git", tmp_path / "work" git("init", "-q", "--bare", "-b", "main", str(remote), cwd=tmp_path) + git("config", "core.hooksPath", (remote / "hooks").as_posix(), cwd=remote) hook = remote / "hooks" / "post-receive" hook.write_bytes( b"#!/bin/sh\nfor key in gc.auto maintenance.auto; do\n" @@ -185,8 +190,10 @@ def test_a_persons_own_global_config_is_still_read_beneath_the_two( two settings after it: a person's own values are read, and their own `maintenance.auto = true` does not turn maintenance back on. A case is skipped where the file includes another path in its place, which a test - cannot write: the session began with `GIT_CONFIG_GLOBAL` set, or, for - the second, with `XDG_CONFIG_HOME` set.""" + cannot write: the session began with `GIT_CONFIG_GLOBAL` set, with + `HOME` unset, or, for the second, with `XDG_CONFIG_HOME` set.""" + if not home_is_known(): + pytest.skip("HOME was unset, and the file includes no `~/` path") if no_auto_maintenance.read_bytes() != session_global_config(None): pytest.skip("the session began with GIT_CONFIG_GLOBAL set, and its " "file includes that one") @@ -202,3 +209,73 @@ def test_a_persons_own_global_config_is_still_read_beneath_the_two( for key, value in NO_AUTO_MAINTENANCE: assert asked("--global", "--includes", "--get", key, cwd=tmp_path) == value, key + + +@pytest.mark.skipif(os.name == "nt", + reason="Git for Windows finds a home of its own") +@pytest.mark.parametrize("xdg", [False, True], ids=["no-xdg", "xdg"]) +def test_where_home_is_unset_the_file_is_one_git_can_read( + xdg, tmp_path, monkeypatch): + """With `HOME` unset git cannot expand a `~/` path, and an include of one + made every git the session started die `could not expand include path` + (a hermetic runner, `env -i`). The file leaves them out, and an + `XDG_CONFIG_HOME` that is set is still included.""" + monkeypatch.delenv("HOME", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + if xdg: + under = tmp_path / "xdg" + (under / "git").mkdir(parents=True) + (under / "git" / "config").write_bytes( + b"[openreposhape]\n\tprobe = xdg\n") + monkeypatch.setenv("XDG_CONFIG_HOME", str(under)) + config = tmp_path / "session.gitconfig" + config.write_bytes(session_global_config(None)) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) + for key, value in NO_AUTO_MAINTENANCE: + assert asked("--global", "--includes", "--get", key, + cwd=tmp_path) == value, key + assert asked("--global", "--includes", "--get", "openreposhape.probe", + cwd=tmp_path) == ("xdg" if xdg else "") + + +def test_an_empty_GIT_CONFIG_GLOBAL_is_no_global_config_and_includes_nothing( + tmp_path, monkeypatch): + """Git reads an empty `GIT_CONFIG_GLOBAL` as "no global config" (2.43), + and the file the fixture writes over it does not bring `~/.gitconfig` + back.""" + home = tmp_path / "home" + home.mkdir() + (home / ".gitconfig").write_bytes(b"[openreposhape]\n\tprobe = home\n") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + config = tmp_path / "session.gitconfig" + config.write_bytes(session_global_config("")) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) + assert asked("--global", "--includes", "--get", "openreposhape.probe", + cwd=tmp_path) == "" + for key, value in NO_AUTO_MAINTENANCE: + assert asked("--global", "--includes", "--get", key, + cwd=tmp_path) == value, key + + +@pytest.fixture(scope="session") +def seen_by_a_session_fixture() -> dict: + """The session's two variables as a SESSION fixture sees them when it is + built, which is when a session fixture that makes a repository (`standard`, + `scaffolded`) makes it. Pytest builds the higher scope first, and within a + scope an autouse fixture first and then the arguments in order.""" + return {name: os.environ.get(name) + for name in ("GIT_CONFIG_GLOBAL", "GIT_CONFIG_COUNT")} + + +def test_the_settings_are_in_place_before_a_session_fixture_builds_a_repo( + seen_by_a_session_fixture, no_auto_maintenance): + """`no_auto_maintenance` at function scope, or without `autouse`, is + built after a session fixture and every test here but this one still + passes: the fixture must be session scope and autouse. A session fixture + sees the file and the count that the test sees.""" + assert seen_by_a_session_fixture == { + name: os.environ.get(name) + for name in ("GIT_CONFIG_GLOBAL", "GIT_CONFIG_COUNT")} + assert seen_by_a_session_fixture["GIT_CONFIG_GLOBAL"] == \ + str(no_auto_maintenance) From 979bb39445f95e2e23e64f51504269945765867f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 18:47:40 +0000 Subject: [PATCH 5/6] tests/test_no_auto_maintenance.py: the two new global-file tests share one helper that writes the session file, reads both settings back and returns the probe, so the fix round adds no duplicated test body (#645) The `HOME`-unset test and the empty-`GIT_CONFIG_GLOBAL` test each wrote `session_global_config(...)` to a file, set `GIT_CONFIG_GLOBAL` and looped over the two settings. That block is now `probe_read_from_the_session_file`, so SonarCloud's duplication gate has nothing to count. No assertion changes: the new file still gives 11 passed, and the same probes fail. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_no_auto_maintenance.py | 34 ++++++++++++++++--------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/tests/test_no_auto_maintenance.py b/tests/test_no_auto_maintenance.py index 8eefa140..c7116dd0 100644 --- a/tests/test_no_auto_maintenance.py +++ b/tests/test_no_auto_maintenance.py @@ -86,6 +86,21 @@ def asked(*args: str, cwd: Path) -> str: return git("config", *args, cwd=cwd, check=False).stdout.strip() +def probe_read_from_the_session_file(named, tmp_path, monkeypatch) -> str: + """`session_global_config(named)` written to a file that is the whole + global config for the rest of the test, read back with its includes: the + two settings are asserted, and what the `openreposhape.probe` key reads + is returned (empty where no included file sets it).""" + config = tmp_path / "session.gitconfig" + config.write_bytes(session_global_config(named)) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) + for key, value in NO_AUTO_MAINTENANCE: + assert asked("--global", "--includes", "--get", key, + cwd=tmp_path) == value, key + return asked("--global", "--includes", "--get", "openreposhape.probe", + cwd=tmp_path) + + @pytest.fixture def behind(tmp_path) -> Path: """A clone of a local bare remote that has moved on by a commit, as a @@ -228,14 +243,8 @@ def test_where_home_is_unset_the_file_is_one_git_can_read( (under / "git" / "config").write_bytes( b"[openreposhape]\n\tprobe = xdg\n") monkeypatch.setenv("XDG_CONFIG_HOME", str(under)) - config = tmp_path / "session.gitconfig" - config.write_bytes(session_global_config(None)) - monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) - for key, value in NO_AUTO_MAINTENANCE: - assert asked("--global", "--includes", "--get", key, - cwd=tmp_path) == value, key - assert asked("--global", "--includes", "--get", "openreposhape.probe", - cwd=tmp_path) == ("xdg" if xdg else "") + assert probe_read_from_the_session_file( + None, tmp_path, monkeypatch) == ("xdg" if xdg else "") def test_an_empty_GIT_CONFIG_GLOBAL_is_no_global_config_and_includes_nothing( @@ -248,14 +257,7 @@ def test_an_empty_GIT_CONFIG_GLOBAL_is_no_global_config_and_includes_nothing( (home / ".gitconfig").write_bytes(b"[openreposhape]\n\tprobe = home\n") monkeypatch.setenv("HOME", str(home)) monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) - config = tmp_path / "session.gitconfig" - config.write_bytes(session_global_config("")) - monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) - assert asked("--global", "--includes", "--get", "openreposhape.probe", - cwd=tmp_path) == "" - for key, value in NO_AUTO_MAINTENANCE: - assert asked("--global", "--includes", "--get", key, - cwd=tmp_path) == value, key + assert probe_read_from_the_session_file("", tmp_path, monkeypatch) == "" @pytest.fixture(scope="session") From deb029c926d9336ee2dc6c5d3590bca3b3040834 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 19:05:04 +0000 Subject: [PATCH 6/6] tests/test_clone_just_pushed.py and tests/conftest.py: `read_through_git` is asked about a remotes directory typed through a symlink, so dropping its `typed=True` no longer passes, and its docstring says the environment it appends to is the one it is laid over at the same moment (#662) rv662's mutant N3 (`typed=False` in `read_through_git`) passed every test: `typed` matters only where the caller spells the directory through a symlink, and the symlink case called `insteadof_prefixes` directly. The new case makes `link` to the remotes directory and asks git (`ls-remote --get-url`) what the environment `read_through_git(link)` does to `link/A.git` and to `remotes/A.git`: both go to the resolved directory's file URI. The mutant now fails it (1 failed, 18 passed). A small helper, `a_link_to`, makes the link for this case and the existing symlink case, and skips where the account cannot. `read_through_git` reads `os.environ` when it is called, so its result is only right laid over `os.environ` at the same moment, as `pasted` does; the docstring now says so. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- tests/conftest.py | 8 ++++---- tests/test_clone_just_pushed.py | 31 ++++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 834b9d3e..58d73d53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1602,10 +1602,10 @@ def read_through_git(remotes: Path) -> dict: URL and the legs' `.gitmodules` URLs are both rewritten. The entries are APPENDED to the ones `os.environ` holds - (`appended_to_command_scope`), which every caller lays this over, so the - session's own (`no_auto_maintenance`'s, and the two the Windows job sets) - still reach the pasted line, as they reach every other git a test - starts.""" + (`appended_to_command_scope`), which every caller lays this over at the + same moment, so the session's own (`no_auto_maintenance`'s, and the two + the Windows job sets) still reach the pasted line, as they reach every + other git a test starts.""" return appended_to_command_scope( os.environ, insteadof_entries(remotes, typed=True)) diff --git a/tests/test_clone_just_pushed.py b/tests/test_clone_just_pushed.py index a4bdb997..f85d4ed6 100644 --- a/tests/test_clone_just_pushed.py +++ b/tests/test_clone_just_pushed.py @@ -154,16 +154,23 @@ def test_a_directory_is_named_with_a_separator_after_it_given_or_not( assert prefixes == insteadof_prefixes(remotes, typed=typed), prefixes +def a_link_to(directory: Path, tmp_path: Path) -> Path: + """`tmp_path/link`, a symlink to `directory`, or a skip where this + account cannot make one.""" + link = tmp_path / "link" + try: + link.symlink_to(directory, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("this account cannot make a symlink") + return link + + def test_a_typed_directory_adds_the_spelling_it_was_given_first(tmp_path): """TYPED: a symlink kept, as a line a tool prints names a remote. The resolved directory is the one `.gitmodules` records, and stays.""" real = tmp_path / "real" real.mkdir() - link = tmp_path / "link" - try: - link.symlink_to(real, target_is_directory=True) - except (OSError, NotImplementedError): - pytest.skip("this account cannot make a symlink") + link = a_link_to(real, tmp_path) resolved = spellings(real.resolve()) typed = spellings(Path(os.path.abspath(link))) assert typed.isdisjoint(resolved), "premise: a link that is not its target" @@ -204,6 +211,20 @@ def test_the_arguments_and_the_environment_send_a_plain_path_to_its_uri( assert get_url(neighbour, env=environ, cwd=tmp_path) == neighbour +def test_the_environment_form_reads_a_path_typed_through_a_symlink( + remotes, tmp_path): + """`read_through_git` is the one user of `typed=True`: the line a tool + prints names the remotes as the person typed them, a symlink and all, and + only the TYPED spelling matches that path. Both go to the resolved + directory's URI.""" + link = a_link_to(remotes, tmp_path) + environ = {**os.environ, **read_through_git(link)} + assert get_url(str(link / "A.git"), env=environ, cwd=tmp_path) == ( + remotes.resolve() / "A.git").as_uri() + assert get_url(str(remotes / "A.git"), env=environ, cwd=tmp_path) == ( + remotes.resolve() / "A.git").as_uri() + + def test_the_environment_form_keeps_what_the_environment_holds( remotes, tmp_path, monkeypatch): """APPENDED, as `appended_to_command_scope` appends the session's own