From 9bfb8164ea2f305dbc9976e348578f5a0660dcee Mon Sep 17 00:00:00 2001 From: Byron Date: Thu, 5 Nov 2026 08:19:31 +0100 Subject: [PATCH 1/4] test(refs): use POSIX paths for Git reference names Restore Windows CI coverage for `PathLike` revision arguments. The test introduced in `6de90f1c` uses `Path("refs/heads/main")`, which emits backslashes on Windows. Those are invalid Git reference names, so resolving `SymbolicReference` fails during ref-name validation in all four files/reftable and SHA-1/SHA-256 variants. The same failures were already present in PR #2336, before the unrelated `patch_env()` change in PR #2340. Use `PurePosixPath` for the test's Git reference names, which use forward slashes on every platform. It still implements `os.PathLike`, preserving coverage through `SymbolicReference` and direct and nested `merge_base()` arguments alongside `PathLikeMock`. Keep native `Path` for filesystem operations elsewhere in the module and preserve production validation. Assisted-by: GPT 6.1 Sol Co-authored-by: GPT 6.1 Sol --- test/test_refs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_refs.py b/test/test_refs.py index bd0febf82..97b14b61e 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -10,7 +10,7 @@ import tarfile import unittest from itertools import chain -from pathlib import Path +from pathlib import Path, PurePosixPath from unittest.mock import Mock, patch import pytest @@ -270,7 +270,7 @@ def test_merge_base_preserves_nested_reference_arguments(object_format, ref_form @pytest.mark.parametrize("object_format", ["sha1", "sha256"]) @pytest.mark.parametrize("ref_format", ["files", "reftable"]) -@pytest.mark.parametrize("path_type", [Path, PathLikeMock]) +@pytest.mark.parametrize("path_type", [PurePosixPath, PathLikeMock]) def test_revision_arguments_preserve_pathlike_paths(object_format, ref_format, path_type): with fixture_repo("exact-refs.sh", object_format, ref_format, "shadowed") as repo: local = repo.head.commit From 09010352a81f7ca78b8577a2233bf0b197e5aa52 Mon Sep 17 00:00:00 2001 From: Byron Date: Thu, 5 Nov 2026 09:41:18 +0100 Subject: [PATCH 2/4] test: isolate repository fixtures from checkout history Took a quick look, pretty zoomed out, but think it's a desirable change as finally the test-suite is independent of the input repository or state. Make PR and post-merge test results independent of checkout depth, refs, and history. The shared test repository and dependency clone sources previously came from the source checkout, requiring historical objects and a bootstrap script even though setup already used `gix.testtools`. Generate small repositories from scratch through `gix.testtools`, with named root, release, merge, conflict, rename, date, and submodule states. Adapt legacy assertions to those scenarios while retaining merge, serialization, traversal, index, remote, and nested-submodule coverage. Make dirty-submodule removal checks explicit for staged, unstaged, and untracked data, including dry runs. Remove the source-history environment override, dependency factory, checkout lock cleanup, and bootstrap script. The main CI suites now use shallow checkouts and remove source `.git` metadata before pytest. Add a regression that initializes the shared fixture from a source copy without Git metadata, and document the isolation requirement. Hash raw parser samples in cache arguments to avoid archive filename limits on cold runs. Assisted-by: GPT 6.0 Astra Co-authored-by: GPT 6.0 Astra --- .github/workflows/alpine-test.yml | 8 +- .github/workflows/cygwin-test.yml | 5 - .github/workflows/pythonpackage.yml | 8 +- CONTRIBUTING.md | 9 +- README.md | 8 +- doc/gix-backend.md | 4 + init-tests-after-clone.sh | 76 --------------- test/conftest.py | 29 ------ test/fixtures/diff_initial | 8 -- test/lib/helper.py | 19 ++-- test/run-local.py | 1 - test/test_base.py | 15 +-- test/test_commit.py | 101 ++++++++++---------- test/test_db.py | 2 +- test/test_diff.py | 6 +- test/test_docs.py | 24 ++--- test/test_fixture_health.py | 98 +++++++++---------- test/test_fun.py | 22 ++--- test/test_git.py | 10 +- test/test_index.py | 36 +++---- test/test_refs.py | 14 +-- test/test_remote.py | 6 +- test/test_repo.py | 130 +++++++++++--------------- test/test_submodule.py | 80 +++++++--------- test/test_tree.py | 34 +++---- tests/fixtures/dependencies.sh | 20 ---- tests/fixtures/history.sh | 6 -- tests/fixtures/sample-repositories.sh | 129 +++++++++++++++++++++++++ tests/fixtures/submodule-cases.sh | 7 ++ 29 files changed, 424 insertions(+), 491 deletions(-) delete mode 100755 init-tests-after-clone.sh delete mode 100644 test/fixtures/diff_initial delete mode 100755 tests/fixtures/dependencies.sh create mode 100644 tests/fixtures/sample-repositories.sh diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml index 14506a30a..189826c0f 100644 --- a/.github/workflows/alpine-test.yml +++ b/.github/workflows/alpine-test.yml @@ -34,7 +34,6 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false - name: Set workspace ownership @@ -42,10 +41,6 @@ jobs: chown -R runner:docker -- "$GITHUB_WORKSPACE" shell: sh -exo pipefail {0} # Run this as root, not the "runner" user. - - name: Prepare this repo for tests - run: | - ./init-tests-after-clone.sh - - name: Set git user identity and command aliases for the tests run: | git config --global user.email "travis@ci.com" @@ -95,6 +90,9 @@ jobs: python --version python -c 'import os, sys; print(f"{sys.platform=}, {os.name=}")' + - name: Remove source Git metadata to verify fixture isolation + run: .venv/bin/python -c "from pathlib import Path; from git.util import rmtree; rmtree('.git'); assert not Path('.git').exists()" + - name: Test with pytest run: | . .venv/bin/activate diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index ba3a09246..c55b73dd8 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -30,7 +30,6 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false - name: Install Cygwin @@ -61,10 +60,6 @@ jobs: git config --global --add safe.directory "$(pwd)/.git" git config --global core.autocrlf false - - name: Prepare this repo for tests - run: | - ./init-tests-after-clone.sh - - name: Set git user identity and command aliases for the tests run: | git config --global user.email "travis@ci.com" diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 1ca50a599..e8ef05cd4 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -61,7 +61,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false - name: Set up Python ${{ matrix.python-version }} @@ -115,10 +114,6 @@ jobs: brew install git echo "$(brew --prefix git)/bin" >> "$GITHUB_PATH" - - name: Prepare this repo for tests - run: | - ./init-tests-after-clone.sh - - name: Set git user identity and command aliases for the tests run: | git config --global user.email "travis@ci.com" @@ -219,6 +214,9 @@ jobs: run: | basedpyright --warnings + - name: Remove source Git metadata to verify fixture isolation + run: python -c "from pathlib import Path; from git.util import rmtree; rmtree('.git'); assert not Path('.git').exists()" + - name: Test with pytest run: | if [[ "$RUNNER_OS" == "Windows" && -z "${GIT_PYTHON_GIT_EXECUTABLE+x}" ]]; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b14b1d33..d5848e1bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,8 @@ The following is a short step-by-step rundown of what one typically would do to contribute. - [Fork this project](https://github.com/gitpython-developers/GitPython/fork) on GitHub. -- For setting up the environment to run the self tests, please run `init-tests-after-clone.sh`. +- Install the `test` extra and select a backend before running `pytest`. Test + repositories are generated automatically; no source Git history is required. - Please try to **write a test that fails unless the contribution is present.** - Try to avoid massive commits and prefer to take small steps, with one commit for each. - Feel free to add yourself to AUTHORS file. @@ -64,8 +65,10 @@ Put initial repository and filesystem states in `tests/fixtures/*.sh` and use `test.lib.fixtures` to run them through `gix.testtools`. Read-only tests share cached output; mutating tests use writable copies. Use `execute=True` only when the fixture cannot be copied, and explain the reason at its call site. Keep -operations under test in Python. Immutable parser byte samples remain in -`test/fixtures`; generated caches and archives are ignored. +operations under test in Python. Generate histories from scratch and refer to +named fixture revisions; do not clone the source checkout, fetch project history, +or depend on its refs, object IDs, index, or working-tree state. Immutable parser +byte samples remain in `test/fixtures`; generated caches and archives are ignored. Use `test.cleanup.cleanup_directory` for cleanup outside the shared fixture owners. Close repository handles before removing their files. Cleanup is diff --git a/README.md b/README.md index 3974060f7..3776c2f43 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,8 @@ To clone the [the GitHub repository](https://github.com/gitpython-developers/Git ```sh git clone https://github.com/gitpython-developers/GitPython cd GitPython -./init-tests-after-clone.sh ``` -On Windows, `./init-tests-after-clone.sh` can be run in a Git Bash shell. - If you are cloning [your own fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks), then replace the above `git clone` command with one that gives the URL of your fork. Or use this [`gh`](https://cli.github.com/) command (assuming you have `gh` and your fork is called `GitPython`): ```sh @@ -175,9 +172,8 @@ See [Issue #525](https://github.com/gitpython-developers/GitPython/issues/525). ### RUNNING TESTS -_Important_: Right after cloning this repository, please be sure to have executed -the `./init-tests-after-clone.sh` script in the repository root. Otherwise -you will encounter test failures. +Test repositories are generated from scratch by fixture scripts. Tests work from +a shallow clone or a source copy without `.git`; no checkout preparation is needed. #### Install test dependencies diff --git a/doc/gix-backend.md b/doc/gix-backend.md index c2959c5c3..8a5b2ec71 100644 --- a/doc/gix-backend.md +++ b/doc/gix-backend.md @@ -214,6 +214,10 @@ installation/import smoke coverage, including the Cygwin Python 3.9 job. Running tests also requires Bash and Git 2.52+; `gix` here is GixPython's module. Initial repository and filesystem states live in `tests/fixtures/*.sh`. +Histories are generated from scratch, including the shared sample repository and +its nested dependency repositories. Tests use named revisions such as `root`, +`release`, and `merge`, and require no source checkout history or Git metadata. +CI removes the source `.git` directory before running the full suite. `test/lib/fixtures.py` calls `gix.testtools` with an explicit lifetime: - `read_only_fixture()` and the default `fixture_repo()` reuse cached output. diff --git a/init-tests-after-clone.sh b/init-tests-after-clone.sh deleted file mode 100755 index db3540857..000000000 --- a/init-tests-after-clone.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/sh -# -# This file is part of GitPython and is released under the -# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ - -set -eu - -fallback_repo_for_tags='https://github.com/gitpython-developers/GitPython.git' - -ci() { - # For now, check just these, as a false positive could lead to data loss. - test -n "${TRAVIS-}" || test -n "${GITHUB_ACTIONS-}" -} - -no_version_tags() { - test -z "$(git tag -l '[0-9]*' 'v[0-9]*')" -} - -warn() { - if test -n "${GITHUB_ACTIONS-}"; then - printf '::warning ::%s\n' "$*" >&2 # Annotate workflow. - else - printf '%s\n' "$@" >&2 - fi -} - -if ! ci; then - printf 'This operation will destroy locally modified files. Continue ? [N/y]: ' >&2 - read -r answer - case "$answer" in - [yY]) - ;; - *) - exit 2 ;; - esac -fi - -# Stop if we have run this. (You can delete __testing_point__ to let it rerun.) -# This also keeps track of where we are, so we can get back here. -git tag __testing_point__ - -# The tests need a branch called master. -# -# If master is locally absent but more than one remote has it, checkout fails -# by default even if all remotes agree, and we fall back to creating it at -# HEAD. The reflog we populate below then traces HEAD's history rather than -# a remote master's, but master is reset to __testing_point__ either way. -git checkout master -- || git checkout -b master - -# The tests need a reflog history on the master branch. -git reset --hard HEAD~1 -git reset --hard HEAD~1 -git reset --hard HEAD~1 - -# Point the master branch where we started, so we test the correct code. -git reset --hard __testing_point__ - -# The tests need some version tags. Try to get them even in forks. This fetches -# other objects too. So, locally, we always do it, for a consistent experience. -if ! ci || no_version_tags; then - git fetch --all --tags -fi - -# If we still have no version tags, try to get them from the original repo. -if no_version_tags; then - warn 'No local or remote version tags found. Trying fallback remote:' \ - "$fallback_repo_for_tags" - - # git fetch supports * but not [], and --no-tags means no *other* tags, so... - printf 'refs/tags/%d*:refs/tags/%d*\n' 0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 | - xargs git fetch --no-tags "$fallback_repo_for_tags" - - if no_version_tags; then - warn 'No version tags found anywhere. Some tests will fail.' - fi -fi diff --git a/test/conftest.py b/test/conftest.py index a7ee2bd74..d0c355229 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -5,11 +5,8 @@ from pathlib import Path import pytest -from gix import testtools from git import _backend -from test.lib import GIT_REPO, TestBase -from test.lib.fixtures import read_only_fixture def pytest_addoption(parser): @@ -99,29 +96,3 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): with open(path, "w", encoding="utf-8") as stream: json.dump(records, stream, indent=2) stream.write("\n") - - -@pytest.fixture(scope="session") -def dependency_repo_factory(): - """Prepare each immutable historical clone source only when a test needs it.""" - source = Path(GIT_REPO).resolve() - revision = testtools.git(source, "rev-parse HEAD").strip() - paths = {} - - def get(name): - if name not in paths: - paths[name] = str(read_only_fixture("dependencies.sh", source, revision, name) / "repo") - return paths[name] - - return get - - -@pytest.fixture(scope="class", autouse=True) -def historical_dependency_sources(request): - if request.cls is not None and issubclass(request.cls, TestBase): - factory = request.getfixturevalue("dependency_repo_factory") - request.cls._dependency_repo_factory = staticmethod(factory) - yield - del request.cls._dependency_repo_factory - else: - yield diff --git a/test/fixtures/diff_initial b/test/fixtures/diff_initial deleted file mode 100644 index 648d70437..000000000 --- a/test/fixtures/diff_initial +++ /dev/null @@ -1,8 +0,0 @@ -@@ -0,0 +1,7 @@ -+======= -+CHANGES -+======= -+ -+0.1.0 -+===== -+initial release diff --git a/test/lib/helper.py b/test/lib/helper.py index 5554f407e..dacb3dfe6 100644 --- a/test/lib/helper.py +++ b/test/lib/helper.py @@ -16,7 +16,6 @@ "TestCase", "SkipTest", "skipIf", - "GIT_REPO", "GIT_DAEMON_PORT", "xfail_if_raises", "symlinks_supported", @@ -31,7 +30,6 @@ import logging import os import os.path as osp -from pathlib import Path from stat import S_ISLNK, ST_MODE import socket import textwrap @@ -41,7 +39,6 @@ from typing import Union, Type, Tuple import pytest -from gix import testtools from git.util import cwd from test.cleanup import TemporaryDirectory @@ -53,7 +50,6 @@ ospd = osp.dirname -GIT_REPO = os.environ.get("GIT_PYTHON_TEST_GIT_REPO_BASE", ospd(ospd(ospd(__file__)))) GIT_DAEMON_PORT = os.environ.get("GIT_PYTHON_TEST_GIT_DAEMON_PORT", "19418") _logger = logging.getLogger(__name__) @@ -373,22 +369,21 @@ class TestBase(TestCase): self.rorepo # 'ro' stands for read-only - The rorepo is a scripted clone of your project's git repo. If you refer to specific - shas for your objects, be sure you choose some that are part of the immutable - portion of the project history (so that tests don't fail for others). + The rorepo is generated by sample-repositories.sh. Its named revisions describe + the test scenarios and do not depend on the project's checkout or history. """ def _small_repo_url(self): """:return: A path to a small, clonable repository""" from git.cmd import Git - return Git.polish_url(self._dependency_repo_factory("smmap")) + return Git.polish_url(str(read_only_fixture("sample-repositories.sh") / "smmap")) def _gitdb_repo_url(self): - """:return: A local gitdb repository reconstructed from merged history""" + """:return: A generated repository with a nested submodule""" from git.cmd import Git - return Git.polish_url(self._dependency_repo_factory("gitdb")) + return Git.polish_url(str(read_only_fixture("sample-repositories.sh") / "gitdb")) @classmethod def setUpClass(cls): @@ -399,9 +394,7 @@ def setUpClass(cls): from git import Repo gc.collect() - source = Path(GIT_REPO).resolve() - revision = testtools.git(source, "rev-parse HEAD").strip() - cls.rorepo = Repo(read_only_fixture("history.sh", source, revision, revision, "baseline") / "repo") + cls.rorepo = Repo(read_only_fixture("sample-repositories.sh") / "repo") @classmethod def tearDownClass(cls): diff --git a/test/run-local.py b/test/run-local.py index c7824bd53..b455ebb2f 100644 --- a/test/run-local.py +++ b/test/run-local.py @@ -37,7 +37,6 @@ def main(): raise RuntimeError("Git executable not found in PATH") # Windows can share minimum-version checks for an absolute path. env["GIT_PYTHON_GIT_EXECUTABLE"] = os.path.abspath(executable) - env["GIT_PYTHON_TEST_GIT_REPO_BASE"] = str(root) # Separate CLI and gix runs can use their own localhost daemon. with socket.socket() as listener: listener.bind(("127.0.0.1", 0)) diff --git a/test/test_base.py b/test/test_base.py index 214cb2c7e..9adfc4eab 100644 --- a/test/test_base.py +++ b/test/test_base.py @@ -28,10 +28,10 @@ def tearDown(self): gc.collect() type_tuples = ( - ("blob", "8741fc1d09d61f02ffd8cded15ff603eff1ec070", "blob.py"), - ("tree", "3a6a5e3eeed3723c09f1ef0399f81ed6b8d82e79", "directory"), - ("commit", "4251bd59fb8e11e40c40548cba38180a9536118c", None), - ("tag", "e56a60e8e9cd333cfba0140a77cd12b0d9398f10", None), + ("blob", "HEAD:git/objects/blob.py", "blob.py"), + ("tree", "HEAD:git", "directory"), + ("commit", "dated", None), + ("tag", "annotated", None), ) def test_base_object(self): @@ -42,7 +42,8 @@ def test_base_object(self): s = set() num_objs = 0 num_index_objs = 0 - for obj_type, (typename, hexsha, path) in zip(types, self.type_tuples): + for obj_type, (typename, revision, path) in zip(types, self.type_tuples): + hexsha = self.rorepo.git.rev_parse(revision) binsha = hex_to_bin(hexsha) item = None if path is None: @@ -116,7 +117,7 @@ def test_with_bare_rw_repo(self, bare_rw_repo: Repo): assert osp.isdir(bare_rw_repo.working_dir) assert bare_rw_repo.working_tree_dir is None - @with_rw_repo("0.1.6") + @with_rw_repo("release") def test_with_rw_repo(self, rw_repo: Repo): assert not rw_repo.config_reader("repository").getboolean("core", "bare") assert osp.isdir(rw_repo.working_tree_dir) @@ -125,7 +126,7 @@ def test_with_rw_repo(self, rw_repo: Repo): @pytest.mark.net @skipIf(sys.platform == "win32", "FIXME: Freezes! sometimes...") - @with_rw_and_rw_remote_repo("0.1.6") + @with_rw_and_rw_remote_repo("release") def test_with_rw_remote_and_rw_repo(self, rw_repo, rw_remote_repo): assert not rw_repo.config_reader("repository").getboolean("core", "bare") assert rw_remote_repo.config_reader("repository").getboolean("core", "bare") diff --git a/test/test_commit.py b/test/test_commit.py index 09570f15c..8c09828e5 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -5,6 +5,7 @@ import copy from datetime import datetime +from hashlib import sha256 from io import BytesIO from pathlib import Path import os.path as osp @@ -40,9 +41,9 @@ def assert_commit_serialization(self, rwrepo, commit_id, print_performance_info= st = time.time() for cm in [ rwrepo.commit(commit_id), - rwrepo.commit("4c39f9da792792d4e73fc3a5effde66576ae128c"), - rwrepo.commit("0.1.6"), - next(rwrepo.commit("0.1.6").iter_parents(max_count=1)), + rwrepo.commit("root"), + rwrepo.commit("merge"), + rwrepo.commit("manifest-update"), ]: nds += 1 @@ -101,24 +102,24 @@ def assert_commit_serialization(self, rwrepo, commit_id, print_performance_info= class TestCommit(TestCommitSerialization): def test_bake(self): - commit = self.rorepo.commit("2454ae89983a4496a445ce347d7a41c0bb0ea7ae") + commit = self.rorepo.commit("manifest-update") # Commits have no dict. self.assertRaises(AttributeError, setattr, commit, "someattr", 1) commit.author # bake - self.assertEqual("Sebastian Thiel", commit.author.name) - self.assertEqual("byronimo@gmail.com", commit.author.email) + self.assertEqual("Fixture Author", commit.author.name) + self.assertEqual("author@example.com", commit.author.email) self.assertEqual(commit.author, commit.committer) assert isinstance(commit.authored_date, int) and isinstance(commit.committed_date, int) assert isinstance(commit.author_tz_offset, int) and isinstance(commit.committer_tz_offset, int) self.assertEqual( commit.message, - "Added missing information to docstrings of commit and stats module\n", + "Update manifest\n", ) @with_rw_repo("HEAD") def test_replace_no_changes(self, rw_repo): - old_commit = rw_repo.commit("2454ae89983a4496a445ce347d7a41c0bb0ea7ae") + old_commit = rw_repo.commit("manifest-update") new_commit = old_commit.replace() for attr in old_commit.__slots__: @@ -126,21 +127,22 @@ def test_replace_no_changes(self, rw_repo): @with_rw_repo("HEAD") def test_replace_new_sha(self, rw_repo): - commit = rw_repo.commit("2454ae89983a4496a445ce347d7a41c0bb0ea7ae") + commit = rw_repo.commit("manifest-update") new_commit = commit.replace(message="Added replace method") - assert new_commit.hexsha == "fc84cbecac1bd4ba4deaac07c1044889edd536e6" + assert new_commit.hexsha != commit.hexsha + assert rw_repo.commit(new_commit.hexsha).message == "Added replace method" assert new_commit.message == "Added replace method" @with_rw_repo("HEAD") def test_replace_invalid_attribute(self, rw_repo): - commit = rw_repo.commit("2454ae89983a4496a445ce347d7a41c0bb0ea7ae") + commit = rw_repo.commit("manifest-update") with self.assertRaises(ValueError): commit.replace(badattr="This will never work") def test_stats(self): - commit = self.rorepo.commit("33ebe7acec14b25c5f84f35a664803fcab2f7781") + commit = self.rorepo.commit("root") stats = commit.stats def check_entries(d, has_change_type=False): @@ -163,14 +165,14 @@ def check_entries(d, has_change_type=False): # END for each stated file # Check that data is parsed properly. - michael = Actor._from_string("Michael Trier ") - self.assertEqual(commit.author, michael) - self.assertEqual(commit.committer, michael) - self.assertEqual(commit.authored_date, 1210193388) - self.assertEqual(commit.committed_date, 1210193388) - self.assertEqual(commit.author_tz_offset, 14400, commit.author_tz_offset) - self.assertEqual(commit.committer_tz_offset, 14400, commit.committer_tz_offset) - self.assertEqual(commit.message, "initial project\n") + author = Actor("Fixture Author", "author@example.com") + self.assertEqual(commit.author, author) + self.assertEqual(commit.committer, author) + self.assertEqual(commit.authored_date, 978307200) + self.assertEqual(commit.committed_date, 978307200) + self.assertEqual(commit.author_tz_offset, 0) + self.assertEqual(commit.committer_tz_offset, 0) + self.assertEqual(commit.message, "Initial commit\n") def test_stats_preserve_filename_spaces(self): paths = ("name", " name", "name ", " name ", " ") @@ -273,7 +275,7 @@ def test_stats_preserve_quoted_filename_bytes(self): } def test_renames(self): - commit = self.rorepo.commit("185d847ec7647fd2642a82d9205fb3d07ea71715") + commit = self.rorepo.commit("renamed") files = commit.stats.files # When a file is renamed, the output of git diff is like "dir/{old => new}" @@ -283,15 +285,15 @@ def test_renames(self): def check_entries(path, changes): expected = { - ".github/workflows/Future.yml": { - "insertions": 57, + "renamed.txt": { + "insertions": 1, "deletions": 0, - "lines": 57, + "lines": 1, }, - ".github/workflows/test_pytest.yml": { + "side.txt": { "insertions": 0, - "deletions": 55, - "lines": 55, + "deletions": 1, + "lines": 1, }, } assert path in expected @@ -312,8 +314,8 @@ def test_unicode_actor(self): assert isinstance(special.name, str) def test_traversal(self): - start = self.rorepo.commit("a4d06724202afccd2b5c54f81bcf2bf26dea7fff") - first = self.rorepo.commit("33ebe7acec14b25c5f84f35a664803fcab2f7781") + start = self.rorepo.commit("merge") + first = self.rorepo.commit("root") p0 = start.parents[0] p1 = start.parents[1] p00 = p0.parents[0] @@ -333,9 +335,7 @@ def test_traversal(self): # At some point, both iterations should stop. self.assertEqual(list(bfirst)[-1], first) - stoptraverse = self.rorepo.commit("254d04aa3180eb8b8daf7b7ff25f010cd69b4e7d").traverse( - ignore_self=0, as_edge=True - ) + stoptraverse = self.rorepo.commit("merge").traverse(ignore_self=0, as_edge=True) stoptraverse_list = list(stoptraverse) for itemtup in stoptraverse_list: self.assertIsInstance(itemtup, (tuple)) and self.assertEqual(len(itemtup), 2) # as_edge=True -> tuple @@ -346,7 +346,7 @@ def test_traversal(self): else: self.assertIsNone(src) # ignore_self=0 -> first is (None, Commit) - stoptraverse = self.rorepo.commit("254d04aa3180eb8b8daf7b7ff25f010cd69b4e7d").traverse(as_edge=True) + stoptraverse = self.rorepo.commit("merge").traverse(as_edge=True) self.assertEqual(len(next(stoptraverse)), 2) # Ignore self @@ -374,7 +374,7 @@ def test_iteration(self): self.assertEqual(all_commits, list(self.rorepo.iter_commits())) # This includes merge commits. - mcomit = self.rorepo.commit("d884adc80c80300b4cc05321494713904ef1df2d") + mcomit = self.rorepo.commit("merge") assert mcomit in all_commits # We can limit the result to paths. @@ -420,20 +420,14 @@ def test_rev_list_bisect_all(self): in the commit header. This test ensures that we properly parse it. """ revs = self.rorepo.git.rev_list( - "933d23bf95a5bd1624fbcdf328d904e1fa173474", + "release", first_parent=True, bisect_all=True, ) commits = Commit._iter_from_process_or_stream(self.rorepo, StringProcessAdapter(revs.encode("ascii"))) - expected_ids = ( - "7156cece3c49544abb6bf7a0c218eb36646fad6d", - "1f66cfbbce58b4b552b041707a12d437cc5f400a", - "33ebe7acec14b25c5f84f35a664803fcab2f7781", - "933d23bf95a5bd1624fbcdf328d904e1fa173474", - ) - for sha1, commit in zip(expected_ids, commits): - self.assertEqual(sha1, commit.hexsha) + assert " (" in revs + self.assertEqual([line.split()[0] for line in revs.splitlines()], [commit.hexsha for commit in commits]) def test_iter_from_invalid_process_or_stream(self): for source, error in ((Mock(wait=Mock(), stdout=None), ValueError), (object(), TypeError)): @@ -445,14 +439,15 @@ def test_ambiguous_arg_iteration(self): assert list(repo.iter_commits(repo.head.ref)) == [repo.head.commit] def test_count(self): - self.assertEqual(self.rorepo.tag("refs/tags/0.1.5").commit.count(), 143) + self.assertEqual(self.rorepo.tag("refs/tags/root").commit.count(), 1) + self.assertEqual(self.rorepo.tag("refs/tags/base").commit.count(), 7) def test_list(self): # This doesn't work anymore, as we will either attempt getattr with bytes, or # compare 20 byte string with actual 20 byte bytes. This usage makes no sense # anyway. assert isinstance( - Commit.list_items(self.rorepo, "0.1.5", max_count=5)["5117c9c8a4d3af19a9958677e45cda9269de1541"], + Commit.list_items(self.rorepo, "base", max_count=5)[self.rorepo.commit("base").hexsha], Commit, ) @@ -473,7 +468,7 @@ def test_equality(self): def test_iter_parents(self): # Should return all but ourselves, even if skip is defined. - c = self.rorepo.commit("0.1.5") + c = self.rorepo.commit("base") for skip in (0, 1): piter = c.iter_parents(skip=skip) first_parent = next(piter) @@ -488,7 +483,7 @@ def test_name_rev(self): @with_rw_repo("HEAD", bare=True) def test_serialization(self, rwrepo): # Create all commits of our repo. - self.assert_commit_serialization(rwrepo, "0.1.6") + self.assert_commit_serialization(rwrepo, "release") @with_rw_repo("HEAD") def test_serialization_unicode_support(self, rw_repo): @@ -496,7 +491,7 @@ def test_serialization_unicode_support(self, rw_repo): # Create a commit with Unicode in the message, and the author's name. # Verify its serialization and deserialization. - cmt = rw_repo.commit("0.1.6") + cmt = rw_repo.commit("release") assert isinstance(cmt.message, str) # It automatically decodes it as such. assert isinstance(cmt.author.name, str) # Same here. @@ -526,7 +521,7 @@ def test_invalid_commit(self): "commit.sh", "raw", fixture_path("commit_invalid_data"), - Path(fixture_path("commit_invalid_data")).read_bytes().hex(), + sha256(Path(fixture_path("commit_invalid_data")).read_bytes()).hexdigest(), ) with Repo(fixture / "repo") as repo: cmt = Commit(repo, bytes.fromhex((fixture / "object-id").read_text().strip())) @@ -741,7 +736,7 @@ def test_gpgsig(self): "commit.sh", "raw", fixture_path("commit_with_gpgsig"), - Path(fixture_path("commit_with_gpgsig")).read_bytes().hex(), + sha256(Path(fixture_path("commit_with_gpgsig")).read_bytes()).hexdigest(), ) repo = Repo(fixture / "repo") self.addCleanup(repo.close) @@ -799,7 +794,7 @@ def stream(self, *args): assert getattr(c, field) is not None def test_datetimes(self): - commit = self.rorepo.commit("4251bd5") + commit = self.rorepo.commit("dated") self.assertEqual(commit.authored_date, 1255018625) self.assertEqual(commit.committed_date, 1255026171) self.assertEqual( @@ -878,7 +873,7 @@ def test_trailers(self): assert commit.trailers_dict == {KEY_2: [VALUE_2]} def test_commit_co_authors(self): - commit = copy.copy(self.rorepo.commit("4251bd5")) + commit = copy.copy(self.rorepo.commit("dated")) commit.message = """Commit message Co-authored-by: Test User 1 <602352+test@users.noreply.github.com> @@ -893,7 +888,7 @@ def test_commit_co_authors(self): def test_commit_co_authors_bounds_malformed_trailer(self): """A malformed trailer line must not make co_authors run in quadratic time.""" - commit = copy.copy(self.rorepo.commit("4251bd5")) + commit = copy.copy(self.rorepo.commit("dated")) # An unterminated trailer repeating " <" without a closing ">". The old # `(.*) <(.*?)>` regex backtracked over every " <" (O(n^2)); the crafted line # is fully attacker-controlled through the commit message. diff --git a/test/test_db.py b/test/test_db.py index 46580d84b..18bda8bcc 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -31,7 +31,7 @@ def test_base(self): gdb = GitCmdObjectDB(osp.join(self.rorepo.git_dir, "objects"), self.rorepo.git) # Partial to complete - works with everything. - hexsha = bin_to_hex(gdb.partial_to_complete_sha_hex("0.1.6")) + hexsha = bin_to_hex(gdb.partial_to_complete_sha_hex("release")) assert len(hexsha) == 40 assert bin_to_hex(gdb.partial_to_complete_sha_hex(hexsha[:20])) == hexsha diff --git a/test/test_diff.py b/test/test_diff.py index 5f3e44b80..abe41ac72 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -337,7 +337,7 @@ def test_empty_diff(self): self.assertEqual(res, []) def test_diff_initial_commit(self): - initial_commit = self.rorepo.commit("33ebe7acec14b25c5f84f35a664803fcab2f7781") + initial_commit = self.rorepo.commit("root") # Without creating a patch... diff_index = initial_commit.diff(NULL_TREE) @@ -350,7 +350,7 @@ def test_diff_initial_commit(self): self.assertIsNone(diff_index[0].a_path, repr(diff_index[0].a_path)) self.assertEqual(diff_index[0].b_path, "CHANGES", repr(diff_index[0].b_path)) self.assertIsNotNone(diff_index[0].new_file) - self.assertEqual(diff_index[0].diff, fixture("diff_initial")) + self.assertEqual(diff_index[0].diff, b"@@ -0,0 +1 @@\n+Initial changes\n") def test_diff_unsafe_paths(self): output = StringProcessAdapter(fixture("diff_patch_unsafe_paths")) @@ -471,7 +471,7 @@ def test_diff_rejects_no_index(self): def test_diff_interface(self): """Test a few variations of the main diff routine.""" assertion_map = {} - for i, commit in enumerate(self.rorepo.iter_commits("0.1.6", max_count=2)): + for i, commit in enumerate(self.rorepo.iter_commits("release", max_count=2)): diff_item = commit if i % 2 == 0: diff_item = commit.tree diff --git a/test/test_docs.py b/test/test_docs.py index ecbf525d5..a7abd9f4a 100644 --- a/test/test_docs.py +++ b/test/test_docs.py @@ -99,13 +99,15 @@ def test_init_repo_object(self, rw_dir, rw_repo): # [8-test_init_repo_object] active_branch = repo.active_branch self.assertEqual(repo.head.ref, active_branch) # HEAD is a sym-ref pointing to the active branch. - self.assertEqual(repo.tags["0.3.5"], repo.tag("refs/tags/0.3.5")) # You can access tags in various ways too. + self.assertEqual( + repo.tags["release"], repo.tag("refs/tags/release") + ) # You can access tags in various ways too. self.assertEqual(repo.refs[active_branch.name], repo.heads[active_branch.name]) # .refs provides all refs... if "TRAVIS" not in os.environ: remote_branch = next(ref for ref in repo.remotes.origin.refs if ref.remote_head != "HEAD") self.assertEqual(repo.refs[remote_branch.name], remote_branch) # ...remotes... - self.assertEqual(repo.refs["0.3.5"], repo.tags["0.3.5"]) # ... and tags. + self.assertEqual(repo.refs["release"], repo.tags["release"]) # ... and tags. # ![8-test_init_repo_object] # Create a new head/branch. @@ -316,17 +318,17 @@ def test_references_and_objects(self, rw_dir): # [12-test_references_and_objects] repo.commit("master") - repo.commit("v0.8.1") + repo.commit("my_tag") repo.commit("HEAD~10") # ![12-test_references_and_objects] # [13-test_references_and_objects] - fifty_first_commits = list(repo.iter_commits("master", max_count=50)) - assert len(fifty_first_commits) == 50 - # This will return commits 21-30 from the commit list as traversed backwards master. - ten_commits_past_twenty = list(repo.iter_commits("master", max_count=10, skip=20)) - assert len(ten_commits_past_twenty) == 10 - assert fifty_first_commits[20:30] == ten_commits_past_twenty + first_twenty_commits = list(repo.iter_commits("master", max_count=20)) + assert len(first_twenty_commits) == 20 + # This will return commits 11-20 from the commit list as traversed backwards master. + ten_commits_past_ten = list(repo.iter_commits("master", max_count=10, skip=10)) + assert len(ten_commits_past_ten) == 10 + assert first_twenty_commits[10:20] == ten_commits_past_ten # ![13-test_references_and_objects] # [14-test_references_and_objects] @@ -384,7 +386,7 @@ def test_references_and_objects(self, rw_dir): assert repo.tree() == repo.head.commit.tree past = repo.commit("HEAD~5") assert repo.tree(past) == repo.tree(past.hexsha) - self.assertEqual(repo.tree("v0.8.1").type, "tree") # Yes, you can provide any refspec - works everywhere. + self.assertEqual(repo.tree("my_tag").type, "tree") # Yes, you can provide any refspec - works everywhere. # ![21-test_references_and_objects] # [22-test_references_and_objects] @@ -506,7 +508,7 @@ def test_references_and_objects(self, rw_dir): repo.git.clear_cache() - @with_rw_repo("c15a6e1923a14bc760851913858a3942a4193cdb") + @with_rw_repo("submodule-current") @patch_git_config("protocol.file.allow", "always") def test_submodules(self, repo): # [1-test_submodules] diff --git a/test/test_fixture_health.py b/test/test_fixture_health.py index 63a1c6b8d..ed80e1a81 100644 --- a/test/test_fixture_health.py +++ b/test/test_fixture_health.py @@ -1,9 +1,12 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ -"""Verify that the source repository is usable by git.""" +"""Verify that generated fixtures are isolated, reusable, and usable by Git.""" +import os import subprocess +import shutil +import sys from pathlib import Path import pytest @@ -13,7 +16,37 @@ REPO_ROOT = Path(__file__).resolve().parent.parent -FIXTURE_DIRS = [pytest.param(REPO_ROOT, id="repo_root")] + +def test_shared_fixture_works_without_checkout_history(tmp_path): + source = tmp_path / "source" + source.mkdir() + shutil.copytree(REPO_ROOT / "test/lib", source / "test/lib", ignore=shutil.ignore_patterns("__pycache__")) + for name in ("__init__.py", "cleanup.py"): + shutil.copyfile(REPO_ROOT / "test" / name, source / "test" / name) + shutil.copytree( + REPO_ROOT / "tests/fixtures", + source / "tests/fixtures", + ignore=shutil.ignore_patterns("generated-*", "__pycache__"), + ) + result = subprocess.run( + [ + sys.executable, + "-c", + "from test.lib import TestBase; " + "TestBase.setUpClass(); " + "assert TestBase.rorepo.head.commit.parents; " + "TestBase.tearDownClass()", + ], + cwd=source, + env={ + key: value + for key, value in os.environ.items() + if key in {"PATH", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR", "GIT_PYTHON_BACKEND"} + }, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr def test_scripted_readonly_fixture_reuses_initial_state(): @@ -63,58 +96,11 @@ def fail(*args): assert caught.value.__suppress_context__ -def test_source_tree_has_no_gitlinks() -> None: - """All formerly external dependencies are regular monorepo directories.""" - if not (REPO_ROOT / ".git").exists(): - pytest.skip(f"{REPO_ROOT} is not a git checkout") - try: - result = subprocess.run( - ["git", "ls-files", "--stage"], - cwd=REPO_ROOT, - capture_output=True, - text=True, - check=True, - ) - except FileNotFoundError: - pytest.skip("git is not installed or not on PATH") - gitlinks = [line for line in result.stdout.splitlines() if line.startswith("160000 ")] - assert not gitlinks, "Unexpected submodules:\n" + "\n".join(gitlinks) - - -@pytest.mark.parametrize("fixture_dir", FIXTURE_DIRS) -def test_fixture_dir_is_trusted_by_git(fixture_dir: Path) -> None: - """git accepts ``fixture_dir`` as its own repository owned by a trusted user. - - Run ``git -C rev-parse --show-toplevel`` and assert it - succeeds and reports ``fixture_dir`` itself as the toplevel. Failure - typically means the directory's on-disk ownership doesn't match the - running user and the CI workflow's ``safe.directory`` list is missing - an entry that would override the check. - """ - if not (fixture_dir / ".git").exists(): - pytest.skip(f"{fixture_dir} is not a git checkout") - try: - result = subprocess.run( - ["git", "-C", str(fixture_dir), "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - check=False, - ) - except FileNotFoundError: - pytest.skip("git is not installed or not on PATH") - assert result.returncode == 0, ( - f"git refuses to operate in {fixture_dir}.\n" - f"stderr: {result.stderr.strip()}\n" - "The directory's owner doesn't match the running user and no " - "`safe.directory` entry overrides the check. On CI, the " - "workflow's `safe.directory` list typically needs an entry for " - "this path. Locally, this is unexpected and usually indicates " - "an ownership problem worth investigating." - ) - reported = Path(result.stdout.strip()) - assert reported.samefile(fixture_dir), ( - f"git reports the toplevel as {reported}, " - f"not as {fixture_dir} itself. " - "This usually means the directory is not an initialized git " - "repository (its `.git` marker may be stale or pointing elsewhere)." +@pytest.mark.parametrize("name", ["repo", "gitdb", "smmap"]) +def test_generated_repository_is_self_contained_and_trusted(name): + path = read_only_fixture("sample-repositories.sh") / name + assert not (path / ".git/objects/info/alternates").exists() + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], cwd=path, capture_output=True, text=True, check=True ) + assert Path(result.stdout.strip()).samefile(path) diff --git a/test/test_fun.py b/test/test_fun.py index 5ae101824..f392f5de9 100644 --- a/test/test_fun.py +++ b/test/test_fun.py @@ -33,12 +33,10 @@ def _assert_index_entries(self, entries, trees): # END assert entry matches fully def test_aggressive_tree_merge(self): - # Head tree with additions, removals and modification compared to its - # predecessor. + # Trees with additions, removals and modifications, including a submodule. repo = self.rorepo - HC = self.rorepo.commit("6c1faef799095f3990e9970bc2cb10aa0221cf9c") - H = HC.tree - B = HC.parents[0].tree + H = repo.tree("submodule-current") + B = repo.tree("side") # Entries from single tree. trees = [H.binsha] @@ -50,16 +48,16 @@ def test_aggressive_tree_merge(self): # Three way, no conflict. tree = self.rorepo.tree - B = tree("35a09c0534e89b2d43ec4101a5fb54576b577905") - H = tree("4fe5cfa0e063a8d51a1eb6f014e2aaa994e5e7d4") - M = tree("1f2b19de3301e76ab3a6187a49c9c93ff78bafbd") + B = tree("manifest-update") + H = tree("merge^1") + M = tree("side") trees = [B.binsha, H.binsha, M.binsha] self._assert_index_entries(self.merge_trees(repo, trees), trees) # Three-way, conflict in at least one file, both modified. - B = tree("a7a4388eeaa4b6b94192dce67257a34c4a6cbd26") - H = tree("f9cec00938d9059882bb8eabdaf2f775943e00e5") - M = tree("44a601a068f4f543f73fd9c49e264c931b1e1652") + B = tree("base") + H = tree("conflict-ours") + M = tree("conflict-theirs") trees = [B.binsha, H.binsha, M.binsha] self._assert_index_entries(self.merge_trees(repo, trees), trees) @@ -134,7 +132,7 @@ def test_linked_worktree_traversal(self): self.assertIsNotNone(gitdir) self.assertTrue(stat(gitdir).st_mode & S_IFDIR) - @with_rw_repo("0.1.6") + @with_rw_repo("release") def test_tree_names_from_git_preserve_undecodable_bytes(self, repo): tree = Tree(repo, repo._null_binsha, path="") tree._cache = [(b"a" * repo._oid_size, 0o100644, "\udc9f")] diff --git a/test/test_git.py b/test/test_git.py index dac9c11ed..5b1dd49de 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -558,15 +558,15 @@ def test_it_accepts_environment_variables(self): def test_persistent_cat_file_command(self): # Read header only. - hexsha = "b2339455342180c7cc1e9bba3e9f181f7baa5167" + hexsha = self.rorepo.commit("root").hexsha g = self.git.cat_file(batch_check=True, istream=subprocess.PIPE, as_process=True) - g.stdin.write(b"b2339455342180c7cc1e9bba3e9f181f7baa5167\n") + g.stdin.write((hexsha + "\n").encode("ascii")) g.stdin.flush() obj_info = g.stdout.readline() # Read header + data. g = self.git.cat_file(batch=True, istream=subprocess.PIPE, as_process=True) - g.stdin.write(b"b2339455342180c7cc1e9bba3e9f181f7baa5167\n") + g.stdin.write((hexsha + "\n").encode("ascii")) g.stdin.flush() obj_info_two = g.stdout.readline() self.assertEqual(obj_info, obj_info_two) @@ -577,7 +577,7 @@ def test_persistent_cat_file_command(self): g.stdout.read(1) # Now we should be able to read a new object. - g.stdin.write(b"b2339455342180c7cc1e9bba3e9f181f7baa5167\n") + g.stdin.write((hexsha + "\n").encode("ascii")) g.stdin.flush() self.assertEqual(g.stdout.readline(), obj_info) @@ -588,7 +588,7 @@ def test_persistent_cat_file_command(self): self.assertEqual(size, size_two) def test_object_header_sends_each_name_as_one_record(self): - hexsha = "b2339455342180c7cc1e9bba3e9f181f7baa5167" + hexsha = self.rorepo.commit("root").hexsha git = Git(self.rorepo.working_dir) header = git.get_object_header(hexsha) diff --git a/test/test_index.py b/test/test_index.py index 63d33fbd1..ab7ae46ea 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -215,7 +215,7 @@ def _cmp_tree_index(self, tree, index): } assert {(entry.path, entry.stage) for entry in index.iter_entries()} == expected - @with_rw_repo("0.1.6") + @with_rw_repo("release") def test_index_lock_handling(self, rw_repo): index = rw_repo.index index_path = Path(index.path) @@ -240,7 +240,7 @@ def fail_copy(source, stream): assert not lock_path.exists() assert rw_repo.index.entry(*("foo", 0)).mode == 0o100644 - @with_rw_repo("0.1.6") + @with_rw_repo("release") def test_read_tree_methods_reject_index_output(self, rw_repo): output_path = (Path(rw_repo.working_tree_dir) / "alternate-index").as_posix() unsafe_option = f"--index-output={output_path}" @@ -256,11 +256,11 @@ def test_read_tree_methods_reject_index_output(self, rw_repo): with pytest.raises(UnsafeOptionError): rw_repo.index.merge_tree("HEAD", base=unsafe_option) - @with_rw_repo("0.1.6") + @with_rw_repo("release") def test_index_file_from_tree(self, rw_repo): - common_ancestor_sha = "5117c9c8a4d3af19a9958677e45cda9269de1541" - cur_sha = "4b43ca7ff72d5f535134241e7c797ddc9c7a3573" - other_sha = "39f85c4358b7346fee22169da9cad93901ea9eb9" + common_ancestor_sha = "base" + cur_sha = "conflict-ours" + other_sha = "conflict-theirs" # Simple index from tree. base_index = IndexFile.from_tree(rw_repo, common_ancestor_sha) @@ -309,7 +309,7 @@ def merge_required(t): # END for each blob self.assertEqual(num_blobs, len([(e.path, e.stage) for e in three_way_index.iter_entries()])) - @with_rw_repo("0.1.6") + @with_rw_repo("release") def test_index_merge_tree(self, rw_repo): # A bit out of place, but we need a different repo for this: self.assertNotEqual(self.rorepo, rw_repo) @@ -317,7 +317,7 @@ def test_index_merge_tree(self, rw_repo): # SINGLE TREE MERGE # Current index is at the (virtual) cur_commit. - next_commit = "4c39f9da792792d4e73fc3a5effde66576ae128c" + next_commit = "manifest-update" parent_commit = rw_repo.head.commit.parents[0] manifest_key = IndexFile.entry_key("MANIFEST.in", 0) manifest_entry = rw_repo.index.entry(*manifest_key) @@ -372,7 +372,7 @@ def test_index_merge_tree(self, rw_repo): self.assertEqual(len(unmerged_blobs), 1) self.assertEqual(list(unmerged_blobs.keys())[0], manifest_key[0]) - @with_rw_repo("0.1.6") + @with_rw_repo("release") def test_index_file_diffing(self, rw_repo): # Default IndexFile instance points to our index. index = IndexFile(rw_repo) @@ -388,7 +388,7 @@ def test_index_file_diffing(self, rw_repo): # Resetting the head will leave the index in a different state, and the diff # will yield a few changes. cur_head_commit = rw_repo.head.reference.commit - rw_repo.head.reset("HEAD~6", index=True, working_tree=False) + rw_repo.head.reset("HEAD~2", index=True, working_tree=False) # Diff against same index is 0. diff = index.diff() @@ -530,7 +530,7 @@ def _count_existing(self, repo, files): # END num existing helper - @with_rw_repo("0.1.6") + @with_rw_repo("release") def test_index_mutation(self, rw_repo): with xfail_if_raises( sys.platform == "win32" and (Git().config("core.symlinks") == "true" or symlinks_supported()), @@ -681,11 +681,11 @@ def mixed_iterator(): entries = index.reset(new_commit).add([osp.join("lib", "git", "*.py")], fprogress=self._fprogress_add) self._assert_entries(entries) self._assert_fprogress(entries) - self.assertEqual(len(entries), 14) + self.assertEqual({entry.path for entry in entries}, {"lib/git/__init__.py", "lib/git/blob.py"}) # Same file. entries = index.reset(new_commit).add( - [osp.join(rw_repo.working_tree_dir, "lib", "git", "head.py")] * 2, + [osp.join(rw_repo.working_tree_dir, "lib", "git", "blob.py")] * 2, fprogress=self._fprogress_add, ) self._assert_entries(entries) @@ -838,8 +838,8 @@ def rewriter(entry): def make_paths(): """Help out the test by yielding two existing paths and one new path.""" yield "CHANGES" - yield "ez_setup.py" - yield index.entry(*index.entry_key("README", 0)) + yield "setup.py" + yield index.entry(*index.entry_key("README.md", 0)) yield index.entry(*index.entry_key(".gitignore", 0)) for fid in range(3): @@ -916,9 +916,9 @@ def test_index_single_addremove(self): assert repo.index.remove(fp) def test_index_new(self): - B = self.rorepo.tree("6d9b1f4f9fa8c9f030e3207e7deacc5d5f8bba4e") - H = self.rorepo.tree("25dca42bac17d511b7e2ebdd9d1d679e7626db5f") - M = self.rorepo.tree("e746f96bcc29238b79118123028ca170adc4ff0f") + B = self.rorepo.tree("base") + H = self.rorepo.tree("conflict-ours") + M = self.rorepo.tree("conflict-theirs") for args in ((B.binsha,), (B.hexsha, H), (B, H.binsha, M.hexsha.encode("ascii"))): index = IndexFile.new(self.rorepo, *args) diff --git a/test/test_refs.py b/test/test_refs.py index 97b14b61e..efeee1d04 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -486,14 +486,14 @@ def test_tag_base(self): # END if we have a tag object # END for tag in repo-tags assert tag_object_refs - assert isinstance(self.rorepo.tags["0.1.5"], TagReference) + assert isinstance(self.rorepo.tags["base"], TagReference) def test_tags_author(self): tag = self.rorepo.tags[0] tagobj = tag.tag assert isinstance(tagobj.tagger, Actor) tagger_name = tagobj.tagger.name - assert tagger_name == "Michael Trier" + assert tagger_name == "Fixture Author" def test_tag_headers_split_at_line_feeds_only(self): with fixture_repo("refs.sh", "tag-headers") as repo: @@ -638,7 +638,7 @@ def test_is_valid(self): def test_orig_head(self): assert type(self.rorepo.head.orig_head()) is SymbolicReference - @with_rw_repo("0.1.6") + @with_rw_repo("release") def test_head_checkout_detached_head(self, rw_repo): res = rw_repo.remotes.origin.refs.HEAD.reference.checkout() assert isinstance(res, SymbolicReference) @@ -697,7 +697,7 @@ def test_head_commands_allow_explicit_pathspec_from_file(self, rw_repo): assert head.reset(**options) is head assert branch.checkout(**options) == branch - @with_rw_repo("0.1.6") + @with_rw_repo("release") def test_head_reset(self, rw_repo): cur_head = rw_repo.head old_head_commit = cur_head.commit @@ -902,7 +902,7 @@ def test_head_reset(self, rw_repo): assert len(new_head.commit.diff(None)) # Create a new branch that is likely to touch the file we changed. - far_away_head = rw_repo.create_head("far_head", "HEAD~100") + far_away_head = rw_repo.create_head("far_head", "root") self.assertRaises(GitCommandError, far_away_head.checkout) assert active_branch == active_branch.checkout(force=True) assert rw_repo.head.reference != far_away_head @@ -914,7 +914,7 @@ def test_head_reset(self, rw_repo): assert ref.path == full_ref assert ref.object == rw_repo.head.commit - self.assertRaises(OSError, Reference.create, rw_repo, full_ref, "HEAD~20") + self.assertRaises(OSError, Reference.create, rw_repo, full_ref, "root") # It works if it is at the same spot though and points to the same reference. assert Reference.create(rw_repo, full_ref, "HEAD").path == full_ref Reference.delete(rw_repo, full_ref) @@ -1071,7 +1071,7 @@ def test_head_reset(self, rw_repo): # END for each path - @with_rw_repo("0.1.6") + @with_rw_repo("release") def test_tag_message(self, rw_repo): tag_ref = TagReference.create(rw_repo, "test-message-1", message="test") assert tag_ref.tag.message == "test" diff --git a/test/test_remote.py b/test/test_remote.py index 529c37aaf..aba4ddce7 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -443,7 +443,7 @@ def _assert_push_and_pull(self, remote, rw_repo, remote_repo): @pytest.mark.net @skipIf(sys.platform == "win32", "FIXME: Freezes!") - @with_rw_and_rw_remote_repo("0.1.6") + @with_rw_and_rw_remote_repo("release") def test_base(self, rw_repo, remote_repo): num_remotes = 0 remote_set = set() @@ -646,7 +646,7 @@ def test_fetch_error(self, rw_repo): with self.assertRaisesRegex(GitCommandError, msg): rem.fetch("__BAD_REF__") - @with_rw_repo("0.1.6", bare=False) + @with_rw_repo("release", bare=False) def test_push_error(self, repo): rem = repo.remote("origin") with self.assertRaisesRegex(GitCommandError, "src refspec __BAD_REF__ does not match any"): @@ -1033,7 +1033,7 @@ def test_ls_remote_unsafe_options_allowed(self, rw_repo): rw_repo.git.ls_remote(".", **unsafe_option, allow_unsafe_options=True) @pytest.mark.net - @with_rw_and_rw_remote_repo("0.1.6") + @with_rw_and_rw_remote_repo("release") def test_fetch_unsafe_branch_name(self, rw_repo, remote_repo): # Create branch with a name containing a NBSP bad_branch_name = f"branch_with_{chr(160)}_nbsp" diff --git a/test/test_repo.py b/test/test_repo.py index 38ac3e630..378208b30 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -4,7 +4,6 @@ # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ import gc -import glob import io import itertools import os @@ -39,7 +38,7 @@ from git.compat import safe_decode from git.exc import BadObject, UnsafeOptionError, UnsafeProtocolError from git.repo.fun import find_worktree_git_dir -from git.util import bin_to_hex, cwd, rmfile, rmtree +from git.util import bin_to_hex, cwd, rmtree from test.lib import PathLikeMock, TestBase, fixture, with_rw_directory, with_rw_repo from test.lib.fixtures import fixture_repo, read_only_fixture, writable_fixture @@ -53,23 +52,8 @@ def flatten(lol): return list(iter_flatten(lol)) -_tc_lock_fpaths = osp.join(osp.dirname(__file__), "../../.git/*.lock") - - -def _rm_lock_files(): - for lfp in glob.glob(_tc_lock_fpaths): - rmfile(lfp) - - class TestRepo(TestBase): - def setUp(self): - _rm_lock_files() - def tearDown(self): - for lfp in glob.glob(_tc_lock_fpaths): - if osp.isfile(lfp): - raise AssertionError("Previous TC left hanging git-lock file: {}".format(lfp)) - gc.collect() def test_new_should_raise_on_invalid_repo_location(self): @@ -224,7 +208,7 @@ def test_repo_discovery_preserves_object_directory(self): def test_repo_creation_from_different_paths(self): path = read_only_fixture( - "history.sh", self.rorepo.working_dir, self.rorepo.head.commit.hexsha, "0.3.2.1", "worktree" + "history.sh", self.rorepo.working_dir, self.rorepo.head.commit.hexsha, "tree-layout", "worktree" ) rw_repo = Repo(path / "repo") r_from_gitdir = Repo(rw_repo.git_dir) @@ -235,7 +219,7 @@ def test_repo_creation_from_different_paths(self): def test_repo_creation_pathlib(self): path = read_only_fixture( - "history.sh", self.rorepo.working_dir, self.rorepo.head.commit.hexsha, "0.3.2.1", "worktree" + "history.sh", self.rorepo.working_dir, self.rorepo.head.commit.hexsha, "tree-layout", "worktree" ) rw_repo = Repo(path / "repo") r_from_gitdir = Repo(pathlib.Path(rw_repo.git_dir)) @@ -243,7 +227,7 @@ def test_repo_creation_pathlib(self): def test_repo_creation_pathlike(self): path = read_only_fixture( - "history.sh", self.rorepo.working_dir, self.rorepo.head.commit.hexsha, "0.3.2.1", "worktree" + "history.sh", self.rorepo.working_dir, self.rorepo.head.commit.hexsha, "tree-layout", "worktree" ) rw_repo = Repo(path / "repo") r_from_gitdir = Repo(PathLikeMock(rw_repo.git_dir)) @@ -270,7 +254,7 @@ def test_heads_should_populate_head_data(self): self.assertEqual(self.rorepo.heads[active_branch.name], active_branch) def test_tree_from_revision(self): - tree = self.rorepo.tree("0.1.6") + tree = self.rorepo.tree("release") self.assertEqual(len(tree.hexsha), 40) self.assertEqual(tree.type, "tree") self.assertEqual(self.rorepo.tree(tree), tree) @@ -282,7 +266,7 @@ def test_pickleable(self): pickle.loads(pickle.dumps(self.rorepo)) def test_commit_from_revision(self): - commit = self.rorepo.commit("0.1.4") + commit = self.rorepo.commit("annotated") self.assertEqual(commit.type, "commit") self.assertEqual(self.rorepo.commit(commit), commit) @@ -294,31 +278,30 @@ def test_commit_from_tag_starting_with_at(self): self.assertEqual(repo.commit("@foo"), commit) def test_commits(self): - mc = 10 - commits = list(self.rorepo.iter_commits("0.1.6", max_count=mc)) + mc = 5 + commits = list(self.rorepo.iter_commits("release", max_count=mc)) self.assertEqual(len(commits), mc) c = commits[0] - self.assertEqual("9a4b1d4d11eee3c5362a4152216376e634bd14cf", c.hexsha) - self.assertEqual(["c76852d0bff115720af3f27acdb084c59361e5f6"], [p.hexsha for p in c.parents]) - self.assertEqual("ce41fc29549042f1aa09cc03174896cf23f112e3", c.tree.hexsha) - self.assertEqual("Michael Trier", c.author.name) - self.assertEqual("mtrier@gmail.com", c.author.email) - self.assertEqual(1232829715, c.authored_date) - self.assertEqual(5 * 3600, c.author_tz_offset) - self.assertEqual("Michael Trier", c.committer.name) - self.assertEqual("mtrier@gmail.com", c.committer.email) - self.assertEqual(1232829715, c.committed_date) - self.assertEqual(5 * 3600, c.committer_tz_offset) - self.assertEqual("Bumped version 0.1.6\n", c.message) + self.assertEqual(self.rorepo.git.rev_parse("release"), c.hexsha) + self.assertEqual([self.rorepo.commit("base")], list(c.parents)) + self.assertEqual(self.rorepo.git.rev_parse("release^{tree}"), c.tree.hexsha) + self.assertEqual("Fixture Author", c.author.name) + self.assertEqual("author@example.com", c.author.email) + self.assertEqual(978307200, c.authored_date) + self.assertEqual(0, c.author_tz_offset) + self.assertEqual(c.author, c.committer) + self.assertEqual(c.authored_date, c.committed_date) + self.assertEqual(0, c.committer_tz_offset) + self.assertEqual("Release sample project\n", c.message) c = commits[1] self.assertIsInstance(c.parents, tuple) def test_trees(self): - mc = 30 + mc = 5 num_trees = 0 - for tree in self.rorepo.iter_trees("0.1.5", max_count=mc): + for tree in self.rorepo.iter_trees("base", max_count=mc): num_trees += 1 self.assertIsInstance(tree, Tree) # END for each tree @@ -555,10 +538,10 @@ def test_index(self): self.assertIsInstance(index, IndexFile) def test_tag(self): - assert self.rorepo.tag("refs/tags/0.1.5").commit + assert self.rorepo.tag("refs/tags/base").commit def test_tag_to_full_tag_path(self): - tags = ["0.1.5", "tags/0.1.5", "refs/tags/0.1.5"] + tags = ["base", "tags/base", "refs/tags/base"] value_errors = [] for tag in tags: try: @@ -569,7 +552,7 @@ def test_tag_to_full_tag_path(self): def test_archive(self): with tempfile.NamedTemporaryFile("wb", suffix="archive-test", delete=False) as stream: - self.rorepo.archive(stream, "0.1.6", path="doc") + self.rorepo.archive(stream, "release", path="doc") assert stream.tell() os.remove(stream.name) # Do it this way so we can inspect the file on failure. @@ -577,15 +560,15 @@ def test_archive_rejects_unsafe_options(self): tdir = str(read_only_fixture("repo.sh", "empty")) output_marker = osp.join(tdir, "pwn") with self.assertRaises(UnsafeOptionError): - self.rorepo.archive(io.BytesIO(), "0.1.6", exec=f"touch {output_marker}") + self.rorepo.archive(io.BytesIO(), "release", exec=f"touch {output_marker}") assert not osp.exists(output_marker) with self.assertRaises(UnsafeOptionError): - self.rorepo.archive(io.BytesIO(), "0.1.6", output=output_marker) + self.rorepo.archive(io.BytesIO(), "release", output=output_marker) assert not osp.exists(output_marker) with self.assertRaises(UnsafeOptionError): - self.rorepo.archive(io.BytesIO(), "0.1.6", add_file=output_marker) + self.rorepo.archive(io.BytesIO(), "release", add_file=output_marker) with self.assertRaises(UnsafeOptionError): - self.rorepo.archive(io.BytesIO(), "0.1.6", add_virtual_file="file:content") + self.rorepo.archive(io.BytesIO(), "release", add_virtual_file="file:content") def test_archive_rejects_unsafe_remote_protocol(self): tdir = str(read_only_fixture("repo.sh", "empty")) @@ -996,13 +979,13 @@ def _assert_rev_parse(self, name): self._assert_rev_parse_types(rev, obj2) # History with number - ni = 11 - history = [obj.parents[0]] - for _ in range(ni): - history.append(history[-1].parents[0]) - # END get given amount of commits + history = [] + ancestor = obj + while ancestor.parents and len(history) < 11: + ancestor = ancestor.parents[0] + history.append(ancestor) - for pn in range(11): + for pn in range(len(history)): rev = name + "~%i" % (pn + 1) obj2 = rev_parse(rev) self.assertEqual(obj2, history[pn]) @@ -1033,7 +1016,8 @@ def test_rev_parse(self): rev_parse = self.rorepo.rev_parse # Try special case: This one failed at some point, make sure its fixed. - self.assertEqual(rev_parse("33ebe").hexsha, "33ebe7acec14b25c5f84f35a664803fcab2f7781") + root_sha = self.rorepo.git.rev_parse("root") + self.assertEqual(rev_parse(root_sha[:5]).hexsha, root_sha) # Start from reference. num_resolved = 0 @@ -1058,7 +1042,7 @@ def test_rev_parse(self): assert num_resolved # It works with tags! - tag = self._assert_rev_parse("0.1.4") + tag = self._assert_rev_parse("annotated") self.assertEqual(tag.type, "tag") # try full sha directly (including type conversion). @@ -1066,13 +1050,13 @@ def test_rev_parse(self): self._assert_rev_parse_types(tag.object.hexsha, tag.object) # Multiple tree types result in the same tree: HEAD^{tree}^{tree}:CHANGES - rev = "0.1.4^{tree}^{tree}" + rev = "annotated^{tree}^{tree}" self.assertEqual(rev_parse(rev), tag.object.tree) self.assertEqual(rev_parse(rev + ":CHANGES"), tag.object.tree["CHANGES"]) # Try to get parents from first revision - it should fail as no such revision # exists. - first_rev = "33ebe7acec14b25c5f84f35a664803fcab2f7781" + first_rev = root_sha commit = rev_parse(first_rev) self.assertEqual(len(commit.parents), 0) self.assertEqual(commit.hexsha, first_rev) @@ -1089,9 +1073,9 @@ def test_rev_parse(self): # Needs a tag which points to a blob. # ref^0 returns commit being pointed to, same with ref~0, ^{}, and ^{commit} - tag = rev_parse("0.1.4") + tag = rev_parse("annotated") for token in ("~0", "^0", "^{}", "^{commit}"): - self.assertEqual(tag.object, rev_parse("0.1.4%s" % token)) + self.assertEqual(tag.object, rev_parse("annotated%s" % token)) # END handle multiple tokens # Try partial parsing. @@ -1108,10 +1092,10 @@ def test_rev_parse(self): # END for each binsha in repo # Missing closing brace: commit^{tree - self.assertRaises(BadName, rev_parse, "0.1.4^{tree") + self.assertRaises(BadName, rev_parse, "annotated^{tree") # Missing starting brace. - self.assertRaises(BadName, rev_parse, "0.1.4^tree}") + self.assertRaises(BadName, rev_parse, "annotated^tree}") # REVLOG ####### @@ -1153,7 +1137,7 @@ def test_submodules(self): "history.sh", self.rorepo.working_dir, self.rorepo.head.commit.hexsha, - "c15a6e1923a14bc760851913858a3942a4193cdb", + "submodule-current", "bare", ) rwrepo = Repo(path / "repo") @@ -1198,7 +1182,7 @@ def last_commit(repo, rev, path): for _ in range(64): for repo_type in (GitCmdObjectDB,): repo = Repo(self.rorepo.working_tree_dir, odbt=repo_type) - last_commit(repo, "HEAD", "test/test_base.py") + last_commit(repo, "HEAD", "git/objects/blob.py") # END for each repository type # END for each iteration @@ -1273,9 +1257,9 @@ def test_active_branch_raises_type_error_when_head_is_detached(self): def test_merge_base(self): repo = self.rorepo - c1 = "f6aa8d1" - c2 = repo.commit("d46e3fe") - c3 = "763ef75" + c1 = "merge^1" + c2 = repo.commit("side") + c3 = "merge" self.assertRaises(ValueError, repo.merge_base) self.assertRaises(ValueError, repo.merge_base, "foo") @@ -1284,7 +1268,7 @@ def test_merge_base(self): self.assertIsInstance(res, list) self.assertEqual(len(res), 1) self.assertIsInstance(res[0], Commit) - self.assertTrue(res[0].hexsha.startswith("3936084")) + self.assertEqual(res[0], repo.commit("manifest-update")) for kw in ("a", "all"): res = repo.merge_base(c1, c2, c3, **{kw: True}) @@ -1301,8 +1285,8 @@ def test_is_ancestor(self): raise RuntimeError("git merge-base --is-ancestor feature unsupported (test needs git 1.8.0 or later)") repo = self.rorepo - c1 = "f6aa8d1" - c2 = "763ef75" + c1 = "base" + c2 = "merge" self.assertTrue(repo.is_ancestor(c1, c1)) self.assertTrue(repo.is_ancestor("HEAD", "HEAD")) self.assertTrue(repo.is_ancestor(c1, c2)) @@ -1314,10 +1298,10 @@ def test_is_ancestor(self): def test_is_valid_object(self): repo = self.rorepo - commit_sha = "f6aa8d1" - blob_sha = "1fbe3e4375" - tree_sha = "960b40fe36" - tag_sha = "42c2f60c43" + commit_sha = repo.git.rev_parse("release")[:10] + blob_sha = repo.git.rev_parse("HEAD:README.md")[:10] + tree_sha = repo.git.rev_parse("HEAD^{tree}")[:10] + tag_sha = repo.git.rev_parse("annotated")[:10] # Check for valid objects. self.assertTrue(repo.is_valid_object(commit_sha)) @@ -1355,11 +1339,11 @@ def test_git_work_tree_from_bare_repo(self): assert Git(worktree).rev_parse("--is-bare-repository") == "false" assert not repo.bare assert osp.samefile(repo.working_tree_dir, worktree) - subdir = worktree / "git" / "repo" + subdir = worktree / "git" / "objects" with Repo(subdir, search_parent_directories=True) as sub_repo: assert not sub_repo.bare assert osp.samefile(sub_repo.working_dir, worktree) - expected = repo.git.log("-n", "1", "--format=%H", "--", "git/repo") + expected = repo.git.log("-n", "1", "--format=%H", "--", "git/objects") assert sub_repo.git.log("-n", "1", "--format=%H", "--", str(subdir)) == expected def test_git_work_tree_dotgit_relative(self): diff --git a/test/test_submodule.py b/test/test_submodule.py index c2620e9d9..751235e21 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1231,6 +1231,21 @@ def update(self, op, cur_count, max_count, message=""): prog = TestRootProgress() +@pytest.mark.parametrize("state", ["index", "worktree", "untracked"]) +@pytest.mark.parametrize("dry_run", [False, True]) +def test_remove_preserves_dirty_submodule(state, dry_run): + source = read_only_fixture("sample-repositories.sh") / "smmap" + with writable_fixture("submodule-cases.sh", "remove-dirty-" + state, source) as temporary: + with git.Repo(temporary.path() / "parent") as repo: + submodule = repo.submodules[0] + path = Path(submodule.abspath, "untracked" if state == "untracked" else "file") + with pytest.raises(InvalidGitRepositoryError): + submodule.remove(dry_run=dry_run) + assert submodule.exists() and submodule.module_exists() + assert path.read_text() == "keep this data\n" + assert not repo.is_dirty(working_tree=False) + + class TestSubmodule(TestBase): def _fixture(self, scenario, *, write=True, execute=False): source = self._small_repo_url() @@ -1243,9 +1258,9 @@ def _fixture(self, scenario, *, write=True, execute=False): def tearDown(self): gc.collect() - k_subm_current = "c15a6e1923a14bc760851913858a3942a4193cdb" - k_subm_changed = "394ed7006ee5dc8bddfd132b64001d5dfc0ffdd3" - k_no_subm_tag = "0.1.6" + k_subm_current = "submodule-current" + k_subm_changed = "submodule-old" + k_no_subm_tag = "release" def _do_base_tests(self, rwrepo): """Perform all tests in the given repository, it may be bare or nonbare""" @@ -1264,7 +1279,7 @@ def _do_base_tests(self, rwrepo): assert sm.path == "git/ext/gitdb" assert sm.path != sm.name # In our case, we have ids there, which don't equal the path. - assert sm.url.endswith("github.com/gitpython-developers/gitdb.git") + assert sm.url == "../gitdb" assert sm.branch_path == "refs/heads/master" # the default ... assert sm.branch_name == "master" assert sm.parent_commit == rwrepo.head.commit @@ -1296,7 +1311,7 @@ def _do_base_tests(self, rwrepo): new_csmclone_path = None # if rwrepo.bare: with self.assertRaises(InvalidGitRepositoryError): - with sm.config_writer() as cw: + with sm.config_writer(): pass else: with sm.config_writer() as writer: @@ -1467,6 +1482,7 @@ def _do_base_tests(self, rwrepo): # If the head is detached, it still works (but warns). smref = sm.module().head.ref sm.module().head.ref = "HEAD~1" + sm.module().head.reset(index=True, working_tree=True) # If there is no tracking branch, we get a warning as well. csm_tracking_branch = csm.module().head.ref.tracking_branch() csm.module().head.ref.set_tracking_branch(None) @@ -1480,60 +1496,28 @@ def _do_base_tests(self, rwrepo): sm.module().head.ref = smref csm.module().head.ref.set_tracking_branch(csm_tracking_branch) - # REMOVAL OF REPOSITORY - ####################### - # Must delete something. - self.assertRaises(ValueError, csm.remove, module=False, configuration=False) - - # module() is supposed to point to gitdb, which has a child-submodule whose - # URL is still pointing to GitHub. To save time, we will change it to: - csm.set_parent_commit(csm.repo.head.commit) - with csm.config_writer() as cw: - cw.set_value("url", self._small_repo_url()) - csm.repo.index.commit("adjusted URL to point to local source, instead of the internet", skip_hooks=True) - - # We have modified the configuration, hence the index is dirty, and the - # deletion will fail. - # NOTE: As we did a few updates in the meanwhile, the indices were reset. - # Hence we create some changes. + # Test removal from an explicitly clean parent and a dirty child. + sm.module().head.reset(index=True, working_tree=True) csm.set_parent_commit(csm.repo.head.commit) - with sm.config_writer() as writer: - writer.set_value("somekey", "somevalue") - with csm.config_writer() as writer: - writer.set_value("okey", "ovalue") + self.assertRaises(ValueError, csm.remove, module=False, configuration=False) + fn = join_path_native(csm.module().working_tree_dir, "newfile") + with open(fn, "w") as fd: + fd.write("untracked child data") + self.assertRaises(InvalidGitRepositoryError, csm.remove) self.assertRaises(InvalidGitRepositoryError, sm.remove) - # If we remove the dirty index, it would work. - sm.module().index.reset() - # Still, we have the file modified. self.assertRaises(InvalidGitRepositoryError, sm.remove, dry_run=True) - sm.module().index.reset(working_tree=True) - - # Enforce the submodule to be checked out at the right spot as well. - csm.update() - assert csm.module_exists() - assert csm.exists() - assert osp.isdir(csm.module().working_tree_dir) + assert Path(fn).read_text() == "untracked child data" - # This would work. assert sm.remove(force=True, dry_run=True) is sm - assert sm.module_exists() - sm.remove(force=True, dry_run=True) - assert sm.module_exists() - - # But... we have untracked files in the child submodule. - fn = join_path_native(csm.module().working_tree_dir, "newfile") - with open(fn, "w") as fd: - fd.write("hi") - self.assertRaises(InvalidGitRepositoryError, sm.remove) + assert sm.module_exists() and csm.module_exists() + assert csm.exists() + assert Path(fn).read_text() == "untracked child data" - # Forcibly delete the child repository. prev_count = len(sm.children()) csm.remove(force=True) assert not csm.exists() assert not csm.module_exists() assert len(sm.children()) == prev_count - 1 - # Now we have a changed index, as configuration was altered. - # Fix this. sm.module().index.reset(working_tree=True) # Now delete only the module of the main submodule. diff --git a/test/test_tree.py b/test/test_tree.py index eea3f574f..8d29319fb 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -105,7 +105,7 @@ class TestTree(TestBase): @with_rw_repo("HEAD") def test_serializable(self, rw_repo): # Tree at the given commit contains a submodule as well. - roottree = rw_repo.tree("6c1faef799095f3990e9970bc2cb10aa0221cf9c") + roottree = rw_repo.tree("submodule-current") for item in roottree.traverse(ignore_self=False): if item.type != Tree.type: continue @@ -154,7 +154,7 @@ def test_tree_modifier_ordering(self): """TreeModifier.set_done() sorts files in the same order git does.""" git_file_names_in_order = self._get_git_ordered_files() - hexsha = "6c1faef799095f3990e9970bc2cb10aa0221cf9c" + hexsha = self.rorepo.commit("submodule-current").hexsha roottree = self.rorepo.tree(hexsha) blob_mode = Tree.blob_id << 12 tree_mode = Tree.tree_id << 12 @@ -303,7 +303,7 @@ def test_gitmodules_short_name_near_misses_round_trip(self, rw_repo, name): assert Tree(rw_repo, stored.binsha, path="")._cache == cache def test_traverse(self): - root = self.rorepo.tree("0.1.6") + root = self.rorepo.tree("release") num_recursive = 0 all_items = [] for obj in root.traverse(): @@ -358,47 +358,47 @@ def lib_folder(t, _d): assert found_slash def test_repo_lookup_string_path(self): - tree = self.rorepo.tree("0.3.2.1") + tree = self.rorepo.tree("tree-layout") blob = tree / ".gitignore" assert isinstance(blob, Blob) - assert blob.hexsha == "787b3d442a113b78e343deb585ab5531eb7187fa" + assert blob.data_stream.read() == b"*.pyc\n" def test_repo_lookup_pathlike_path(self): - tree = self.rorepo.tree("0.3.2.1") + tree = self.rorepo.tree("tree-layout") blob = tree / PathLikeMock(".gitignore") assert isinstance(blob, Blob) - assert blob.hexsha == "787b3d442a113b78e343deb585ab5531eb7187fa" + assert blob.data_stream.read() == b"*.pyc\n" def test_repo_lookup_invalid_string_path(self): - tree = self.rorepo.tree("0.3.2.1") + tree = self.rorepo.tree("tree-layout") with pytest.raises(KeyError): tree / "doesnotexist" def test_repo_lookup_invalid_pathlike_path(self): - tree = self.rorepo.tree("0.3.2.1") + tree = self.rorepo.tree("tree-layout") with pytest.raises(KeyError): tree / PathLikeMock("doesnotexist") def test_repo_lookup_nested_string_path(self): - tree = self.rorepo.tree("0.3.2.1") + tree = self.rorepo.tree("tree-layout") blob = tree / "git/__init__.py" assert isinstance(blob, Blob) - assert blob.hexsha == "d87dcbdbb65d2782e14eea27e7f833a209c052f3" + assert blob.data_stream.read() == b"# Sample module\nvalue = 1\n" def test_repo_lookup_nested_pathlike_path(self): - tree = self.rorepo.tree("0.3.2.1") + tree = self.rorepo.tree("tree-layout") blob = tree / PathLikeMock("git/__init__.py") assert isinstance(blob, Blob) - assert blob.hexsha == "d87dcbdbb65d2782e14eea27e7f833a209c052f3" + assert blob.data_stream.read() == b"# Sample module\nvalue = 1\n" def test_repo_lookup_folder_string_path(self): - tree = self.rorepo.tree("0.3.2.1") + tree = self.rorepo.tree("tree-layout") tree = tree / "git" assert isinstance(tree, Tree) - assert tree.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" + assert [entry.name for entry in tree] == ["__init__.py", "objects"] def test_repo_lookup_folder_pathlike_path(self): - tree = self.rorepo.tree("0.3.2.1") + tree = self.rorepo.tree("tree-layout") tree = tree / PathLikeMock("git") assert isinstance(tree, Tree) - assert tree.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" + assert [entry.name for entry in tree] == ["__init__.py", "objects"] diff --git a/tests/fixtures/dependencies.sh b/tests/fixtures/dependencies.sh deleted file mode 100755 index 09177bf52..000000000 --- a/tests/fixtures/dependencies.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -eu - -source=$1 -# Include the source revision in the cache identity even though checkout uses history. -source_revision=$2 -name=$3 -case "$name" in - gitdb) revision=2da3232f9d58e7761e384ac6d32f7b1ed77a74a2 ;; - smmap) revision=8ce61ad5cc4016bffaf25080bc0d69b3acbe8555 ;; - *) printf 'Unknown dependency: %s\n' "$name" >&2; exit 1 ;; -esac - -git -C "$source" cat-file -e "$source_revision^{commit}" -git clone --shared --no-checkout "$source" repo -git -C repo checkout -B master "$revision" -if test "$name" = smmap; then - git -C repo tag -a -f v0.8.1 master~10 -m 'Test fixture tag' -fi -git -C repo gc diff --git a/tests/fixtures/history.sh b/tests/fixtures/history.sh index 68f983595..c1dc6ee7f 100755 --- a/tests/fixtures/history.sh +++ b/tests/fixtures/history.sh @@ -27,10 +27,4 @@ else git -C repo update-ref -m 'Create test branch' refs/heads/master "$revision" if test "$layout" != bare; then git -C repo checkout --force master; fi - if test "$layout" = baseline; then - test -n "$(git -C repo tag --list '[0-9]*' 'v[0-9]*')" - git -C repo tag -f __testing_point__ "$source_revision" - for _ in 1 2 3; do git -C repo reset --hard HEAD~1; done - git -C repo reset --hard "$source_revision" - fi fi diff --git a/tests/fixtures/sample-repositories.sh b/tests/fixtures/sample-repositories.sh new file mode 100644 index 000000000..c1f01b046 --- /dev/null +++ b/tests/fixtures/sample-repositories.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -eu + +export GIT_AUTHOR_NAME='Fixture Author' GIT_AUTHOR_EMAIL='author@example.com' +export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME" GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL" +export GIT_AUTHOR_DATE='2001-01-01T00:00:00 +0000' +export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE" + +# Small standalone clone sources, including a nested submodule and older releases. +for project in smmap gitdb; do + git init -q --object-format=sha1 --ref-format=files -b master "$project" + printf 'from setuptools import setup\nsetup()\n' > "$project/setup.py" + printf 'Sample dependency\n' > "$project/README.md" + printf 'Fixture license\n' > "$project/LICENSE" + mkdir -p "$project/smmap" "$project/doc" + printf '# Sample module\n' > "$project/smmap/__init__.py" + printf 'Sample documentation\n' > "$project/doc/readme" + if test "$project" = gitdb; then + rm gitdb/smmap/__init__.py + printf '[submodule "smmap"]\n\tpath = smmap\n\turl = ../smmap\n' > gitdb/.gitmodules + git -C gitdb update-index --add --cacheinfo "160000,$(git -C smmap rev-parse HEAD),smmap" + fi + for n in $(seq 1 30); do + printf 'revision %s\n' "$n" > "$project/file" + git -C "$project" add . + git -C "$project" commit -qm "Dependency revision $n" + if test "$n" = 20; then git -C "$project" tag -a v1.0 -m 'Sample release'; fi + done +done + +git init -q --object-format=sha1 --ref-format=files -b master repo +cd repo +printf 'Initial changes\n' > CHANGES +git add CHANGES +git commit -qm 'Initial commit' +git tag root + +mkdir -p lib/git git/objects doc test +printf 'Fixture Author\n' > AUTHORS +printf 'Sample repository\n' > README.md +printf '3.0.0\n' > VERSION +printf 'Fixture license\n' > LICENSE +printf 'Sample tests\n' > test/readme +printf '*.pyc\n' > .gitignore +printf 'include README.md\n' > MANIFEST.in +printf 'Sample documentation\n' > doc/readme.txt +printf 'from setuptools import setup\nsetup()\n' > setup.py +printf '# Sample module\nvalue = 1\n' > lib/git/__init__.py +printf '# Another module\n' > lib/git/blob.py +printf '# Sample module\nvalue = 1\n' > git/__init__.py +printf '# Sample object\nvalue = 1\n' > git/objects/blob.py +git add . +git commit -qm 'Add project files' +git tag -a annotated -m 'Annotated release' +git tag tree-layout + +for n in $(seq 1 5); do + printf 'Change %s\n' "$n" >> CHANGES + git commit -qam "Change $n" +done +git tag base +printf 'Release\n' >> CHANGES +git commit -qam 'Release sample project' +git tag release +printf 'include AUTHORS\n' >> MANIFEST.in +git commit -qam 'Update manifest' +git tag manifest-update + +# Two branches with two unique ancestors exercise breadth/depth traversal. +git checkout -qb side +printf 'Side contributor\n' >> AUTHORS +git commit -qam 'Add side contributor' +printf 'Side work\n' > side.txt +git add side.txt +git commit -qm 'Side work' +git checkout -q master +printf 'Main contributor\n' > main.txt +git add main.txt +git commit -qm 'Main work' +printf 'Main changes\n' >> CHANGES +git commit -qam 'More main work' +git merge -q --no-ff side -m 'Merge side work' +git tag merge + +# Independent conflicting changes for index and tree merge tests. +git checkout -qb conflict-ours base +printf 'ours\n' > lib/git/__init__.py +git commit -qam 'Our change' +git tag conflict-ours +git checkout -qb conflict-theirs base +printf 'theirs\n' > lib/git/__init__.py +git commit -qam 'Their change' +git tag conflict-theirs +git checkout -q master +git branch -D conflict-ours conflict-theirs + +git mv side.txt renamed.txt +git commit -qm 'Rename file' +git tag renamed +GIT_AUTHOR_DATE='2009-10-08T18:17:05 +0200' GIT_COMMITTER_DATE='2009-10-08T20:22:51 +0200' \ + git commit --allow-empty -qm 'Different author and committer dates' +git tag dated + +# Historical submodule states use generated dependency commits, never remote data. +mkdir -p git/ext/gitdb +printf '[submodule "old-name"]\n\tpath = git/ext/gitdb\n\turl = ../gitdb\n' > .gitmodules +git add .gitmodules +git update-index --add --cacheinfo "160000,$(git -C ../gitdb rev-parse HEAD~2),git/ext/gitdb" +git commit -qm 'Add submodule' +git update-index --cacheinfo "160000,$(git -C ../gitdb rev-parse HEAD~1),git/ext/gitdb" +git commit -qm 'Advance submodule' +git tag submodule-old +git config -f .gitmodules --rename-section submodule.old-name submodule.gitdb +git add .gitmodules +git update-index --cacheinfo "160000,$(git -C ../gitdb rev-parse HEAD),git/ext/gitdb" +git commit -qm 'Rename and advance submodule' +git tag submodule-current +git rm -q --cached git/ext/gitdb .gitmodules +rm .gitmodules +git commit -qm 'Remove submodule' + +for n in $(seq 1 30); do + printf 'Work %s\n' "$n" >> README.md + git commit -qam "Work $n" +done +cd .. +git clone -q --bare repo origin +git -C repo remote add origin ../origin +git -C repo fetch -q origin diff --git a/tests/fixtures/submodule-cases.sh b/tests/fixtures/submodule-cases.sh index c510c513c..873dafcfa 100644 --- a/tests/fixtures/submodule-cases.sh +++ b/tests/fixtures/submodule-cases.sh @@ -15,6 +15,13 @@ add_module() { git -C parent commit -qm 'Add submodule' } case "$scenario" in + remove-dirty-index|remove-dirty-worktree|remove-dirty-untracked) + add_module module module + path="file" + if test "$scenario" = remove-dirty-untracked; then path=untracked; fi + printf 'keep this data\n' > "parent/module/$path" + if test "$scenario" = remove-dirty-index; then git -C parent/module add file; fi + ;; deinit|deinit-latest|deinit-gitlink|deinit-nonempty|deinit-nested) git init -q -b master source printf '%s' 'submodule content' > source/file From f7633d80aad965161863fde61fe427d239a934cd Mon Sep 17 00:00:00 2001 From: Byron Date: Thu, 5 Nov 2026 09:41:27 +0100 Subject: [PATCH 3/4] test(gitdb): generate standalone packed and loose object fixtures GitDB tests previously opened the source checkout's `.git` directory or `GITDB_TEST_GIT_REPO_BASE`, making their input depend on clone history. Give each test class a disposable repository generated by a fixture script, with packed history and additional loose objects. Isolate Git configuration and identity during setup and register class cleanup. Run the script with Bash so the standalone suite retains Python 3.8 support without adding a GixPython dependency. Remove the history override and full-depth checkout from dependency CI, remove source `.git` before running the suite, and document the new setup. Use the actual database size when writing a pack instead of the unrelated iteration count, which the smaller fixture exposed. Assisted-by: GPT 6.0 Astra Co-authored-by: GPT 6.0 Astra --- .github/workflows/backend-benchmark.yml | 4 +-- .github/workflows/dependencies.yml | 4 +-- gitdb/README.rst | 3 ++ gitdb/gitdb/test/fixtures/repository.sh | 14 ++++++++++ gitdb/gitdb/test/lib.py | 28 +++++++++---------- .../test/performance/test_pack_streaming.py | 2 +- test/performance/README.md | 19 +++++++------ test/performance/bench_repository.py | 9 +++++- test/performance/cli-budget.json | 2 +- test/performance/compare_backends.py | 2 +- test/performance/test_benchmark_comparison.py | 12 +++++++- tests/fixtures/benchmark-comparison.sh | 4 ++- tests/fixtures/benchmark-source.sh | 8 ++++++ tests/fixtures/benchmark.sh | 20 +++++++++++-- 14 files changed, 96 insertions(+), 35 deletions(-) create mode 100644 gitdb/gitdb/test/fixtures/repository.sh create mode 100644 tests/fixtures/benchmark-source.sh diff --git a/.github/workflows/backend-benchmark.yml b/.github/workflows/backend-benchmark.yml index 49f0155a0..140aa27cb 100644 --- a/.github/workflows/backend-benchmark.yml +++ b/.github/workflows/backend-benchmark.yml @@ -16,7 +16,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: @@ -50,7 +49,8 @@ jobs: .bench-gix/bin/python -m pip install '.[gix]' -r test/performance/benchmark-requirements.txt - name: Prepare the shared read-only benchmark fixture run: | - .bench-cli/bin/python -c 'from pathlib import Path; from gix import testtools; print("BENCH_REPO=" + str((testtools.scripted_fixture_read_only_with_args("benchmark.sh", [str(Path.cwd())]) / "repo").resolve()))' >> "$GITHUB_ENV" + rm -rf .git + .bench-cli/bin/python -c 'from gix import testtools; print("BENCH_REPO=" + str((testtools.scripted_fixture_read_only_with_args("benchmark.sh", []) / "repo").resolve()))' >> "$GITHUB_ENV" mkdir benchmark-results - name: Measure GixPython operations, opening and discovery first env: diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 143a89018..b58704fe9 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -30,7 +30,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: @@ -45,8 +44,9 @@ jobs: run: flake8 "$PROJECT/$PROJECT" --count --select=E9,F63,F7,F82 --show-source --statistics - name: Test run: | + rm -rf .git if test "$PROJECT" = gitdb; then - GITDB_TEST_GIT_REPO_BASE="$(git rev-parse --git-common-dir)" pytest -o addopts= -v gitdb/gitdb/test + pytest -o addopts= -v gitdb/gitdb/test else pytest -o addopts= -v smmap/smmap/test fi diff --git a/gitdb/README.rst b/gitdb/README.rst index 45344a078..e1ecb1d22 100644 --- a/gitdb/README.rst +++ b/gitdb/README.rst @@ -42,6 +42,9 @@ The source is available in the GitPython repository on GitHub: https://github.com/gitpython-developers/GitPython/tree/main/gitdb +Tests require Git and Bash. Each test class gets a temporary repository generated +from scratch with packed and loose objects; no source checkout history is needed. + Run the tests with:: pytest diff --git a/gitdb/gitdb/test/fixtures/repository.sh b/gitdb/gitdb/test/fixtures/repository.sh new file mode 100644 index 000000000..b7af78229 --- /dev/null +++ b/gitdb/gitdb/test/fixtures/repository.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -eu +git init -q --object-format=sha1 -b main repo +cd repo +for n in $(seq 1 30); do + printf 'revision %s\n' "$n" >> file + git add file + git commit -qm "Revision $n" +done +git gc --prune=now +# Keep both packed and loose objects for the compound database and tutorial tests. +printf 'loose object\n' > loose +git add loose +git commit -qm 'Add loose objects' diff --git a/gitdb/gitdb/test/lib.py b/gitdb/gitdb/test/lib.py index 8e602342c..190487706 100644 --- a/gitdb/gitdb/test/lib.py +++ b/gitdb/gitdb/test/lib.py @@ -17,7 +17,7 @@ import shutil import os import gc -import logging +import subprocess from functools import wraps @@ -30,13 +30,9 @@ class TestBase(unittest.TestCase): * gitrepopath - * read-only base path of the git source repository, i.e. .../git/.git + * base path of a generated repository containing packed and loose objects """ - #{ Invvariants - k_env_git_repo = "GITDB_TEST_GIT_REPO_BASE" - #} END invariants - @classmethod def setUpClass(cls): try: @@ -44,14 +40,18 @@ def setUpClass(cls): except AttributeError: pass - cls.gitrepopath = os.environ.get(cls.k_env_git_repo) - if not cls.gitrepopath: - logging.info( - "You can set the %s environment variable to a .git repository of your choice - defaulting to the gitdb repository", cls.k_env_git_repo) - ospd = os.path.dirname - cls.gitrepopath = os.path.join(ospd(ospd(ospd(__file__))), '.git') - # end assure gitrepo is set - assert cls.gitrepopath.endswith('.git') + temporary = tempfile.TemporaryDirectory(prefix="gitdb-fixture-") + cls.addClassCleanup(temporary.cleanup) + env = {key: value for key, value in os.environ.items() if not key.startswith('GIT_')} + env.update( + GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=os.devnull, + GIT_AUTHOR_NAME='GitDB Tests', GIT_AUTHOR_EMAIL='tests@example.invalid', + GIT_COMMITTER_NAME='GitDB Tests', GIT_COMMITTER_EMAIL='tests@example.invalid', + GIT_AUTHOR_DATE='2001-01-01T00:00:00 +0000', GIT_COMMITTER_DATE='2001-01-01T00:00:00 +0000', + ) + # Keep the standalone suite usable on Python 3.8 without GixPython. + subprocess.run(['bash', fixture_path('repository.sh')], cwd=temporary.name, env=env, check=True) + cls.gitrepopath = os.path.join(temporary.name, 'repo', '.git') #} END bases diff --git a/gitdb/gitdb/test/performance/test_pack_streaming.py b/gitdb/gitdb/test/performance/test_pack_streaming.py index 80c798bbc..26c8ae6a0 100644 --- a/gitdb/gitdb/test/performance/test_pack_streaming.py +++ b/gitdb/gitdb/test/performance/test_pack_streaming.py @@ -52,7 +52,7 @@ def test_pack_writing(self): (ni, elapsed, ni / (elapsed or 1)), file=sys.stderr) st = time() - PackEntity.write_pack((pdb.stream(sha) for sha in pdb.sha_iter()), ostream.write, object_count=ni) + PackEntity.write_pack((pdb.stream(sha) for sha in pdb.sha_iter()), ostream.write, object_count=pdb.size()) elapsed = time() - st total_kb = ostream.bytes_written() / 1000 print(sys.stderr, "PDB Streaming: Wrote pack of size %i kb in %f s (%f kb/s)" % diff --git a/test/performance/README.md b/test/performance/README.md index 2fb13fd9c..68e852f5c 100644 --- a/test/performance/README.md +++ b/test/performance/README.md @@ -23,13 +23,13 @@ uv pip install --python .tox/gix/bin/python -r test/performance/benchmark-requir uv pip install --python .venv/bin/python -r test/performance/benchmark-requirements.txt ``` -Prepare the same fixed repository used by CI (GitPython 3.1.45) through -`gix.testtools`, outside the measured operations. Its Bash script lives in -`tests/fixtures/benchmark.sh`; no remote access is needed if that revision -already exists locally: +Prepare the same generated repository used by CI through `gix.testtools`, +outside the measured operations. `tests/fixtures/benchmark.sh` creates 30 +commits, an annotated tag, a nested directory, and a submodule from scratch. +It requires neither network access nor source Git metadata: ```sh -BENCH_REPO=$(.venv/bin/python -c 'from pathlib import Path; from gix import testtools; print((testtools.scripted_fixture_read_only_with_args("benchmark.sh", [str(Path.cwd())]) / "repo").resolve())') +BENCH_REPO=$(.venv/bin/python -c 'from gix import testtools; print((testtools.scripted_fixture_read_only_with_args("benchmark.sh", []) / "repo").resolve())') ``` Run from the source root, sequentially to avoid competing for CPU and disk: @@ -48,7 +48,7 @@ The default uses six worker processes, three measured values per process and one warmup, with calibrated loop counts and a minimum value duration of 100 ms. Pass standard `pyperf` options such as `--rigorous` for more samples or `--debug-single-value` for a smoke check. Raw JSON retains sample distributions, -environment details, fixture/source revisions, result digests, and one untimed +environment details, fixture revisions, source content digests, result digests, and one untimed invocation's native/fallback decisions. The comparison rejects differing results, revisions, Python/Git versions and storage formats. The harness also rejects the wrong backend rather than silently comparing CLI against itself. @@ -64,7 +64,7 @@ mutates that worktree, and ignores ambient Git environment/config overrides. The journey also enumerates submodules, resolves tree/index revisions with their paths and modes, and reads raw commit/tree bytes through the object database. The last operation covers serialization readback without rewriting -objects; the pinned fixture's signed commit remains intact. These three +objects. The generated fixture includes a submodule for the inventory check. These three measurements have zero-CLI ceilings and join the existing CI job automatically. Add a function returning JSON-compatible results to `MEASUREMENTS` in @@ -98,4 +98,7 @@ Timing is observational: shared CI runners and local activity introduce noise. The CI job publishes all means, standard deviations and ratios, retains raw artifacts, and uses `pyperf compare_to` for significance reporting. It fails on execution/parity errors, without a noisy speed threshold. Compare like-for-like -fixture and source revisions when tracking performance across backend changes. +fixture revisions and source content digests when tracking performance across backend changes. +The source digest covers `VERSION` and the Python package files, including local +edits. Measurements from the old GitPython-history fixture are not comparable +with this generated workload. diff --git a/test/performance/bench_repository.py b/test/performance/bench_repository.py index 5d90191e6..b60784500 100644 --- a/test/performance/bench_repository.py +++ b/test/performance/bench_repository.py @@ -124,6 +124,13 @@ def worker_arguments(cmd, args): cmd.extend(["--repo", str(args.repo), "--expect-backend", args.expect_backend]) +def source_digest(root): + digest = sha256() + for path in sorted([root / "VERSION", *(root / "git").rglob("*.py")]): + digest.update(path.relative_to(root).as_posix().encode() + b"\0" + path.read_bytes() + b"\0") + return digest.hexdigest() + + def main(): runner = pyperf.Runner( processes=6, @@ -156,7 +163,7 @@ def git(*arguments, cwd=args.repo): backend=_backend.name, gixpython=version("GixPython"), git_version=git("version"), - gitpython_revision=git("rev-parse", "HEAD", cwd=Path.cwd()), + gitpython_source=source_digest(Path(__file__).resolve().parents[2]), fixture_revision=git("rev-parse", "HEAD"), fixture_tree=git("rev-parse", "HEAD^{tree}"), fixture_status=git("status", "--porcelain") or "clean", diff --git a/test/performance/cli-budget.json b/test/performance/cli-budget.json index 987a249bb..0d0808157 100644 --- a/test/performance/cli-budget.json +++ b/test/performance/cli-budget.json @@ -1,5 +1,5 @@ { - "fixture_revision": "6ba2c0a2f9ee7feffd7e079621c4845820180c9a", + "fixture_revision": "bd49b71d9f760b7e1a0753c42a6c0ad0a6c48175", "max_gix_cli_processes": { "journey": 1, "inventory": 0, diff --git a/test/performance/compare_backends.py b/test/performance/compare_backends.py index 2c247ee43..cfc8b1fc0 100644 --- a/test/performance/compare_backends.py +++ b/test/performance/compare_backends.py @@ -19,7 +19,7 @@ def compare(cli_path, gix_path, cli_budget=None): if (lm["backend"], rm["backend"]) != ("cli", "gix"): raise ValueError("Expected CLI results followed by GixPython results") for key in ( - "gitpython_revision", + "gitpython_source", "fixture_revision", "fixture_tree", "fixture_status", diff --git a/test/performance/test_benchmark_comparison.py b/test/performance/test_benchmark_comparison.py index 4848a5344..2da4755b1 100644 --- a/test/performance/test_benchmark_comparison.py +++ b/test/performance/test_benchmark_comparison.py @@ -7,7 +7,9 @@ pytest.importorskip("pyperf") -@pytest.mark.parametrize("changed", [None, "result_digest", "fixture_revision", "backend", "cli_processes"]) +@pytest.mark.parametrize( + "changed", [None, "result_digest", "fixture_revision", "gitpython_source", "backend", "cli_processes"] +) def test_comparison_requires_parity(capsys, changed): from test.performance.compare_backends import compare @@ -20,3 +22,11 @@ def test_comparison_requires_parity(capsys, changed): compare(*paths, cli_budget=fixture / "budget.json") assert "journey | 150.00" in capsys.readouterr().out compare(*paths, cli_budget=fixture / "relaxed-budget.json") + + +def test_source_digest_detects_code_changes_without_git_metadata(): + from test.performance.bench_repository import source_digest + + fixture = read_only_fixture("benchmark-source.sh") + assert source_digest(fixture / "source") != source_digest(fixture / "copy") + assert source_digest(fixture / "source") == source_digest(fixture / "moved") diff --git a/tests/fixtures/benchmark-comparison.sh b/tests/fixtures/benchmark-comparison.sh index a555b81f1..33084b69b 100644 --- a/tests/fixtures/benchmark-comparison.sh +++ b/tests/fixtures/benchmark-comparison.sh @@ -3,12 +3,14 @@ set -eu for name in cli gix; do backend=$name fixture_revision=fixture + gitpython_source=source result_digest=equal cli_processes=0 if test "$name" = gix; then case "$1" in backend) backend=different ;; fixture_revision) fixture_revision=different ;; + gitpython_source) gitpython_source=different ;; result_digest) result_digest=different ;; cli_processes) cli_processes=1 ;; equal) ;; @@ -20,7 +22,7 @@ for name in cli gix; do "version": "1.0", "benchmarks": [{"runs": [{"values": [0.1, 0.2]}]}], "metadata": { - "gitpython_revision": "source", "fixture_revision": "$fixture_revision", + "gitpython_source": "$gitpython_source", "fixture_revision": "$fixture_revision", "fixture_tree": "tree", "fixture_status": "clean", "fixture_object_format": "sha1", "fixture_ref_format": "files", "git_version": "git version 2.52.0", "python_version": "3.12.14", "result_digest": "$result_digest", "backend_decisions": "[]", diff --git a/tests/fixtures/benchmark-source.sh b/tests/fixtures/benchmark-source.sh new file mode 100644 index 000000000..7ad9495ce --- /dev/null +++ b/tests/fixtures/benchmark-source.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -eu +mkdir -p source/git +printf '1.0\n' > source/VERSION +printf 'value = 1\n' > source/git/module.py +cp -R source copy +cp -R source moved +printf 'value = 2\n' > copy/git/module.py diff --git a/tests/fixtures/benchmark.sh b/tests/fixtures/benchmark.sh index 9ac4b0f90..ea1dee073 100644 --- a/tests/fixtures/benchmark.sh +++ b/tests/fixtures/benchmark.sh @@ -1,9 +1,23 @@ #!/usr/bin/env bash set -eu -git init --object-format=sha1 --ref-format=files repo -git -C repo fetch --no-tags "$1" 6ba2c0a2f9ee7feffd7e079621c4845820180c9a -git -C repo checkout -b benchmark FETCH_HEAD +export GIT_AUTHOR_NAME='Benchmark' GIT_AUTHOR_EMAIL='benchmark@example.invalid' +export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME" GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL" +export GIT_AUTHOR_DATE='2001-01-01T00:00:00 +0000' +export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE" +git init --object-format=sha1 --ref-format=files -b main dependency +git -C dependency commit --allow-empty -m 'Dependency' +git init --object-format=sha1 --ref-format=files -b benchmark repo +mkdir -p repo/git/objects repo/dependency +printf 'Sample objects\n' > repo/git/objects/readme +printf '[submodule "dependency"]\n\tpath = dependency\n\turl = ../dependency\n' > repo/.gitmodules +git -C repo update-index --add --cacheinfo "160000,$(git -C dependency rev-parse HEAD),dependency" +for n in $(seq 1 30); do + printf 'Revision %s\n' "$n" >> repo/README.md + git -C repo add . + git -C repo commit -m "Revision $n" +done +git -C repo tag -a v1.0 HEAD~10 -m 'Sample release' git -C repo config core.excludesFile /dev/null git -C repo config core.attributesFile /dev/null git -C repo config core.fsmonitor false From 7e8aa8c017c7d8ec27dd4d5a39a18bc9500834d1 Mon Sep 17 00:00:00 2001 From: Byron Date: Thu, 5 Nov 2026 10:03:29 +0100 Subject: [PATCH 4/4] fix(benchmarks): address review feedback on workload comparisons and counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address benchmark review feedback on the generated fixtures. Copilot noted that "performance tests still report their iteration limits of 1,000–10,000 as processed counts" even though the fixture has only 90 packed objects. Use the existing streaming counters and clamp list-based limits to the available SHA count so both totals and throughput reflect completed work. Copilot also requested: "Include the benchmark harness sources in the fingerprint, and add a harness-only change to the digest regression test." Hash the Python sources in `test/performance` alongside `VERSION` and `git`. This prevents comparing different timed workloads that return the same result. Extend the generated source fixture and document the digest scope. Assisted-by: GPT 6.0 Astra Co-authored-by: GPT 6.0 Astra --- gitdb/gitdb/test/performance/test_pack.py | 4 ++-- gitdb/gitdb/test/performance/test_pack_streaming.py | 4 ++-- test/performance/README.md | 5 +++-- test/performance/bench_repository.py | 3 ++- test/performance/test_benchmark_comparison.py | 1 + tests/fixtures/benchmark-source.sh | 5 ++++- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/gitdb/gitdb/test/performance/test_pack.py b/gitdb/gitdb/test/performance/test_pack.py index fc3d3349f..8e8796361 100644 --- a/gitdb/gitdb/test/performance/test_pack.py +++ b/gitdb/gitdb/test/performance/test_pack.py @@ -51,7 +51,7 @@ def test_pack_random_access(self): # END for each random mode # query info and streams only - max_items = 10000 # can wait longer when testing memory + max_items = min(10000, ns) # can wait longer when testing memory for pdb_fun in (pdb.info, pdb.stream): st = time() for sha in sha_list[:max_items]: @@ -62,7 +62,7 @@ def test_pack_random_access(self): # END for each function # retrieve stream and read all - max_items = 5000 + max_items = min(5000, ns) pdb_stream = pdb.stream total_size = 0 st = time() diff --git a/gitdb/gitdb/test/performance/test_pack_streaming.py b/gitdb/gitdb/test/performance/test_pack_streaming.py index 26c8ae6a0..c4e89b5ae 100644 --- a/gitdb/gitdb/test/performance/test_pack_streaming.py +++ b/gitdb/gitdb/test/performance/test_pack_streaming.py @@ -49,7 +49,7 @@ def test_pack_writing(self): # END gather objects for pack-writing elapsed = time() - st print("PDB Streaming: Got %i streams by sha in in %f s ( %f streams/s )" % - (ni, elapsed, ni / (elapsed or 1)), file=sys.stderr) + (count, elapsed, count / (elapsed or 1)), file=sys.stderr) st = time() PackEntity.write_pack((pdb.stream(sha) for sha in pdb.sha_iter()), ostream.write, object_count=pdb.size()) @@ -78,4 +78,4 @@ def test_stream_reading(self): elapsed = time() - st total_kib = total_size / 1000 print(sys.stderr, "PDB Streaming: Got %i streams by sha and read all bytes totallying %i KiB ( %f KiB / s ) in %f s ( %f streams/s )" % - (ni, total_kib, total_kib / (elapsed or 1), elapsed, ni / (elapsed or 1)), sys.stderr) + (count, total_kib, total_kib / (elapsed or 1), elapsed, count / (elapsed or 1)), sys.stderr) diff --git a/test/performance/README.md b/test/performance/README.md index 68e852f5c..24878db2b 100644 --- a/test/performance/README.md +++ b/test/performance/README.md @@ -99,6 +99,7 @@ The CI job publishes all means, standard deviations and ratios, retains raw artifacts, and uses `pyperf compare_to` for significance reporting. It fails on execution/parity errors, without a noisy speed threshold. Compare like-for-like fixture revisions and source content digests when tracking performance across backend changes. -The source digest covers `VERSION` and the Python package files, including local -edits. Measurements from the old GitPython-history fixture are not comparable +The source digest covers `VERSION`, the Python package files, and the benchmark +harness sources in `test/performance`, including local edits. +Measurements from the old GitPython-history fixture are not comparable with this generated workload. diff --git a/test/performance/bench_repository.py b/test/performance/bench_repository.py index b60784500..cc52b4612 100644 --- a/test/performance/bench_repository.py +++ b/test/performance/bench_repository.py @@ -126,7 +126,8 @@ def worker_arguments(cmd, args): def source_digest(root): digest = sha256() - for path in sorted([root / "VERSION", *(root / "git").rglob("*.py")]): + sources = [root / "VERSION", *(root / "git").rglob("*.py"), *(root / "test" / "performance").glob("*.py")] + for path in sorted(sources): digest.update(path.relative_to(root).as_posix().encode() + b"\0" + path.read_bytes() + b"\0") return digest.hexdigest() diff --git a/test/performance/test_benchmark_comparison.py b/test/performance/test_benchmark_comparison.py index 2da4755b1..d8a867fdc 100644 --- a/test/performance/test_benchmark_comparison.py +++ b/test/performance/test_benchmark_comparison.py @@ -29,4 +29,5 @@ def test_source_digest_detects_code_changes_without_git_metadata(): fixture = read_only_fixture("benchmark-source.sh") assert source_digest(fixture / "source") != source_digest(fixture / "copy") + assert source_digest(fixture / "source") != source_digest(fixture / "harness") assert source_digest(fixture / "source") == source_digest(fixture / "moved") diff --git a/tests/fixtures/benchmark-source.sh b/tests/fixtures/benchmark-source.sh index 7ad9495ce..4976e6f0a 100644 --- a/tests/fixtures/benchmark-source.sh +++ b/tests/fixtures/benchmark-source.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash set -eu -mkdir -p source/git +mkdir -p source/git source/test/performance printf '1.0\n' > source/VERSION printf 'value = 1\n' > source/git/module.py +printf 'value = 1\n' > source/test/performance/bench_repository.py cp -R source copy cp -R source moved +cp -R source harness printf 'value = 2\n' > copy/git/module.py +printf 'value = 2\n' > harness/test/performance/bench_repository.py