FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

GitHub Viewer

# This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ import gc import os import os.path as osp from pathlib import Path import shutil import sys from types import SimpleNamespace from unittest import mock, skipUnless import pytest import git from git.cmd import Git from git.config import GitConfigParser, cp from git.exc import ( GitCommandError, InvalidGitRepositoryError, RepositoryDirtyError, UnsafeOptionError, UnsafeProtocolError, ) from git.objects.submodule.base import Submodule from git.objects.submodule.root import RootModule, RootUpdateProgress from git.objects.submodule.util import resolve_relative_url from git.repo.fun import find_submodule_git_dir, touch from git.util import cwd, join_path_native, to_native_path_linux from test.lib import TestBase, patch_git_config, with_rw_repo, PathLikeMock from test.lib.fixtures import fixture_repo, read_only_fixture, writable_fixture @pytest.mark.parametrize("state", ["unborn", "untracked", "detached"]) @pytest.mark.parametrize("remote", [None, ".", "origin"], ids=["working-tree", "local-origin", "origin"]) @patch_git_config("protocol.file.allow", "always") def test_add_relative_submodule_without_tracking_remote(state, remote): # Origins and submodule URLs refer to this private fixture's absolute paths. with writable_fixture("submodule-relative.sh", "add", state, remote, execute=True) as fixture: root = fixture.path().resolve() with git.Repo(root / "parent") as parent, git.Repo(root / (root / "source-path").read_text()) as source: submodule = parent.create_submodule("module", "module", "./source") assert submodule.url == "./source" with submodule.module() as module: assert module.head.commit == source.head.commit assert Path(module.remotes.origin.url).samefile(source.working_tree_dir) parent.index.commit("Add submodule") submodule.remove(configuration=False) submodule.update(init=True) with submodule.module() as module: assert module.head.commit == source.head.commit assert Path(module.remotes.origin.url).samefile(source.working_tree_dir) @pytest.mark.parametrize("url", ["./source", "../source", "./../source"]) def test_add_preserves_relative_url_on_cygwin(url): with writable_fixture("submodule-relative.sh", "cygwin") as fixture: root = fixture.path().resolve() with git.Repo(root / "parent") as parent, git.Repo(root / "source") as source: with mock.patch.object(Submodule, "_clone_repo", return_value=source): with mock.patch.object(Git, "is_cygwin", return_value=True): submodule = parent.create_submodule("module", "module", url, no_checkout=True) assert submodule.url == url with submodule.config_reader() as reader: assert reader.get_value("url") == url with parent.config_reader() as reader: assert reader.get_value('submodule "module"', "url") == url @patch_git_config("protocol.file.allow", "always") def test_update_resolves_local_relative_remote_once(): with writable_fixture("submodule-relative.sh", "local-remote") as fixture: root = fixture.path().resolve() with git.Repo(root / "parent") as parent, git.Repo(root / "repos" / "child.git") as source: submodule = Submodule( parent, source.head.commit.binsha, name="module", path="module", url="../child.git", branch_path="refs/heads/master", ) submodule.update(init=True) with submodule.module() as module: assert module.head.commit == source.head.commit assert Path(module.remotes.origin.url).samefile(source.working_tree_dir) module.remotes.origin.fetch() @pytest.mark.parametrize("no_fetch", [False, True]) @pytest.mark.parametrize("scenario", ["root-anchor", "root-single"]) @patch_git_config("protocol.file.allow", "always") def test_root_update_resolves_relative_remote_url(scenario, no_fetch): # Module origins and .gitmodules URLs contain this private fixture's absolute paths. with writable_fixture("submodule-relative.sh", scenario, execute=True) as fixture: root = fixture.path().resolve() with git.Repo(root / "parent") as parent, git.Repo( root / (root / "replacement-path").read_text() ) as replacement: previous = (root / "previous").read_text().strip() RootModule(parent).update(previous_commit=previous, no_fetch=no_fetch) with parent.submodules[0].module() as module: assert Path(module.remotes.origin.url).samefile(replacement.working_tree_dir) assert module.head.commit == replacement.head.commit module.remotes.origin.fetch() @pytest.mark.parametrize("dry_run", [False, True]) def test_root_update_skips_old_url_resolution_when_remote_matches(dry_run): # The matching remote and .gitmodules URL refer to this private fixture's source. with writable_fixture("submodule-relative.sh", "root-matches", execute=True) as fixture: root = fixture.path().resolve() with git.Repo(root / "parent") as parent, git.Repo(root / "source") as source: previous = (root / "previous").read_text().strip() resolve_url = Submodule._resolved_url with parent.submodules[0].module() as module: original_url = module.remotes.origin.url assert Path(original_url).samefile(source.working_tree_dir) def resolve_current_url(sm): assert sm.url != "../source", "Previous URL does not need resolving" return resolve_url(sm) with mock.patch.object(Submodule, "_resolved_url", autospec=True, side_effect=resolve_current_url): RootModule(parent).update(previous_commit=previous, no_fetch=True, dry_run=dry_run) with parent.submodules[0].module() as module: assert module.remotes.origin.url == original_url assert Path(module.remotes.origin.url).samefile(source.working_tree_dir) assert module.head.commit == source.head.commit @patch_git_config("protocol.file.allow", "user") def test_update_applies_git_transport_policy_to_gitmodules_url(monkeypatch): monkeypatch.setenv("GIT_PROTOCOL_FROM_USER", "1") # The cloned origins and .gitmodules URLs point to this private source. with writable_fixture("submodule-transport.sh", "update", execute=True) as fixture: root = fixture.path().resolve() with git.Repo(root / "source") as source, git.Repo(root / "clone") as clone, git.Repo( root / "baseline" ) as baseline: submodule = clone.submodules[0] with pytest.raises(GitCommandError, match="transport 'file' not allowed"): baseline.git.submodule("update", "--init") assert not Path(baseline.working_tree_dir, "module", ".git").exists() with pytest.raises(GitCommandError, match="transport 'file' not allowed"): submodule.update(init=True) assert not submodule.module_exists() with patch_git_config("protocol.file.allow", "always"): baseline.git.submodule("update", "--init") submodule.update(init=True) with submodule.module() as module, git.Repo(root / "baseline" / "module") as baseline_module: assert module.head.commit.hexsha == baseline_module.git.rev_parse("HEAD") == source.head.commit.hexsha source.index.commit("Second commit", skip_hooks=True) with pytest.raises(GitCommandError, match="transport 'file' not allowed"): baseline.git.submodule("update", "--remote") with pytest.raises(GitCommandError, match="transport 'file' not allowed"): submodule.update(to_latest_revision=True) assert module.head.commit.hexsha == baseline_module.git.rev_parse("HEAD") != source.head.commit.hexsha with patch_git_config("protocol.file.allow", "always"): baseline.git.submodule("update", "--remote") submodule.update(to_latest_revision=True, allow_unsafe_protocols=True) assert module.head.commit.hexsha == baseline_module.git.rev_parse("HEAD") == source.head.commit.hexsha @patch_git_config("protocol.file.allow", "user") def test_root_update_applies_git_transport_policy_to_changed_url(monkeypatch): monkeypatch.setenv("GIT_PROTOCOL_FROM_USER", "1") # The submodule URLs point to this private source and replacement. with writable_fixture("submodule-transport.sh", "changed-url", execute=True) as fixture: root = fixture.path().resolve() with git.Repo(root / "source") as source, git.Repo(root / "parent") as parent, git.Repo( root / "baseline" ) as baseline, git.Repo(root / "baseline" / "module") as baseline_module: submodule = parent.submodules[0] previous = (root / "previous").read_text().strip() # A rejected fetch preserves the original remote and checked-out commit. with baseline_module.git.custom_environment(GIT_PROTOCOL_FROM_USER="0"): with pytest.raises(GitCommandError, match="transport 'file' not allowed"): baseline_module.git.fetch(str(root / "replacement")) assert Path(baseline_module.remotes.origin.url).samefile(source.working_tree_dir) assert baseline_module.git.rev_parse("HEAD") == source.head.commit.hexsha baseline.git.submodule("sync") with pytest.raises(GitCommandError, match="transport 'file' not allowed"): baseline.git.submodule("update", "--remote") with pytest.raises(GitCommandError, match="transport 'file' not allowed"): RootModule(parent).update(previous_commit=previous) with submodule.module() as module: assert Path(module.remotes.origin.url).samefile(source.working_tree_dir) assert module.head.commit.hexsha == baseline_module.git.rev_parse("HEAD") == source.head.commit.hexsha @pytest.mark.parametrize( "scenario,name", [ ("plain", "module"), ("dollar", "$GITPYTHON_TEST_SECRET"), ("braced-dollar", "prefix-${GITPYTHON_TEST_SECRET}-suffix"), ("percent", "%GITPYTHON_TEST_SECRET%"), ], ) @patch_git_config("protocol.file.allow", "always") def test_submodule_update_preserves_literal_name(monkeypatch, caplog, scenario, name): monkeypatch.setenv("GITPYTHON_TEST_SECRET", "sensitive-value") caplog.set_level("DEBUG", logger="git.cmd") # Cloned origins and submodule URLs refer to this private fixture's absolute paths. with writable_fixture("submodule-special.sh", "literal-name", scenario, execute=True) as fixture: with git.Repo(fixture.path().resolve() / "source") as source, git.Repo( fixture.path().resolve() / "clone" ) as clone: assert clone.git.config("--file", ".gitmodules", f"submodule.{name}.path") == "module" clone.submodule_update(init=True, recursive=True) modules_dir = Path(clone.git_dir, "modules") assert {path.name for path in modules_dir.iterdir()} == {name} # Exercise relative gitfile conversion on every platform. with mock.patch.object(Git, "is_cygwin", return_value=True): resolved_git_dir = find_submodule_git_dir(Path(clone.working_tree_dir, "module", ".git")) assert resolved_git_dir is not None assert (modules_dir / name).samefile(resolved_git_dir) with clone.submodules[0].module() as module: assert (modules_dir / name).samefile(module.git_dir) assert module.head.commit == source.head.commit assert "sensitive-value" not in caplog.text def test_reconnect_preserves_existing_gitfile(movable_submodule, monkeypatch): """Reconnecting must not rename already-connected metadata onto itself.""" sm = movable_submodule metadata = str(Path(sm.repo.git_dir, "modules", sm.name)) commit = sm.hexsha moved = Path(sm.abspath).with_name("moved") original = (moved / ".git").read_bytes() execute = Git.execute def reject_reinitialization(self, command, *args, **kwargs): if "init" in command: # Git for Windows can reject renaming a nonempty directory onto # itself, even when both paths name the same existing repository. raise GitCommandError(command, 128, "unable to move metadata to itself: Directory not empty") return execute(self, command, *args, **kwargs) monkeypatch.setattr(Git, "execute", reject_reinitialization) Submodule._connect_module(moved, metadata) assert (moved / ".git").read_bytes() == original with git.Repo(moved) as reopened: assert Path(reopened.git_dir).samefile(metadata) assert Path(reopened.working_tree_dir).samefile(moved) assert reopened.head.commit.hexsha == commit assert (moved / "file").read_text() == "content" def test_reconnect_validates_connected_metadata(movable_submodule): sm = movable_submodule metadata = Path(sm.repo.git_dir, "modules", sm.name) config = metadata / "config" before = config.read_bytes() with pytest.raises(GitCommandError): Submodule._connect_module(sm.abspath, str(metadata)) assert config.read_bytes() == before @pytest.mark.parametrize("object_format", ["sha1", "sha256"]) @pytest.mark.parametrize("ref_format", ["files", "reftable"]) def test_submodule_cli_lifecycle(monkeypatch, object_format, ref_format): """Git owns submodule storage throughout add, move, reconnect, and removal.""" monkeypatch.setenv("GIT_AUTHOR_NAME", "Test") monkeypatch.setenv("GIT_AUTHOR_EMAIL", "test@example.com") monkeypatch.setenv("GIT_COMMITTER_NAME", "Test") monkeypatch.setenv("GIT_COMMITTER_EMAIL", "test@example.com") with writable_fixture("submodule-special.sh", "lifecycle", object_format, ref_format) as fixture: with git.Repo(fixture.path().resolve() / "source") as source, git.Repo( fixture.path().resolve() / "parent" ) as parent: commit = source.head.commit module = parent.create_submodule("logical-name", "module", source.working_tree_dir) with module.module() as child: if child.git.rev_parse("--show-ref-format") != ref_format: child.git.refs("migrate", "--ref-format=" + ref_format) parent.index.commit("Add submodule", skip_hooks=True) module.move("moved") parent.index.commit("Move submodule", skip_hooks=True) assert module.name == "logical-name" module.deinit(force=True) module.update(init=True, no_fetch=True) with module.module() as child: assert child.head.commit == commit assert not child.is_dirty(untracked_files=True) metadata = Path(child.git_dir) module.remove(force=True) assert not Path(parent.working_tree_dir, "moved").exists() assert metadata.is_dir() assert not parent.submodules @pytest.mark.skipif(sys.platform != "win32", reason="Windows locks files against directory renames") @pytest.mark.parametrize("use_short_path", [False, True]) def test_submodule_reconnect_with_open_metadata_file(use_short_path): # The gitfile must point to this fixture's final metadata directory. with writable_fixture("submodule-special.sh", "open-metadata", execute=True) as fixture: root = fixture.path().resolve() if use_short_path: import ctypes buffer = ctypes.create_unicode_buffer(32768) if not ctypes.windll.kernel32.GetShortPathNameW(str(root), buffer, len(buffer)): raise ctypes.WinError() if buffer.value == str(root): pytest.skip("This filesystem does not provide Windows 8.3 path aliases") root = Path(buffer.value) assert root.samefile(fixture.path()) checkout = root / "checkout" metadata = fixture.path().resolve() / "metadata" with git.Repo(checkout) as repo: # Like a retained pack mapping, this open file prevents renaming metadata. object_file = metadata / "objects" / "held-open" with object_file.open("rb"): Submodule._connect_module(checkout, metadata) assert Path(repo.git.rev_parse("--absolute-git-dir")).samefile(metadata) with git.Repo(checkout) as reopened: assert Path(reopened.working_tree_dir).samefile(checkout) assert Path(reopened.git_dir).samefile(metadata) @pytest.fixture(scope="module") def submodule_source(): return read_only_fixture("submodule-source.sh") / "source" @pytest.fixture(scope="module") def movable_submodule_baseline(submodule_source): def prepare(name): return read_only_fixture("submodule-layout.sh", str(submodule_source), name) / "parent" return prepare @pytest.fixture def movable_submodule(request, submodule_source): arguments = [str(submodule_source), getattr(request, "param", "logical-name")] if request.node.originalname == "test_reconnect_preserves_existing_gitfile": arguments.extend(["reconnect-moved"]) elif request.node.originalname == "test_reconnect_validates_connected_metadata": arguments.extend(["unknown-extension"]) elif request.node.originalname == "test_update_closes_checkout_processes_retained_by_logging": arguments.extend(["logging", request.node.callspec.params["operation"]]) with fixture_repo( "submodule-layout.sh", *arguments, write=True, subdir="parent", ) as parent: yield parent.submodules[0] @pytest.fixture def configured_submodule(request, submodule_source): params = getattr(request.node, "callspec", None) params = {} if params is None else params.params # These scenarios intentionally point symlinks at fixture-local absolute paths. with fixture_repo( "submodule-symlinks.sh", str(submodule_source), getattr(request, "param", "logical-name"), request.node.originalname, params.get("kind", params.get("link_kind", "none")), str(params.get("dangling", False)), params.get("operation", "none"), str(params.get("relative_target", False)), write=True, execute=True, subdir="parent", ) as parent: if params.get("link_kind") == "gitmodules": with git.Repo(submodule_source) as source: yield Submodule( parent, source.head.commit.binsha, name="logical-name", path="module", url=str(submodule_source) ) else: yield parent.submodules[0] def test_movable_submodule_copies_are_isolated(movable_submodule, movable_submodule_baseline): sm = movable_submodule baseline = movable_submodule_baseline(sm.name) for relative in (".git/index", ".git/config", ".git/modules/logical-name/config", "module/file"): copy = Path(sm.repo.working_tree_dir, relative) original = baseline / relative assert copy.read_bytes() == original.read_bytes() assert not copy.samefile(original) Path(sm.abspath, "file").write_text("changed", encoding="utf-8") sm.repo.create_head("copy-only") assert (baseline / "module/file").read_text(encoding="utf-8") == "content" with git.Repo(baseline) as original: assert "copy-only" not in original.heads @pytest.fixture def nested_metadata_submodule(submodule_source): with fixture_repo( "submodule-layout.sh", str(submodule_source), "logical-name", "nested", write=True, subdir="parent" ) as parent: yield parent.submodules[0], parent.submodules[1] @pytest.fixture def existing_nested_metadata(submodule_source, state): # The separate Git directory is recorded as an absolute path in the checkout. with fixture_repo( "submodule-layout.sh", str(submodule_source), "logical-name", "existing-nested", state, write=True, execute=True, subdir="parent", ) as parent: yield parent.submodules[0] @pytest.fixture def separate_metadata_submodule(submodule_source): # The separate Git directory is recorded as an absolute path in the checkout. with fixture_repo( "submodule-layout.sh", str(submodule_source), "logical-name", "separate", write=True, execute=True, subdir="separate", ) as parent: yield parent.submodules[0] @pytest.fixture def intermediate_symlink_submodule(submodule_source, target_kind): with fixture_repo( "submodule-layout.sh", str(submodule_source), "logical-name", "intermediate-link", target_kind, write=True, execute=True, subdir="parent", ) as parent: root = Path(parent.working_tree_dir).parent target = root / "parent/target" if target_kind == "internal" else root / "outside" yield parent.submodules[0], target @pytest.fixture def leaf_metadata_submodule(submodule_source, metadata_dir): # Linked worktree metadata and its symlink contain fixture-local absolute paths. with fixture_repo( "submodule-layout.sh", str(submodule_source), "logical-name", "leaf-metadata", metadata_dir, write=True, execute=True, subdir="worktree", ) as parent: yield parent.submodules[0] @pytest.fixture def retained_metadata_submodule(submodule_source): with fixture_repo( "submodule-layout.sh", str(submodule_source), "logical-name", "retained", write=True, subdir="parent" ) as parent: sm = parent.head.commit.tree["module"] yield Submodule(parent, sm.binsha, name="logical-name", path="module", url=str(submodule_source)) def _move_snapshot(submodule): """Capture index, configuration, and path state to detect side effects of rejected moves.""" parent = submodule.repo with submodule.module() as module: config = Path(module.git_dir, "config").read_bytes() return ( Path(parent.index.path).read_bytes(), Path(parent.working_tree_dir, ".gitmodules").read_bytes(), Path(submodule.abspath, ".git").read_bytes(), config, submodule.path, ) @pytest.mark.parametrize( "path", [ ".git/child", ".GiT/child", "nested/.git/child", "git~1/child", "GIT~1 . /child", ".git. /child", ".git:stream/child", ".git::$INDEX_ALLOCATION/child", "nested\\.git\\child", "C:relative", "nul\0name", ] + [ f".g{chr(codepoint)}it/child" for codepoint in (*range(0x200C, 0x2010), *range(0x202A, 0x202F), *range(0x206A, 0x2070), 0xFEFF) ], ) @pytest.mark.parametrize("operation", ["add", "clone", "update", "move", "move-module", "move-config"]) def test_submodule_rejects_unsafe_checkout_before_mutation(movable_submodule, path, operation): sm = movable_submodule root = Path(sm.repo.working_tree_dir) before = _move_snapshot(sm) paths = set(root.rglob("*")) # Check the portable metadata aliases against Git's index validation as well. if operation == "clone" and path not in ("C:relative", "nul\0name"): with sm.repo.git.custom_environment(GIT_INDEX_FILE=str(root.parent / "validation-index")): with patch_git_config("core.protectHFS", "true"), patch_git_config("core.protectNTFS", "true"): with pytest.raises(GitCommandError): sm.repo.git.update_index("--add", "--cacheinfo", f"160000,{sm.hexsha},{path}") with mock.patch.object(git.Repo, "clone_from", side_effect=AssertionError("clone attempted")): with pytest.raises(ValueError): if operation == "add": sm.repo.create_submodule("new", path, sm.url) elif operation == "clone": Submodule._clone_repo(sm.repo, sm.url, path, "new") elif operation == "update": Submodule(sm.repo, sm.binsha, name="new", path=path, url=sm.url).update(init=True) else: sm.move(path, configuration=operation != "move-module", module=operation != "move-config") assert _move_snapshot(sm) == before assert set(root.rglob("*")) == paths def test_add_rejects_metadata_checkout_without_writing_files(movable_submodule): sm = movable_submodule before = _move_snapshot(sm) with pytest.raises(ValueError, match="Git metadata"): sm.repo.create_submodule("new", ".git/new", sm.url) assert not Path(sm.repo.git_dir, "new").exists() assert not Path(sm.repo.git_dir, "modules/new").exists() assert _move_snapshot(sm) == before @pytest.mark.parametrize("absolute_path", [False, True]) @pytest.mark.parametrize("metadata_name", ["metadata", "MeTaDaTa"]) @pytest.mark.parametrize("operation", ["add", "clone", "update", "move"]) def test_submodule_rejects_checkout_in_separate_metadata( separate_metadata_submodule, absolute_path, metadata_name, operation ): sm = separate_metadata_submodule parent = sm.repo root = Path(parent.working_tree_dir) if not (root / metadata_name).is_dir(): pytest.skip("Requires a case-insensitive filesystem") before = _move_snapshot(sm) paths = set(root.rglob("*")) path = root / metadata_name / "new" if absolute_path else f"{metadata_name}/new" with pytest.raises(ValueError, match="Git metadata"): if operation == "add": parent.create_submodule("new", path, sm.url) elif operation == "clone": Submodule._clone_repo(parent, sm.url, path, "new") elif operation == "update": Submodule(parent, sm.binsha, name="new", path=path, url=sm.url).update(init=True) else: sm.move(path) assert _move_snapshot(sm) == before assert set(root.rglob("*")) == paths @pytest.mark.parametrize("operation", ["add", "clone", "update"]) def test_submodule_rejects_nested_metadata_before_mutation(nested_metadata_submodule, operation): sm, other = nested_metadata_submodule root = Path(sm.repo.working_tree_dir) before = _move_snapshot(sm), _move_snapshot(other) paths = set(root.rglob("*")) name = f"{sm.name}/child" with pytest.raises(ValueError, match="inside.*Git directory"): if operation == "add": sm.repo.create_submodule(name, "new", sm.url) elif operation == "clone": Submodule._clone_repo(sm.repo, sm.url, "new", name) elif operation == "update": Submodule(sm.repo, sm.binsha, name=name, path="new", url=sm.url).update(init=True) assert (_move_snapshot(sm), _move_snapshot(other)) == before assert set(root.rglob("*")) == paths @pytest.mark.parametrize("state", ["retained", "checked-out"]) def test_update_rejects_existing_nested_metadata(existing_nested_metadata, state): sm = existing_nested_metadata name = f"{sm.name}/child" before = _move_snapshot(sm) paths = set(Path(sm.repo.working_tree_dir).rglob("*")) with pytest.raises(ValueError, match="inside.*Git directory"): Submodule(sm.repo, sm.binsha, name=name, path="new", url=sm.url).update(init=True, no_fetch=True) assert _move_snapshot(sm) == before assert set(Path(sm.repo.working_tree_dir).rglob("*")) == paths @pytest.mark.parametrize( "path", [ "CON", "con.txt", "CONIN$", "conout$.txt", "AUX .txt", "PRN", "NUL", "COM9", "LPT1", "name:stream", "space ", "period.", "line\nbreak", "star*", 'quote"', "question?", "angle", "pipe|", ], ) def test_submodule_rejects_windows_destination_names_before_mutation(movable_submodule, path): sm = movable_submodule before = _move_snapshot(sm) paths = set(Path(sm.repo.working_tree_dir).rglob("*")) with mock.patch("git.objects.submodule.base.sys", SimpleNamespace(platform="win32")): with mock.patch.object(git.Repo, "clone_from", side_effect=AssertionError("clone attempted")): with pytest.raises(ValueError): sm.repo.create_submodule("new", f"nested/{path}", sm.url) with pytest.raises(ValueError): sm.repo.create_submodule(f"nested/{path}", "new", sm.url) assert _move_snapshot(sm) == before assert set(Path(sm.repo.working_tree_dir).rglob("*")) == paths @pytest.mark.parametrize("path", ["nested/space ", "nested/period."]) def test_windows_destination_validation_precedes_normalization(path): parent = SimpleNamespace(working_tree_dir=str(read_only_fixture("empty.sh"))) # Windows' GetFullPathName removes trailing spaces and periods. with mock.patch("git.objects.submodule.base._to_relative_path", return_value=path.rstrip(" .")): with mock.patch("git.objects.submodule.base.sys", SimpleNamespace(platform="win32")): with pytest.raises(ValueError, match="Invalid submodule path on Windows"): Submodule._to_relative_path(parent, path) def test_submodule_move_keeps_its_own_metadata(movable_submodule): sm = movable_submodule name = sm.name sm.move(f"{name}/child") assert sm.name == name assert Path(sm.abspath, "file").read_text() == "content" with sm.module() as module: assert Path(module.git_dir) == Path(sm.repo.git_dir, "modules", sm.name) def test_clone_disables_metadata_that_becomes_nested(movable_submodule, monkeypatch): sm = movable_submodule clone_from = git.Repo.clone_from ancestor = Path(sm.repo.git_dir, "modules/new") prepared = read_only_fixture("empty.sh", "sha1", "files", "master", "true") / "repo" def clone_and_create_ancestor(*args, **kwargs): clone = clone_from(*args, **kwargs) shutil.copytree(prepared, ancestor, dirs_exist_ok=True) return clone monkeypatch.setattr(git.Repo, "clone_from", clone_and_create_ancestor) with pytest.raises(ValueError, match="inside.*Git directory"): Submodule._clone_repo(sm.repo, sm.url, "new", "new/child") assert not (ancestor / "child/HEAD").exists() assert (ancestor / "HEAD").is_file() @pytest.mark.parametrize("ref_format", ["files", "reftable"]) def test_readd_retained_submodule_without_checkout(submodule_source, ref_format): with fixture_repo( "submodule-layout.sh", str(submodule_source), "logical-name", "readd-retained", ref_format, write=True, subdir="parent", ) as parent: root = Path(parent.working_tree_dir).parent before = (root / "retained-refs").read_text().rstrip("\n") branch = (root / "retained-branch").read_text().strip() commit = (root / "retained-commit").read_text().strip() url = (root / "retained-url").read_text().strip() assert Path(url).samefile(submodule_source) replacement = Submodule.add(parent, "logical-name", "module", url, branch=branch, no_checkout=True) assert not Path(replacement.abspath, "file").exists() with replacement.module() as module: assert module.remotes.origin.url == url assert module.git.for_each_ref("--format=%(refname) %(objectname)") == before assert module.git.rev_parse("--show-ref-format") == ref_format assert module.head.reference.name == branch assert module.head.commit.hexsha == commit @pytest.mark.parametrize("change", ["url", "clone-options", "branch", "depth"]) def test_readd_retained_submodule_rejects_incompatible_options(retained_metadata_submodule, change): sm = retained_metadata_submodule name, path, url = sm.name, sm.path, sm.url root = Path(sm.repo.working_tree_dir) def snapshot(): return {str(path.relative_to(root)): path.read_bytes() for path in root.rglob("*") if path.is_file()} before = snapshot() options = {"no_checkout": True} if change == "url": url += "-other" elif change == "clone-options": options["clone_multi_options"] = ["--filter=blob:none"] elif change == "branch": options["branch"] = "other" else: options["depth"] = 1 with pytest.raises(ValueError): Submodule.add(sm.repo, name, path, url, **options) assert not (root / path).exists() assert snapshot() == before @pytest.mark.parametrize("target_kind", ["relative", "absolute", "internal", "dangling"]) @pytest.mark.parametrize("configuration,module", [(True, True), (False, True), (True, False)]) @pytest.mark.parametrize("absolute_path", [False, True]) def test_move_rejects_intermediate_symlink( intermediate_symlink_submodule, target_kind, configuration, module, absolute_path ): """Reject intermediate symlinks before changing repository state or their targets. Cover relative and absolute destinations in every move mode, including links within the repository and dangling links, which must also be rejected. """ submodule, target = intermediate_symlink_submodule parent = submodule.repo root = Path(parent.working_tree_dir) tree = parent.git.write_tree() before = _move_snapshot(submodule) destination = root / "nested/link/new/moved" if absolute_path else "nested/link/new/moved" with pytest.raises(ValueError, match="contains a symbolic link"): submodule.move(destination, configuration=configuration, module=module) assert _move_snapshot(submodule) == before assert parent.git.write_tree() == tree assert Path(submodule.abspath, "file").read_text(encoding="utf-8") == "content" if target_kind == "dangling": assert not target.exists() else: assert list(target.iterdir()) == [] @pytest.mark.parametrize("absolute_path", [False, True]) def test_move_normal_destination(movable_submodule, absolute_path): """Allow ordinary relative and absolute moves, and make a repeated move a no-op.""" submodule = movable_submodule root = Path(submodule.repo.working_tree_dir) destination = root / "nested/moved" if absolute_path else "nested/moved" assert submodule.move(destination) is submodule assert Path(submodule.abspath, "file").read_text(encoding="utf-8") == "content" assert not (root / "module").exists() assert submodule.path == "nested/moved" submodule.repo.git.write_tree() before = _move_snapshot(submodule) assert submodule.move(destination) is submodule assert _move_snapshot(submodule) == before @pytest.mark.parametrize("metadata_dir", ["git_dir", "common_dir"]) @patch_git_config("protocol.file.allow", "always") def test_move_rejects_leaf_symlink_to_metadata(leaf_metadata_submodule, metadata_dir): submodule = leaf_metadata_submodule parent = submodule.repo root = Path(parent.working_tree_dir) assert not osp.samefile(parent.git_dir, parent.common_dir) target = Path(getattr(parent, metadata_dir)) destination = root / "destination" before = _move_snapshot(submodule) with pytest.raises(ValueError, match="Git metadata"): submodule.move("destination", module=False) assert _move_snapshot(submodule) == before assert destination.is_symlink() assert destination.samefile(target) assert Path(submodule.abspath, "file").read_text(encoding="utf-8") == "content" @pytest.mark.parametrize("kind", ["empty", "nonempty", "file", "dangling"]) def test_move_leaf_symlink_compatibility(configured_submodule, kind): """Preserve leaf-symlink replacement without modifying the external target. Moving onto a link to an empty directory replaces the link; nonempty, file, and dangling targets fail without changing repository state. Direct checkout path access must still reject every leaf symlink. """ submodule = configured_submodule root = Path(submodule.repo.working_tree_dir) tmp_path = Path(configured_submodule.repo.working_tree_dir).parent target = tmp_path / "outside" destination = root / "destination" with pytest.raises(ValueError, match="contains a symbolic link"): Submodule(submodule.repo, Submodule.NULL_BIN_SHA, name="unused", path="destination").abspath before = _move_snapshot(submodule) if kind == "empty": assert submodule.move("destination") is submodule assert not destination.is_symlink() assert (destination / "file").is_file() assert list(target.iterdir()) == [] else: with pytest.raises(OSError if kind == "dangling" else ValueError): submodule.move("destination") assert _move_snapshot(submodule) == before assert destination.is_symlink() if kind == "nonempty": assert (target / "keep").read_text(encoding="utf-8") == "keep" elif kind == "file": assert target.read_text(encoding="utf-8") == "keep" else: assert not target.exists() @pytest.mark.parametrize("leaf", [False, True]) @pytest.mark.parametrize("dangling", [False, True]) @pytest.mark.parametrize("operation", ["add", "clone"]) @pytest.mark.parametrize("gitfile", [False, True]) def test_clone_rejects_checkout_symlinks(configured_submodule, leaf, dangling, operation, gitfile): """Reject checkout symlinks before add or clone creates metadata or touches the target. Cover leaf and intermediate links, including dangling targets, with both embedded and separate Git directories. """ sm = configured_submodule tmp_path = Path(configured_submodule.repo.working_tree_dir).parent target = tmp_path / "outside" path = "link" if leaf else "link/new/module" before = _move_snapshot(sm) with mock.patch.object(Submodule, "_need_gitfile_submodules", return_value=gitfile): with pytest.raises(ValueError, match="contains a symbolic link"): if operation == "add": Submodule.add(sm.repo, "new", path, sm.url) else: Submodule._clone_repo(sm.repo, sm.url, path, "new") assert _move_snapshot(sm) == before assert not (Path(sm.repo.git_dir) / "modules/new").exists() assert not target.exists() if dangling else list(target.iterdir()) == [] @pytest.mark.parametrize("link_kind", ["gitmodules", "checkout"]) @pytest.mark.parametrize("operation", ["update", "move", "remove"]) def test_submodule_rejects_checkout_and_gitmodules_symlinks(configured_submodule, link_kind, operation): """Reject operations on symlinked checkouts or .gitmodules without side effects. Update, move, and forced removal must preserve the external target, repository configuration, index, checkout, and any existing move destination. """ sm = configured_submodule root = Path(sm.repo.working_tree_dir) tmp_path = Path(configured_submodule.repo.working_tree_dir).parent path = root / (".gitmodules" if link_kind == "gitmodules" else "module") target = tmp_path / "outside" before = ( {p.relative_to(target): p.read_bytes() for p in target.rglob("*") if p.is_file()} if target.is_dir() else target.read_bytes() ) config = Path(sm.repo.git_dir, "config").read_bytes() index = Path(sm.repo.index.path).read_bytes() gitmodules = (root / ".gitmodules").read_bytes() with pytest.raises(ValueError, match="contains a symbolic link"): if operation == "update": sm.update() elif operation == "move": sm.move("moved") else: sm.remove(force=True) after = ( {p.relative_to(target): p.read_bytes() for p in target.rglob("*") if p.is_file()} if target.is_dir() else target.read_bytes() ) assert after == before assert Path(sm.repo.git_dir, "config").read_bytes() == config assert Path(sm.repo.index.path).read_bytes() == index assert (root / ".gitmodules").read_bytes() == gitmodules assert (root / "module/file").read_text() == "content" assert path.is_symlink() assert (root / "moved").is_dir() def test_add_closes_checkout_processes(movable_submodule, monkeypatch): """Adding a submodule must not leave a child process holding its checkout open.""" # Native reads have no persistent process; keep exercising the CLI fallback. monkeypatch.setattr(git._backend, "object_data", lambda *args, **kwargs: NotImplemented) sm = movable_submodule checkout = Path(sm.repo.working_tree_dir, "new") execute = Git.execute processes = [] def capture_process(self, command, *args, **kwargs): result = execute(self, command, *args, **kwargs) if ( kwargs.get("as_process") and "cat-file" in command and Path(self.working_dir).resolve() == checkout.resolve() ): processes.append(result.proc) return result monkeypatch.setattr(Git, "execute", capture_process) try: added = Submodule.add(sm.repo, "new", "new", sm.url) assert processes, "The HEAD read must exercise a persistent cat-file process" assert all(process.poll() is not None for process in processes) added.move("moved") finally: for process in processes: if process.poll() is None: process.terminate() process.wait() @pytest.mark.parametrize("operation", ["update", "error", "recursive", "keep-going"]) def test_update_closes_checkout_processes_retained_by_logging(movable_submodule, monkeypatch, caplog, operation): monkeypatch.setattr(git._backend, "object_data", lambda *args, **kwargs: NotImplemented) sm = movable_submodule recursive = operation in ("recursive", "keep-going") if operation in ("error", "keep-going"): sm.binsha = b"\x01" * len(sm.binsha) checkout = Path(sm.abspath).resolve() execute = Git.execute processes = [] def capture_process(self, command, *args, **kwargs): result = execute(self, command, *args, **kwargs) if kwargs.get("as_process") and "cat-file" in command: working_dir = Path(self.working_dir).resolve() if working_dir == checkout or checkout in working_dir.parents: processes.append(result.proc) return result monkeypatch.setattr(Git, "execute", capture_process) try: if operation == "error": with pytest.raises(GitCommandError): sm.update(to_latest_revision=True, no_fetch=True) else: sm.update(to_latest_revision=True, no_fetch=True, recursive=recursive, keep_going=operation == "keep-going") # These records retain Head arguments and their internally opened Repos. # Collection cannot release the processes while the records remain alive. records = [record for record in caplog.records if "a tracking branch was not set" in record.msg] assert len(records) == (2 if recursive else 1) assert processes assert all(process.poll() is not None for process in processes) finally: for process in processes: if process.poll() is None: process.terminate() process.wait() @pytest.fixture def windows_directory_symlink_removal(monkeypatch): """Exercise Windows rmdir semantics on POSIX, where rmdir rejects symlinks.""" if sys.platform != "win32": original_rmdir = os.rmdir def rmdir(path, *args, **kwargs): if osp.islink(path): return os.unlink(path, *args, **kwargs) return original_rmdir(path, *args, **kwargs) monkeypatch.setattr(os, "rmdir", rmdir) def test_submodule_allows_symlink_above_worktree( configured_submodule, windows_directory_symlink_removal, metadata_realpath ): """Allow adding and moving submodules when the parent is opened through a symlink.""" sm = configured_submodule tmp_path = Path(configured_submodule.repo.working_tree_dir).parent alias = tmp_path / "alias" with git.Repo(alias) as parent: added = Submodule.add(parent, "new", "new", sm.url) added.move("moved") assert alias.is_symlink() with added.module() as module: assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(added.abspath).resolve() assert Path(added.abspath, "file").read_text() == "content" @pytest.fixture(params=[False, True], ids=["native-realpath", "windows37-realpath"]) def metadata_realpath(request): """Model Python 3.7 on Windows without altering pathlib's own resolver.""" if request.param: paths = SimpleNamespace(**vars(osp)) paths.realpath = osp.abspath with mock.patch("git.objects.submodule.base.osp", paths): yield else: yield @pytest.mark.parametrize("operation", ["add", "reconnect"]) @patch_git_config("protocol.file.allow", "always") def test_submodule_allows_metadata_destination_symlinks(configured_submodule, operation, metadata_realpath): """Allow linked metadata destinations while keeping the checkout correctly connected. Adding and reconnecting after deinit may store metadata outside the parent repository through a symlink under .git/modules, preserving that link. """ sm = configured_submodule root = Path(sm.repo.working_tree_dir) tmp_path = Path(configured_submodule.repo.working_tree_dir).parent outside = tmp_path / "outside" link = Path(sm.repo.git_dir) / "modules/link" sm = Submodule.add(sm.repo, "link/new", "new", sm.url) if operation == "reconnect": sm.repo.index.commit("Add linked metadata submodule", skip_hooks=True) sm.repo.git.submodule("deinit", "--force", "new") sm.update(init=True) assert link.is_symlink() assert (outside / "new/HEAD").is_file() with sm.module() as module: assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(sm.abspath).resolve() assert Path(sm.abspath, "file").read_text() == "content" assert (root / ".gitmodules").is_file() @pytest.mark.parametrize("kind", ["modules", "intermediate", "leaf", "gitfile", "config", "alias"]) @pytest.mark.parametrize("operation", ["update", "move", "remove"]) @pytest.mark.parametrize("configured_submodule", ["nested/module"], indirect=True) @patch_git_config("protocol.file.allow", "always") def test_submodule_allows_existing_metadata_symlinks( configured_submodule, kind, operation, windows_directory_symlink_removal, metadata_realpath ): """Keep submodule operations compatible with existing symlinks in Git metadata. Cover linked metadata directories, gitfiles, configs, and internal aliases. Update and move must retain a usable checkout; forced removal retains the metadata directory for a later reinitialization. """ sm = configured_submodule root = Path(sm.repo.working_tree_dir) tmp_path = Path(configured_submodule.repo.working_tree_dir).parent modules = Path(sm.repo.git_dir) / "modules" paths = { "modules": modules, "intermediate": modules / "nested", "leaf": modules / "nested/module", "gitfile": root / "module/.git", "config": modules / "nested/module/config", "alias": modules / "alias", } link = paths[kind] target = tmp_path / "outside" if kind == "alias": target = modules / "nested" assert sm.module_exists() if operation == "remove": metadata_dir = (modules / "nested/module").resolve() assert metadata_dir.is_dir() url = sm.url sm.remove(force=True) assert not (root / "module").exists() assert metadata_dir.is_dir() if kind in ("modules", "intermediate", "alias"): assert link.is_symlink() and link.is_dir() replacement = Submodule.add(sm.repo, "nested/module", "module", url) if kind == "leaf": assert link.is_symlink() and link.is_dir() assert target.is_dir() with replacement.module() as module: assert Path(module.git.rev_parse("--show-toplevel")).resolve() == (root / "module").resolve() return if operation == "update": sm.update() else: sm.move("moved") if kind == "modules": assert link.is_symlink() assert link.is_dir() with sm.module() as module: assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(sm.abspath).resolve() assert Path(sm.abspath, "file").read_text() == "content" @pytest.mark.parametrize("kind", ["modules", "intermediate", "leaf"]) @pytest.mark.parametrize("configured_submodule", ["nested/module"], indirect=True) @patch_git_config("protocol.file.allow", "always") def test_remove_linked_metadata_keeps_siblings_and_can_reinitialize(configured_submodule, kind, metadata_realpath): sm = configured_submodule sibling = sm.repo.submodules["nested/sibling"] modules = Path(sm.repo.git_dir) / "modules" link = {"modules": modules, "intermediate": modules / "nested", "leaf": modules / "nested/module"}[kind] sm.remove(force=True, configuration=False) assert link.is_symlink() assert link.exists() with sibling.module() as module: assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(sibling.abspath).resolve() assert Path(sibling.abspath, "file").read_text() == "content" sm.update(init=True) assert link.is_symlink() and link.is_dir() assert Path(sm.abspath, "file").read_text() == "content" with sm.module() as module: assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(sm.abspath).resolve() @pytest.mark.parametrize("relative_target", [False, True], ids=["absolute-target", "relative-target"]) def test_add_to_dangling_metadata_symlink(configured_submodule, metadata_realpath, relative_target): sm = configured_submodule tmp_path = Path(configured_submodule.repo.working_tree_dir).parent link = Path(sm.repo.git_dir) / "modules/new" target = tmp_path / "missing" / "metadata" assert link.is_symlink() and not link.exists() assert not target.exists() link_target = os.readlink(str(link)) added = Submodule.add(sm.repo, "new", "new", sm.url) assert link.is_symlink() and link.is_dir() assert os.readlink(str(link)) == link_target assert (target / "HEAD").is_file() with added.module() as module: assert Path(module.git_dir).resolve() == target.resolve() assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(added.abspath).resolve() assert Path(added.abspath, "file").read_text() == "content" class TestRootProgress(RootUpdateProgress): """Just prints messages, for now without checking the correctness of the states""" def update(self, op, cur_count, max_count, message=""): print(op, cur_count, max_count, message) TestRootProgress.__test__ = False prog = TestRootProgress() class TestSubmodule(TestBase): def _fixture(self, scenario, *, write=True, execute=False): source = self._small_repo_url() if not write: return read_only_fixture("submodule-cases.sh", scenario, source) temporary = writable_fixture("submodule-cases.sh", scenario, source, execute=execute) self.addCleanup(temporary.close) return temporary.path().resolve() def tearDown(self): gc.collect() k_subm_current = "c15a6e1923a14bc760851913858a3942a4193cdb" k_subm_changed = "394ed7006ee5dc8bddfd132b64001d5dfc0ffdd3" k_no_subm_tag = "0.1.6" def _do_base_tests(self, rwrepo): """Perform all tests in the given repository, it may be bare or nonbare""" # Manual instantiation. smm = Submodule(rwrepo, rwrepo._null_binsha) # Name needs to be set in advance. self.assertRaises(AttributeError, getattr, smm, "name") # Iterate - 1 submodule. sms = Submodule.list_items(rwrepo, self.k_subm_current) assert len(sms) == 1 sm = sms[0] # At a different time, there is None. assert len(Submodule.list_items(rwrepo, self.k_no_subm_tag)) == 0 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.branch_path == "refs/heads/master" # the default ... assert sm.branch_name == "master" assert sm.parent_commit == rwrepo.head.commit # Size is always 0. assert sm.size == 0 # The module is not checked-out yet. self.assertRaises(InvalidGitRepositoryError, sm.module) # ...which is why we can't get the branch either - it points into the module() # repository. self.assertRaises(InvalidGitRepositoryError, getattr, sm, "branch") # branch_path works, as it's just a string. assert isinstance(sm.branch_path, str) # Some commits earlier we still have a submodule, but it's at a different # commit. smold = next(Submodule.iter_items(rwrepo, self.k_subm_changed)) assert smold.binsha != sm.binsha assert smold != sm # the name changed # Force it to reread its information. del smold._url smold.url == sm.url # noqa: B015 # FIXME: Should this be an assertion? # Test config_reader/writer methods. sm.config_reader() new_smclone_path = None # Keep custom paths for later. new_csmclone_path = None # if rwrepo.bare: with self.assertRaises(InvalidGitRepositoryError): with sm.config_writer() as cw: pass else: with sm.config_writer() as writer: # For faster checkout, set the url to the local path. new_smclone_path = self._gitdb_repo_url() writer.set_value("url", new_smclone_path) writer.release() assert sm.config_reader().get_value("url") == new_smclone_path assert sm.url == new_smclone_path # END handle bare repo smold.config_reader() # Cannot get a writer on historical submodules. if not rwrepo.bare: with self.assertRaises(ValueError): with smold.config_writer(): pass # END handle bare repo # Make the old into a new - this doesn't work as the name changed. self.assertRaises(ValueError, smold.set_parent_commit, self.k_subm_current) # the sha is properly updated smold.set_parent_commit(self.k_subm_changed + "~1") assert smold.binsha != sm.binsha # Raises if the sm didn't exist in new parent - it keeps its parent_commit # unchanged. self.assertRaises(ValueError, smold.set_parent_commit, self.k_no_subm_tag) # TODO: Test that, if a path is in the .gitmodules file, but not in the index, # then it raises. # TEST UPDATE ############## # Module retrieval is not always possible. if rwrepo.bare: self.assertRaises(InvalidGitRepositoryError, sm.module) self.assertRaises(InvalidGitRepositoryError, sm.remove) self.assertRaises(InvalidGitRepositoryError, sm.add, rwrepo, "here", "there") else: # It's not checked out in our case. self.assertRaises(InvalidGitRepositoryError, sm.module) assert not sm.module_exists() # Currently there is only one submodule. assert len(list(rwrepo.iter_submodules())) == 1 assert sm.binsha != "\0" * 20 # TEST ADD ########### # Preliminary tests. # Adding existing returns exactly the existing. sma = Submodule.add(rwrepo, sm.name, sm.path) assert sma.path == sm.path # Adding existing as pathlike sma = Submodule.add(rwrepo, sm.name, PathLikeMock(sm.path)) assert sma.path == sm.path # No url and no module at path fails. self.assertRaises(ValueError, Submodule.add, rwrepo, "newsubm", "pathtorepo", url=None) # CONTINUE UPDATE ################# # Let's update it - it's a recursive one too. newdir = osp.join(sm.abspath, "dir") os.makedirs(newdir) # Update fails if the path already exists non-empty. self.assertRaises(OSError, sm.update) os.rmdir(newdir) # Dry-run does nothing. sm.update(dry_run=True, progress=prog) assert not sm.module_exists() assert sm.update() is sm sm_repopath = sm.path # Cache for later. assert sm.module_exists() assert isinstance(sm.module(), git.Repo) assert sm.module().working_tree_dir == sm.abspath # INTERLEAVE ADD TEST ##################### # url must match the one in the existing repository (if submodule name # suggests a new one) or we raise. self.assertRaises( ValueError, Submodule.add, rwrepo, "newsubm", sm.path, "git://someurl/repo.git", ) # CONTINUE UPDATE ################# # We should have setup a tracking branch, which is also active. assert sm.module().head.ref.tracking_branch() is not None # Delete the whole directory and re-initialize. assert len(sm.children()) != 0 # shutil.rmtree(sm.abspath) sm.remove(force=True, configuration=False) assert len(sm.children()) == 0 # Dry-run does nothing. sm.update(dry_run=True, recursive=False, progress=prog) assert len(sm.children()) == 0 sm.update(recursive=False) assert len(list(rwrepo.iter_submodules())) == 2 assert len(sm.children()) == 1 # It's not checked out yet. csm = sm.children()[0] assert not csm.module_exists() csm_repopath = csm.path # Adjust the path of the submodules module to point to the local # destination. new_csmclone_path = self._small_repo_url() with csm.config_writer() as writer: writer.set_value("url", new_csmclone_path) assert csm.url == new_csmclone_path # Dry-run does nothing. assert not csm.module_exists() sm.update(recursive=True, dry_run=True, progress=prog) assert not csm.module_exists() # Update recursively again. sm.update(recursive=True) assert csm.module_exists() # Tracking branch once again. assert csm.module().head.ref.tracking_branch() is not None # This flushed in a sub-submodule. assert len(list(rwrepo.iter_submodules())) == 2 # Reset both heads to the previous version, verify that to_latest_revision # works. smods = (sm.module(), csm.module()) for repo in smods: repo.head.reset("HEAD~2", working_tree=1) # END for each repo to reset # Dry-run does nothing. self.assertRaises( RepositoryDirtyError, sm.update, recursive=True, dry_run=True, progress=prog, ) sm.update(recursive=True, dry_run=True, progress=prog, force=True) for repo in smods: assert repo.head.commit != repo.head.ref.tracking_branch().commit # END for each repo to check self.assertRaises(RepositoryDirtyError, sm.update, recursive=True, to_latest_revision=True) sm.update(recursive=True, to_latest_revision=True, force=True) for repo in smods: assert repo.head.commit == repo.head.ref.tracking_branch().commit repo.close() # END for each repo to check del smods # If the head is detached, it still works (but warns). smref = sm.module().head.ref sm.module().head.ref = "HEAD~1" # 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) sm.update(recursive=True, to_latest_revision=True) # to_latest_revision changes the child submodule's commit, it needs an # update now. csm.set_parent_commit(csm.repo.head.commit) # Undo the changes. 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. 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(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) # 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) # 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. assert sm.module_exists() csm.repo.close() sm.remove(configuration=False, force=True) assert sm.exists() assert not sm.module_exists() assert sm.config_reader().get_value("url") # Delete the rest. sm_path = sm.path sm.remove() assert not sm.exists() assert not sm.module_exists() self.assertRaises(ValueError, getattr, sm, "path") assert len(rwrepo.submodules) == 0 # ADD NEW SUBMODULE ################### # Add a simple remote repo - trailing slashes are no problem. smid = "newsub" osmid = "othersub" nsm = Submodule.add( rwrepo, smid, sm_repopath, new_smclone_path + "/", None, no_checkout=True, ) assert nsm.name == smid assert nsm.module_exists() assert nsm.exists() # It's not checked out. assert not osp.isfile(join_path_native(nsm.module().working_tree_dir, Submodule.k_modules_file)) assert len(rwrepo.submodules) == 1 # Add another submodule, but into the root, not as submodule. osm = Submodule.add(rwrepo, osmid, csm_repopath, new_csmclone_path, Submodule.k_head_default) assert osm != nsm assert osm.module_exists() assert osm.exists() assert osp.isfile(join_path_native(osm.module().working_tree_dir, "setup.py")) assert len(rwrepo.submodules) == 2 # Commit the changes, just to finalize the operation. rwrepo.index.commit("my submod commit", skip_hooks=True) assert len(rwrepo.submodules) == 2 # Needs update, as the head changed. # It thinks it's in the history of the repo otherwise. nsm.set_parent_commit(rwrepo.head.commit) osm.set_parent_commit(rwrepo.head.commit) # MOVE MODULE ############# # Invalid input. self.assertRaises(ValueError, nsm.move, "doesntmatter", module=False, configuration=False) # Renaming to the same path does nothing. assert nsm.move(sm_path) is nsm # Rename a module. nmp = join_path_native("new", "module", "dir") + "/" # New module path. pmp = nsm.path assert nsm.move(nmp) is nsm nmp = nmp[:-1] # Cut last / nmpl = to_native_path_linux(nmp) assert nsm.path == nmpl assert rwrepo.submodules[0].path == nmpl mpath = "newsubmodule" absmpath = join_path_native(rwrepo.working_tree_dir, mpath) open(absmpath, "w").write("") self.assertRaises(ValueError, nsm.move, mpath) os.remove(absmpath) # Now it works, as we just move it back. nsm.move(pmp) assert nsm.path == pmp assert rwrepo.submodules[0].path == pmp # REMOVE 'EM ALL ################ # If a submodule's repo has no remotes, it can't be added without an # explicit url. osmod = osm.module() osm.remove(module=False) for remote in osmod.remotes: remote.remove(osmod, remote.name) assert not osm.exists() self.assertRaises(ValueError, Submodule.add, rwrepo, osmid, csm_repopath, url=None) # END handle bare mode # Error if there is no submodule file here. self.assertRaises( IOError, Submodule._config_parser, rwrepo, rwrepo.commit(self.k_no_subm_tag), True, ) @with_rw_repo(k_subm_current) @patch_git_config("protocol.file.allow", "always") def test_base_rw(self, rwrepo): self._do_base_tests(rwrepo) @with_rw_repo(k_subm_current, bare=True) def test_base_bare(self, rwrepo): self._do_base_tests(rwrepo) @pytest.mark.xfail( sys.platform == "win32", reason=( '"The process cannot access the file because it is being used by another process"' + " on first call to rm.update" ), raises=PermissionError, ) @with_rw_repo(k_subm_current, bare=False) @patch_git_config("protocol.file.allow", "always") def test_root_module(self, rwrepo): # Can query everything without problems. rm = RootModule(rwrepo) assert rm.module() is rwrepo # Try attributes. rm.binsha rm.mode rm.path assert rm.name == rm.k_root_name assert rm.parent_commit == rwrepo.head.commit rm.url rm.branch assert len(rm.list_items(rm.module())) == 1 rm.config_reader() with rm.config_writer(): pass # Cannot set the parent commit as root module's path didn't exist. self.assertRaises(ValueError, rm.set_parent_commit, "HEAD") with mock.patch.object(RootModule, "list_items", side_effect=ValueError("boom")): rm.update(keep_going=True) # TEST UPDATE ############# # Set up a commit that removes existing, adds new and modifies existing # submodules. rm = RootModule(rwrepo) assert len(rm.children()) == 1 # Modify path without modifying the index entry. # (Which is what the move method would do properly.) # ================================================== sm = rm.children()[0] pp = "path/prefix" fp = join_path_native(pp, sm.path) prep = sm.path assert not sm.module_exists() # It was never updated after rwrepo's clone. # Ensure we clone from a local source. with sm.config_writer() as writer: writer.set_value("url", self._gitdb_repo_url()) # Dry-run does nothing. sm.update(recursive=False, dry_run=True, progress=prog) assert not sm.module_exists() sm.update(recursive=False) assert sm.module_exists() with sm.config_writer() as writer: # Change path to something with prefix AFTER url change. writer.set_value("path", fp) # Update doesn't fail, because list_items ignores the wrong path in such # situations. rm.update(recursive=False) # Move it properly - doesn't work as it its path currently points to an # indexentry which doesn't exist (move it to some path, it doesn't matter here). self.assertRaises(InvalidGitRepositoryError, sm.move, pp) # Reset the path(cache) to where it was, now it works. sm.path = prep sm.move(fp, module=False) # Leave it at the old location. assert not sm.module_exists() cpathchange = rwrepo.index.commit("changed sm path", skip_hooks=True) # Finally we can commit. # Update puts the module into place. rm.update(recursive=False, progress=prog) sm.set_parent_commit(cpathchange) assert sm.module_exists() # Add submodule. # ============== nsmn = "newsubmodule" nsmp = "submrepo" subrepo_url = self._small_repo_url() nsm = Submodule.add(rwrepo, nsmn, nsmp, url=subrepo_url) csmadded = rwrepo.index.commit( "Added submodule", skip_hooks=True ).hexsha # Make sure we don't keep the repo reference. nsm.set_parent_commit(csmadded) assert nsm.module_exists() # In our case, the module should not exist, which happens if we update a parent # repo and a new submodule comes into life. nsm.remove(configuration=False, module=True) assert not nsm.module_exists() and nsm.exists() # Dry-run does nothing. rm.update(recursive=False, dry_run=True, progress=prog) # Otherwise it will work. rm.update(recursive=False, progress=prog) assert nsm.module_exists() # Remove submodule - the previous one. # ==================================== sm.set_parent_commit(csmadded) smp = sm.abspath assert not sm.remove(module=False).exists() assert osp.isdir(smp) # Module still exists. csmremoved = rwrepo.index.commit("Removed submodule", skip_hooks=True) # An update will remove the module. # Not in dry_run. rm.update(recursive=False, dry_run=True, force_remove=True) assert osp.isdir(smp) # When removing submodules, we may get new commits as nested submodules are # auto-committing changes to allow deletions without force, as the index would # be dirty otherwise. # QUESTION: Why does this seem to work in test_git_submodule_compatibility() ? self.assertRaises(InvalidGitRepositoryError, rm.update, recursive=False, force_remove=False) rm.update(recursive=False, force_remove=True) assert not osp.isdir(smp) # 'Apply work' to the nested submodule and ensure this is not removed/altered # during updates. We need to commit first, otherwise submodule.update wouldn't # have a reason to change the head. touch(osp.join(nsm.module().working_tree_dir, "new-file")) # We cannot expect is_dirty to even run as we wouldn't reset a head to the same # location. assert nsm.module().head.commit.hexsha == nsm.hexsha nsm.module().index.add([nsm]) nsm.module().index.commit("added new file", skip_hooks=True) rm.update(recursive=False, dry_run=True, progress=prog) # Would not change head, and thus doesn't fail. # Everything we can do from now on will trigger the 'future' check, so no # is_dirty() check will even run. This would only run if our local branch is in # the past and we have uncommitted changes. prev_commit = nsm.module().head.commit rm.update(recursive=False, dry_run=False, progress=prog) assert prev_commit == nsm.module().head.commit, "head shouldn't change, as it is in future of remote branch" # this kills the new file rm.update(recursive=True, progress=prog, force_reset=True) assert prev_commit != nsm.module().head.commit, "head changed, as the remote url and its commit changed" # Change url... # ============= # ...to the first repository. This way we have a fast checkout, and a completely # different repository at the different url. nsm.set_parent_commit(csmremoved) nsmurl = self._gitdb_repo_url() with nsm.config_writer() as writer: writer.set_value("url", nsmurl) csmpathchange = rwrepo.index.commit("changed url", skip_hooks=True) nsm.set_parent_commit(csmpathchange) # Now nsm head is in the future of the tracked remote branch. prev_commit = nsm.module().head.commit # dry-run does nothing rm.update(recursive=False, dry_run=True, progress=prog) assert nsm.module().remotes.origin.url != nsmurl rm.update(recursive=False, progress=prog, force_reset=True) assert nsm.module().remotes.origin.url == nsmurl assert prev_commit != nsm.module().head.commit, "Should now point to gitdb" assert len(rwrepo.submodules) == 1 assert not rwrepo.submodules[0].children()[0].module_exists(), "nested submodule should not be checked out" # Add the submodule's changed commit to the index, which is what the user would # do. Beforehand, update our instance's binsha with the new one. nsm.binsha = nsm.module().head.commit.binsha rwrepo.index.add([nsm]) # Change branch. # ============== # We only have one branch, so we switch to a virtual one, and back to the # current one to trigger the difference. cur_branch = nsm.branch nsmm = nsm.module() prev_commit = nsmm.head.commit for branch in ("some_virtual_branch", cur_branch.name): with nsm.config_writer() as writer: writer.set_value(Submodule.k_head_option, git.Head.to_full_path(branch)) csmbranchchange = rwrepo.index.commit("changed branch to %s" % branch, skip_hooks=True) nsm.set_parent_commit(csmbranchchange) # END for each branch to change # Let's remove our tracking branch to simulate some changes. nsmmh = nsmm.head assert nsmmh.ref.tracking_branch() is None # Never set it up until now. assert not nsmmh.is_detached # Dry-run does nothing. rm.update(recursive=False, dry_run=True, progress=prog) assert nsmmh.ref.tracking_branch() is None # The real thing does. rm.update(recursive=False, progress=prog) assert nsmmh.ref.tracking_branch() is not None assert not nsmmh.is_detached # Recursive update. # ================= # Finally we recursively update a module, just to run the code at least once # remove the module so that it has more work. assert len(nsm.children()) >= 1 # Could include smmap. assert nsm.exists() and nsm.module_exists() and len(nsm.children()) >= 1 # Ensure we pull locally only. nsmc = nsm.children()[0] with nsmc.config_writer() as writer: writer.set_value("url", subrepo_url) rm.update(recursive=True, progress=prog, dry_run=True) # Just to run the code. rm.update(recursive=True, progress=prog) # gitdb: has either 1 or 2 submodules depending on the version. assert len(nsm.children()) >= 1 and nsmc.module_exists() def test_iter_items_from_nonexistent_hash(self): it = Submodule.iter_items(self.rorepo, "b4ecbfaa90c8be6ed6d9fb4e57cc824663ae15b4") with self.assertRaisesRegex(ValueError, r"\bcould not be resolved\b"): next(it) def test_iter_items_from_invalid_hash(self): """Check legacy behavaior on BadName (also applies to IOError, i.e. OSError).""" it = Submodule.iter_items(self.rorepo, "xyz") with self.assertRaises(StopIteration) as ctx: next(it) self.assertIsNone(ctx.exception.value) def test_deinit_calls_git_submodule(self): rwdir = self._fixture("empty") repo = git.Repo(rwdir / "parent") submodule = Submodule(repo, b"\0" * 20, name="module", path="module") with mock.patch.object(Git, "_call_process_safe") as git_submodule: submodule.deinit() git_submodule.assert_called_once_with( "submodule", "deinit", "--", submodule.path, env={"GIT_LITERAL_PATHSPECS": "1"} ) git_submodule.reset_mock() submodule.deinit(force=True) git_submodule.assert_called_once_with( "submodule", "deinit", "--force", "--", submodule.path, env={"GIT_LITERAL_PATHSPECS": "1"} ) @patch_git_config("protocol.file.allow", "always") def test_update_after_deinit(self): rwdir = self._fixture("deinit", execute=True) source_repo = git.Repo(rwdir / "source") parent_repo = git.Repo(rwdir / "parent") submodule = parent_repo.submodules[0] assert not submodule.module_exists() assert osp.isdir(osp.join(parent_repo.git_dir, "modules", submodule.name)) submodule.update() assert submodule.module_exists() assert submodule.module().head.commit == source_repo.head.commit assert osp.isfile(osp.join(submodule.abspath, "file")) @patch_git_config("protocol.file.allow", "always") def test_update_to_latest_revision_after_deinit_fetches_remote(self): rwdir = self._fixture("deinit-latest", execute=True) source_repo = git.Repo(rwdir / "source") parent_repo = git.Repo(rwdir / "parent") submodule = parent_repo.submodules[0] submodule.update(to_latest_revision=True) assert submodule.module().head.commit == source_repo.head.commit assert osp.isfile(osp.join(submodule.abspath, "new-file")) @patch_git_config("protocol.file.allow", "always") def test_update_after_deinit_fetches_new_gitlink_commit(self): rwdir = self._fixture("deinit-gitlink", execute=True) source_repo = git.Repo(rwdir / "source") parent_repo = git.Repo(rwdir / "parent") submodule = parent_repo.submodules[0] submodule.update() assert submodule.module().head.commit == source_repo.head.commit assert osp.isfile(osp.join(submodule.abspath, "new-file")) def test_update_after_deinit_refuses_non_empty_checkout(self): rwdir = self._fixture("deinit-nonempty", execute=True) parent_repo = git.Repo(rwdir / "parent") submodule = parent_repo.submodules[0] checkout_file = osp.join(submodule.abspath, "file") with pytest.raises(OSError, match="does already exist and is non-empty"): submodule.update() with open(checkout_file) as fp: assert fp.read() == "user content" assert not osp.exists(osp.join(submodule.abspath, ".git")) def test_update_after_deinit_without_init_does_not_restore_checkout(self): rwdir = self._fixture("deinit", execute=True) parent_repo = git.Repo(rwdir / "parent") submodule = parent_repo.submodules[0] assert submodule.update(init=False) is submodule assert not submodule.module_exists() assert not osp.exists(osp.join(submodule.abspath, ".git")) def test_dry_run_update_after_deinit_does_not_restore_checkout(self): rwdir = self._fixture("deinit", execute=True) parent_repo = git.Repo(rwdir / "parent") submodule = parent_repo.submodules[0] assert submodule.update(dry_run=True) is submodule assert not submodule.module_exists() assert not osp.exists(osp.join(submodule.abspath, ".git")) @patch_git_config("protocol.file.allow", "always") def test_update_after_deinit_restores_nested_checkout(self): rwdir = self._fixture("deinit-nested", execute=True) source_repo = git.Repo(rwdir / "source") parent_repo = git.Repo(rwdir / "parent") submodule = parent_repo.submodules[0] submodule.update() module_repo = submodule.module() assert module_repo.head.commit == source_repo.head.commit assert osp.isfile(osp.join(submodule.abspath, "file")) assert osp.samefile(module_repo.git_dir, osp.join(parent_repo.git_dir, "modules", submodule.name)) @with_rw_repo(k_no_subm_tag, bare=False) def test_first_submodule(self, rwrepo): assert len(list(rwrepo.iter_submodules())) == 0 for sm_name, sm_path in ( ("first", "submodules/first"), ("second", osp.join(rwrepo.working_tree_dir, "submodules/second")), ): sm = rwrepo.create_submodule(sm_name, sm_path, rwrepo.git_dir, no_checkout=True) assert sm.exists() and sm.module_exists() rwrepo.index.commit("Added submodule " + sm_name, skip_hooks=True) # END for each submodule path to add self.assertRaises(ValueError, rwrepo.create_submodule, "fail", osp.expanduser("~")) self.assertRaises( ValueError, rwrepo.create_submodule, "fail-too", rwrepo.working_tree_dir + osp.sep, ) def test_add_empty_repo(self): rwdir = self._fixture("add-empty") empty_repo_dir = str(rwdir / "empty-repo") parent = git.Repo(rwdir / "parent") for checkout_mode in range(2): name = "empty" + str(checkout_mode) self.assertRaises( ValueError, parent.create_submodule, name, name, url=empty_repo_dir, no_checkout=checkout_mode and True or False, ) # END for each checkout mode @patch_git_config("protocol.file.allow", "always") def test_update_submodule_with_relative_path(self): rwdir = self._fixture("relative") cloned_repo = git.Repo(rwdir / "cloned_repo") cloned_repo.submodule_update(init=True, recursive=True) @patch_git_config("protocol.file.allow", "always") def test_update_rejects_parent_component_in_name(self): for existing_metadata in (False, True): scenario = "invalid-name-existing" if existing_metadata else "invalid-name" rwdir = self._fixture(scenario, execute=True) clone = git.Repo(rwdir / "clone") with pytest.raises(ValueError, match="submodule name"): clone.submodules[0].update(init=True) assert (rwdir / "escaped").exists() == existing_metadata invalid_names = ( "", "nul\0name", "..", "../module", R"..\module", "nested/../module", R"nested\..\module", "/module", R"\module", "C:module", R"C:\module", ) for name in invalid_names: with pytest.raises(ValueError, match="submodule name"): Submodule._module_abspath(clone, "module", name) with mock.patch.object(Submodule, "_need_gitfile_submodules", return_value=False): with pytest.raises(ValueError, match="submodule name"): Submodule._module_abspath(clone, "module", "../module") @patch_git_config("protocol.file.allow", "always") def test_root_update_keeps_going_after_invalid_submodule_name(self): rwdir = self._fixture("invalid-and-valid", execute=True) clone = git.Repo(rwdir / "clone") assert [sm.name for sm in clone.submodules] == ["../invalid", "valid"] clone.submodule_update(keep_going=True) assert os.listdir(osp.join(clone.working_tree_dir, "invalid")) == [] assert not clone.submodule("../invalid").module_exists() assert clone.submodule("valid").module_exists() clone.submodule("../invalid").update(recursive=True, keep_going=True) @patch_git_config("protocol.file.allow", "always") def test_list_only_valid_submodules(self): with git.Repo(self._fixture("module", write=False) / "parent") as repo: assert len(repo.submodules) == 1 with git.Repo(self._fixture("list-valid", write=False) / "parent") as repo: assert len(repo.submodules) == 0 @pytest.mark.xfail( sys.platform == "win32", reason=( '"The process cannot access the file because it is being used by another process"' + " on first call to sm.move" ), raises=PermissionError, ) @patch_git_config("protocol.file.allow", "always") def test_git_submodules_and_add_sm_with_new_commit(self): rwdir = self._fixture("existing-repository") parent = git.Repo(rwdir / "parent") assert len(parent.submodules) == 1 sm = parent.submodules[0] assert sm.exists() and sm.module_exists() clone = git.Repo(rwdir / "parent/existing-subrepository") sm2 = parent.create_submodule("nongit-file-submodule", clone.working_tree_dir) assert len(parent.submodules) == 2 for _ in range(2): for init in (False, True): sm.update(init=init) sm2.update(init=init) # END for each init state # END for each iteration sm.move(sm.path + "_moved") sm2.move(sm2.path + "_moved") parent.index.commit("moved submodules", skip_hooks=True) smm = sm.module() with smm.config_writer() as writer: writer.set_value("user", "email", "example@example.com") writer.set_value("user", "name", "me") fp = osp.join(smm.working_tree_dir, "empty-file") with open(fp, "w"): pass smm.git.add(Git.polish_url(fp)) smm.git.commit(m="new file added") # Submodules are retrieved from the current commit's tree, therefore we can't # really get a new submodule object pointing to the new submodule commit. sm_too = parent.submodules["module"] assert parent.head.commit.tree[sm.path].binsha == sm.binsha assert sm_too.binsha == sm.binsha, "cached submodule should point to the same commit as updated one" added_bies = parent.index.add([sm]) # Added base-index-entries. assert len(added_bies) == 1 parent.index.commit("add same submodule entry", skip_hooks=True) commit_sm = parent.head.commit.tree[sm.path] assert commit_sm.binsha == added_bies[0].binsha assert commit_sm.binsha == sm.binsha sm_too.binsha = sm_too.module().head.commit.binsha added_bies = parent.index.add([sm_too]) assert len(added_bies) == 1 parent.index.commit("add new submodule entry", skip_hooks=True) commit_sm = parent.head.commit.tree[sm.path] assert commit_sm.binsha == added_bies[0].binsha assert commit_sm.binsha == sm_too.binsha assert sm_too.binsha != sm.binsha @pytest.mark.xfail( sys.platform == "win32", reason='"The process cannot access the file because it is being used by another process" on call to sm.move', raises=PermissionError, ) @patch_git_config("protocol.file.allow", "always") def test_git_submodule_compatibility(self): rwdir = self._fixture("compatibility") parent = git.Repo(rwdir / "parent") sm_path = join_path_native("submodules", "intermediate", "one") sm = parent.submodules[0] def assert_exists(sm, value=True): assert sm.exists() == value assert sm.module_exists() == value # END assert_exists # As git is backwards compatible itself, it would still recognize what we do # here... unless we really muss it up. That's the only reason why the test is # still here... assert len(parent.git.submodule().splitlines()) == 1 module_repo_path = osp.join(sm.module().working_tree_dir, ".git") assert module_repo_path.startswith(osp.join(parent.working_tree_dir, sm_path)) if not sm._need_gitfile_submodules(parent.git): assert osp.isdir(module_repo_path) assert not sm.module().has_separate_working_tree() else: assert osp.isfile(module_repo_path) assert sm.module().has_separate_working_tree() assert find_submodule_git_dir(module_repo_path) is not None, "module pointed to by .git file must be valid" # END verify submodule 'style' # Test move. new_sm_path = join_path_native("submodules", "one") sm.move(new_sm_path) assert_exists(sm) # Add additional submodule level. with sm.module() as module: csm = module.create_submodule( "nested-submodule", join_path_native("nested-submodule", "working-tree"), url=self._small_repo_url(), ) module.index.commit("added nested submodule", skip_hooks=True) sm_head_commit = module.commit() assert_exists(csm) # Fails because there are new commits, compared to the remote we cloned from. self.assertRaises(InvalidGitRepositoryError, sm.remove, dry_run=True) assert_exists(sm) assert sm.module().commit() == sm_head_commit assert_exists(csm) # keep_going evaluation. rsm = parent.submodule_update() assert_exists(sm) assert_exists(csm) with csm.config_writer().set_value("url", "bar"): pass csm.repo.index.commit("Have to commit submodule change for algorithm to pick it up", skip_hooks=True) assert csm.url == "bar" self.assertRaises( # noqa: B017 Exception, rsm.update, recursive=True, to_latest_revision=True, progress=prog, ) assert_exists(csm) rsm.update(recursive=True, to_latest_revision=True, progress=prog, keep_going=True) # remove sm_module_path = sm.module().git_dir csm.repo.close() for dry_run in (True, False): sm.remove(dry_run=dry_run, force=True) assert_exists(sm, value=dry_run) assert osp.isdir(sm_module_path) # END for each dry-run mode def test_ignore_non_submodule_file(self): rwdir = self._fixture("non-submodule", write=False) parent = git.Repo(rwdir / "parent") assert len(parent.submodules) == 0 def test_gitmodules_does_not_merge_includes(self): rwdir = self._fixture("include", write=False) parent = git.Repo(rwdir / "parent") parser = Submodule._config_parser(parent, None, read_only=True) self.assertEqual(parser.get_value('submodule "module"', "path"), "module") def test_remove_norefs(self): rwdir = self._fixture("remove-norefs") parent = git.Repo(rwdir / "parent") sm = parent.submodules[0] assert sm.repo is parent # A remote without fetched references must not prevent removal. sm.remove() assert not sm.exists() def test_move_preserves_logical_name(self): rwdir = self._fixture("logical-name") parent = git.Repo(rwdir / "parent") sm = parent.submodules[0] name = "mymodules/myname" assert sm.move("renamed/myname").name == name assert sm.exists() and sm.module_exists() assert not hasattr(sm, "rename") @patch_git_config("protocol.file.allow", "always") def test_branch_renames(self): rwdir = self._fixture("branch-renames", execute=True) sm_source_repo = git.Repo(rwdir / "sm-source") parent_repo = git.Repo(rwdir / "parent") sm = parent_repo.submodules[0] assert sm.exists() # Create feature branch with one new commit in submodule source. sm_fb = sm_source_repo.create_head("feature") sm_fb.checkout() new_file = touch(osp.join(sm_source_repo.working_tree_dir, "new-file")) sm_source_repo.index.add([new_file]) sm.repo.index.commit("added new file", skip_hooks=True) # Change designated submodule checkout branch to the new upstream feature # branch. with sm.config_writer() as smcw: smcw.set_value("branch", sm_fb.name) assert sm.repo.is_dirty(index=True, working_tree=False) sm.repo.index.commit("changed submodule branch to '%s'" % sm_fb, skip_hooks=True) # Verify submodule update with feature branch that leaves currently checked out # branch in it's past. sm_mod = sm.module() prev_commit = sm_mod.commit() assert sm_mod.head.ref.name == "master" assert parent_repo.submodule_update() assert sm_mod.head.ref.name == sm_fb.name assert sm_mod.commit() == prev_commit, "Without to_latest_revision, we don't change the commit" assert parent_repo.submodule_update(to_latest_revision=True) assert sm_mod.head.ref.name == sm_fb.name assert sm_mod.commit() == sm_fb.commit # Create new branch which is in our past, and thus seemingly unrelated to the # currently checked out one. # To make it even 'harder', we shall fork and create a new commit. sm_pfb = sm_source_repo.create_head("past-feature", commit="HEAD~20") sm_pfb.checkout() sm_source_repo.index.add([touch(osp.join(sm_source_repo.working_tree_dir, "new-file"))]) sm_source_repo.index.commit("new file added, to past of '%r'" % sm_fb, skip_hooks=True) # Change designated submodule checkout branch to a new commit in its own past. with sm.config_writer() as smcw: smcw.set_value("branch", sm_pfb.path) sm.repo.index.commit("changed submodule branch to '%s'" % sm_pfb, skip_hooks=True) # Test submodule updates - must fail if submodule is dirty. touch(osp.join(sm_mod.working_tree_dir, "unstaged file")) # This doesn't fail as our own submodule binsha didn't change, and the reset is # only triggered if to_latest_revision is True. parent_repo.submodule_update(to_latest_revision=False) assert sm_mod.head.ref.name == sm_pfb.name, "should have been switched to past head" assert sm_mod.commit() == sm_fb.commit, "Head wasn't reset" self.assertRaises(RepositoryDirtyError, parent_repo.submodule_update, to_latest_revision=True) parent_repo.submodule_update(to_latest_revision=True, force_reset=True) assert sm_mod.commit() == sm_pfb.commit, "Now head should have been reset" assert sm_mod.head.ref.name == sm_pfb.name @skipUnless(sys.platform == "win32", "Specifically for Windows.") def test_to_relative_path_with_super_at_root_drive(self): class Repo: working_tree_dir = "D:\\" super_repo = Repo() submodule_path = "D:\\submodule_path" relative_path = Submodule._to_relative_path(super_repo, submodule_path) msg = '_to_relative_path should be "submodule_path" but was "%s"' % relative_path assert relative_path == "submodule_path", msg def test_to_relative_path_is_confined_to_parent(self): rwdir = self._fixture("path-confined", write=False) class Repo: working_tree_dir = str(rwdir / "parent") assert Submodule._to_relative_path(Repo(), osp.join(Repo.working_tree_dir, "nested")) == "nested" assert Submodule._to_relative_path(Repo(), osp.join("nested", "..", "module")) == "module" self.assertRaises(ValueError, Submodule._to_relative_path, Repo(), Repo.working_tree_dir) self.assertRaises(ValueError, Submodule._to_relative_path, Repo(), osp.join("..", "outside")) self.assertRaises( ValueError, Submodule._to_relative_path, Repo(), osp.join(Repo.working_tree_dir + "-other", "module"), ) def test_update_rejects_checkout_path_outside_parent(self): rwdir = self._fixture("empty") parent = git.Repo(rwdir / "parent") submodule = Submodule( parent, Submodule.NULL_BIN_SHA, name="module", path=osp.join("..", "outside"), url="unused", ) with mock.patch.object(Submodule, "_clone_repo", side_effect=AssertionError("clone attempted")): with pytest.raises(ValueError, match="is not in repository"): submodule.update(init=True) def test_update_rejects_checkout_path_through_symlink(self): rwdir = self._fixture("checkout-link", execute=True) parent = git.Repo(rwdir / "parent") submodule = Submodule( parent, Submodule.NULL_BIN_SHA, name="module", path=osp.join("link", "module"), url="unused", ) with mock.patch.object(Submodule, "_clone_repo", side_effect=AssertionError("clone attempted")): with pytest.raises(ValueError, match="contains a symbolic link"): submodule.update(init=True) def test_update_rejects_checkout_path_at_parent_root(self): rwdir = self._fixture("empty") parent = git.Repo(rwdir / "parent") submodule = Submodule(parent, Submodule.NULL_BIN_SHA, name="module", path=".", url="unused") with pytest.raises(ValueError, match="must not be the repository root"): submodule.update(init=True) @skipUnless(sys.platform == "win32", "Specifically for Windows.") def test_to_relative_path_windows_path_kinds(self): rwdir = self._fixture("empty", write=False) class Repo: working_tree_dir = str(rwdir / "parent") inside_path = osp.join(Repo.working_tree_dir, "nested", "module") _drive, rooted_inside_path = osp.splitdrive(inside_path) assert Submodule._to_relative_path(Repo(), rooted_inside_path) == "nested/module" assert Submodule._to_relative_path(Repo(), rooted_inside_path.replace("\\", "/")) == "nested/module" assert Submodule._to_relative_path(Repo(), PathLikeMock(inside_path)) == "nested/module" self.assertRaises( ValueError, Submodule._to_relative_path, Repo(), f"{osp.splitdrive(Repo.working_tree_dir)[0]}relative", ) self.assertRaises(ValueError, Submodule._to_relative_path, Repo(), R"Z:\outside") self.assertRaises(ValueError, Submodule._to_relative_path, Repo(), R"\\server\share\outside") self.assertRaises(ValueError, Submodule._to_relative_path, Repo(), R"\\?\C:\outside") @skipUnless(sys.platform == "win32", "Specifically for Windows.") def test_find_submodule_git_dir_with_drive_rooted_gitfile(self): rwdir = self._fixture("rooted-gitfile", write=False) with cwd(rwdir), git.Repo(rwdir / "module") as module: resolved_git_dir = find_submodule_git_dir(rwdir / "gitfile") assert resolved_git_dir is not None assert osp.samefile(resolved_git_dir, module.git_dir) @pytest.mark.xfail( reason="for some unknown reason the assertion fails, even though it in fact is working in more common setup", raises=AssertionError, ) def test_depth(self): rwdir = self._fixture("empty") parent = git.Repo(rwdir / "parent") sm_name = "mymodules/myname" sm_depth = 1 sm = parent.create_submodule(sm_name, sm_name, url=self._small_repo_url(), depth=sm_depth) self.assertEqual(len(list(sm.module().iter_commits())), sm_depth) @patch_git_config("protocol.file.allow", "always") def test_update_clone_multi_options_argument(self): rwdir = self._fixture("empty") parent = git.Repo(rwdir / "parent") sm_name = "foo" sm_url = self._small_repo_url() sm_branch = "refs/heads/master" sm_hexsha = git.Repo(self._small_repo_url()).head.commit.hexsha sm = Submodule( parent, bytes.fromhex(sm_hexsha), name=sm_name, path=sm_name, url=sm_url, branch_path=sm_branch, ) # Act sm.update(init=True, clone_multi_options=["--config core.eol=true"], allow_unsafe_options=True) # Assert sm_config = GitConfigParser(file_or_files=osp.join(parent.git_dir, "modules", sm_name, "config")) self.assertTrue(sm_config.get_value("core", "eol")) @patch_git_config("protocol.file.allow", "always") def test_update_no_clone_multi_options_argument(self): rwdir = self._fixture("empty") parent = git.Repo(rwdir / "parent") sm_name = "foo" sm_url = self._small_repo_url() sm_branch = "refs/heads/master" sm_hexsha = git.Repo(self._small_repo_url()).head.commit.hexsha sm = Submodule( parent, bytes.fromhex(sm_hexsha), name=sm_name, path=sm_name, url=sm_url, branch_path=sm_branch, ) # Act sm.update(init=True) # Assert sm_config = GitConfigParser(file_or_files=osp.join(parent.git_dir, "modules", sm_name, "config")) with self.assertRaises(cp.NoOptionError): sm_config.get_value("core", "eol") def test_add_clone_multi_options_argument(self): rwdir = self._fixture("empty") parent = git.Repo(rwdir / "parent") sm_name = "foo" # Act Submodule.add( parent, sm_name, sm_name, url=self._small_repo_url(), clone_multi_options=["--config core.eol=true"], allow_unsafe_options=True, ) # Assert sm_config = GitConfigParser(file_or_files=osp.join(parent.git_dir, "modules", sm_name, "config")) self.assertTrue(sm_config.get_value("core", "eol")) def test_add_no_clone_multi_options_argument(self): rwdir = self._fixture("empty") parent = git.Repo(rwdir / "parent") sm_name = "foo" # Act Submodule.add(parent, sm_name, sm_name, url=self._small_repo_url()) # Assert sm_config = GitConfigParser(file_or_files=osp.join(parent.git_dir, "modules", sm_name, "config")) with self.assertRaises(cp.NoOptionError): sm_config.get_value("core", "eol") def test_add_does_not_expand_environment_variables_in_url(self): rwdir = self._fixture("literal-url") parent = git.Repo(rwdir / "parent") url = str(rwdir / "$PATH_repo") sm = Submodule.add(parent, "new", "new", url) assert sm.url == Git.polish_url(url) @with_rw_repo("HEAD") def test_submodule_add_unsafe_url(self, rw_repo): with writable_fixture("empty.sh") as fixture: tmp_dir = fixture.path().resolve() tmp_file = tmp_dir / "pwn" urls = [ f"ext::sh -c touch% {tmp_file}", "fd::/foo", ] for url in urls: with self.assertRaises(UnsafeProtocolError): Submodule.add(rw_repo, "new", "new", url) assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_add_unsafe_url_allowed(self, rw_repo): with writable_fixture("empty.sh") as fixture: tmp_dir = fixture.path().resolve() tmp_file = tmp_dir / "pwn" urls = [ f"ext::sh -c touch% {tmp_file}", "fd::/foo", ] for url in urls: # The URL will be allowed into the command, but the command will fail # since we don't have that protocol enabled in the Git config file. with self.assertRaises(GitCommandError): Submodule.add(rw_repo, "new", "new", url, allow_unsafe_protocols=True) assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_add_unsafe_options(self, rw_repo): with writable_fixture("empty.sh") as fixture: tmp_dir = fixture.path().resolve() tmp_file = tmp_dir / "pwn" unsafe_options = [ f"--upload-pack='touch {tmp_file}'", f"-u 'touch {tmp_file}'", "--config=protocol.ext.allow=always", "-c protocol.ext.allow=always", ] for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): Submodule.add(rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option]) assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_add_unsafe_options_allowed(self, rw_repo): with writable_fixture("empty.sh") as fixture: tmp_dir = fixture.path().resolve() tmp_file = tmp_dir / "pwn" unsafe_options = [ f"--upload-pack='touch {tmp_file}'", f"-u 'touch {tmp_file}'", ] for unsafe_option in unsafe_options: # The options will be allowed, but the command will fail. with self.assertRaises(GitCommandError): Submodule.add( rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option], allow_unsafe_options=True, ) assert not tmp_file.exists() unsafe_options = [ "--config=protocol.ext.allow=always", "-c protocol.ext.allow=always", ] for unsafe_option in unsafe_options: with self.assertRaises(GitCommandError): Submodule.add( rw_repo, "new", "new", str(tmp_dir), clone_multi_options=[unsafe_option], allow_unsafe_options=True, ) @with_rw_repo("HEAD") def test_submodule_update_unsafe_url(self, rw_repo): with writable_fixture("empty.sh") as fixture: tmp_dir = fixture.path().resolve() tmp_file = tmp_dir / "pwn" urls = [ f"ext::sh -c touch% {tmp_file}", "fd::/foo", ] for url in urls: submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=url) with self.assertRaises(UnsafeProtocolError): submodule.update() assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_update_unsafe_url_allowed(self, rw_repo): with writable_fixture("empty.sh") as fixture: tmp_dir = fixture.path().resolve() tmp_file = tmp_dir / "pwn" urls = [ f"ext::sh -c touch% {tmp_file}", "fd::/foo", ] for url in urls: submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=url) # The URL will be allowed into the command, but the command will fail # since we don't have that protocol enabled in the Git config file. with self.assertRaises(GitCommandError): submodule.update(allow_unsafe_protocols=True) assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_update_unsafe_options(self, rw_repo): with writable_fixture("empty.sh") as fixture: tmp_dir = fixture.path().resolve() tmp_file = tmp_dir / "pwn" unsafe_options = [ f"--upload-pack='touch {tmp_file}'", f"-u 'touch {tmp_file}'", "--config=protocol.ext.allow=always", "-c protocol.ext.allow=always", ] submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): submodule.update(clone_multi_options=[unsafe_option]) assert not tmp_file.exists() @with_rw_repo("HEAD") def test_submodule_update_unsafe_options_are_checked_after_splitting_multi_options(self, rw_repo): with writable_fixture("empty.sh") as fixture: tmp_dir = fixture.path().resolve() payload = "--single-branch --config protocol.ext.allow=always" submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) with self.assertRaises(UnsafeOptionError): submodule.update(clone_multi_options=[payload]) assert not submodule.module_exists() @with_rw_repo("HEAD") def test_submodule_update_unsafe_options_allowed(self, rw_repo): with writable_fixture("empty.sh") as fixture: tmp_dir = fixture.path().resolve() tmp_file = tmp_dir / "pwn" unsafe_options = [ f"--upload-pack='touch {tmp_file}'", f"-u 'touch {tmp_file}'", ] submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) for unsafe_option in unsafe_options: # The options will be allowed, but the command will fail. with self.assertRaises(GitCommandError): submodule.update(clone_multi_options=[unsafe_option], allow_unsafe_options=True) assert not tmp_file.exists() unsafe_options = [ "--config=protocol.ext.allow=always", "-c protocol.ext.allow=always", ] submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) for unsafe_option in unsafe_options: with self.assertRaises(GitCommandError): submodule.update(clone_multi_options=[unsafe_option], allow_unsafe_options=True) @patch_git_config("protocol.file.allow", "always") def test_submodule_update_relative_url(self): rwdir = self._fixture("relative") cloned_repo = git.Repo(rwdir / "cloned_repo") cloned_repo.submodule_update(init=True, recursive=True) has_module = any(sm.name == "module" for sm in cloned_repo.submodules) assert has_module, "Relative submodule was not updated properly" def test_resolve_relative_url_like_git(self): # Expected values are what `git submodule init` records for the same pair. cases = [ ("ssh://host/org/super.git", "./sub", "ssh://host/org/super.git/sub"), ("ssh://host/org/super.git", "../sub", "ssh://host/org/sub"), ("ssh://host:22/org/super.git", "../sub", "ssh://host:22/org/sub"), ("git@host:org/super.git", "./sub", "git@host:org/super.git/sub"), ("git@host:org/super.git", "../sub", "git@host:org/sub"), ("git@host:org/super.git", "../../sub", "git@host:sub"), ("https://host/org/super.git", "./sub/", "https://host/org/super.git/sub"), ("https://host/org/super.git/", "././sub", "https://host/org/super.git/sub"), ("https://host/org/super.git", ".././sub", "https://host/org/sub"), ("https://host/org/super.git", "../../../sub", "https://sub"), ("file:///srv/git/super.git", "../sub", "file:///srv/git/sub"), ("/srv/git/super", "../sub", "/srv/git/sub"), ("/srv/git/super", "../../../../sub", "sub"), ("//share/super", "../../sub", "//sub"), ("foo/bar", "../sub", "foo/sub"), ("./foo", "../sub", "sub"), ("./super.git", "../", ""), ("ssh://host/org/super.git", "./", "ssh://host/org/super.git/"), ("ssh://host/org/super.git", "../", "ssh://host/org/"), ("../foo/bar", "../sub", "../foo/sub"), ("ssh://host/org/super.git", ".sub", ".sub"), ("ssh://host/org/super.git", "https://other/sub", "https://other/sub"), ] for remote_url, url, expected in cases: assert resolve_relative_url(remote_url, url) == expected, (remote_url, url) for remote_url, url in [("foo", "../../sub"), ("git@host:super.git", "../../../sub"), ("../foo", "../../sub")]: with self.assertRaisesRegex(ValueError, "cannot strip one component"): resolve_relative_url(remote_url, url) def test_update_resolves_relative_url_against_remote(self): with fixture_repo("submodule-relative.sh", "update-remote", write=True, subdir="parent") as parent: for url, expected in [ ("./planted", "ssh://git@host/org/super.git/planted"), ("../planted", "ssh://git@host/org/planted"), ]: submodule = Submodule(parent, Submodule.NULL_BIN_SHA, name="module", path="module", url=url) with mock.patch.object(git.Repo, "clone_from", side_effect=AssertionError("stop")) as clone_from: with pytest.raises(AssertionError, match="stop"): submodule.update(init=True) assert clone_from.call_args.args[0] == expected def test_clone_repo_resolves_relative_url_against_tracking_remote(self): with fixture_repo("submodule-relative.sh", "tracking-remote", write=True, subdir="parent") as parent: for remote_name in ("upstream", "team/upstream"): parent.create_remote(remote_name, "git@host:org/super.git") with parent.head.reference.config_writer() as writer: writer.set_value("remote", remote_name) writer.set_value("merge", "refs/heads/main") for url, expected in [ ("./planted", "git@host:org/super.git/planted"), ("../planted", "git@host:org/planted"), ]: with mock.patch.object(git.Repo, "clone_from", side_effect=AssertionError("stop")) as clone_from: with pytest.raises(AssertionError, match="stop"): Submodule._clone_repo(parent, url, "module", "module") assert clone_from.call_args.args[0] == expected

Back | FazBrowse Home | New Git URL