From e2655026ad0150c562dbbc4a11f4dbcaf86c4d33 Mon Sep 17 00:00:00 2001 From: Benjamin Bannier Date: Wed, 15 Jul 2026 13:00:21 +0200 Subject: [PATCH 01/11] Extract git version resolution into `_resolve_git_version` We consolidate version-tag lookup, branch detection, commit-hash recognition, checkout, and outdated-check into one function that returns a `GitResolution` dataclass. Call sites in `test()` and `_install()` are updated to use it. This prepares for upcoming non-Git package sources by making the resolution boundary explicit. --- testing/test_manager.py | 166 ++++++++++++++++++++++++++++++++++++++++ zeekpkg/manager.py | 103 ++++++++++++++++--------- 2 files changed, 231 insertions(+), 38 deletions(-) create mode 100644 testing/test_manager.py diff --git a/testing/test_manager.py b/testing/test_manager.py new file mode 100644 index 0000000..ace5763 --- /dev/null +++ b/testing/test_manager.py @@ -0,0 +1,166 @@ +"""Unit tests for zeekpkg.manager internals.""" + +import pathlib +from unittest.mock import patch + +import git +import pytest + +from zeekpkg.manager import GitResolution, Manager, _resolve_git_version +from zeekpkg.package import ( + TRACKING_METHOD_BRANCH, + TRACKING_METHOD_COMMIT, + TRACKING_METHOD_VERSION, +) + + +@pytest.fixture() +def manager(tmp_path: pathlib.Path) -> Manager: + return Manager( + state_dir=str(tmp_path / "state"), + script_dir=str(tmp_path / "scripts"), + plugin_dir=str(tmp_path / "plugins"), + ) + + +@pytest.fixture() +def repo(tmp_path: pathlib.Path) -> git.Repo: + """A minimal git repo with a single commit on 'main' and a self-remote. + + The self-pointing origin remote ensures _is_branch_outdated can resolve + origin/main without network access. + """ + r = git.Repo.init(tmp_path / "origin", initial_branch="main") + r.config_writer().set_value("user", "name", "Test").release() + r.config_writer().set_value("user", "email", "test@test").release() + (tmp_path / "origin" / "file.txt").write_text("hello") + r.index.add(["file.txt"]) + r.index.commit("initial commit") + return r.clone(str(tmp_path / "clone")) + + +def test_defaults_to_branch_when_no_tags(repo: git.Repo) -> None: + resolution = _resolve_git_version(repo, "") + assert isinstance(resolution, GitResolution) + assert resolution.tracking_method == TRACKING_METHOD_BRANCH + assert resolution.version == "main" + + +def test_defaults_to_latest_tag(repo: git.Repo) -> None: + repo.create_tag("v1.0.0") + repo.create_tag("v2.0.0") + resolution = _resolve_git_version(repo, "") + assert resolution.tracking_method == TRACKING_METHOD_VERSION + assert resolution.version == "v2.0.0" + + +def test_explicit_version_tag(repo: git.Repo) -> None: + repo.create_tag("v1.0.0") + repo.create_tag("v2.0.0") + resolution = _resolve_git_version(repo, "v1.0.0") + assert resolution.tracking_method == TRACKING_METHOD_VERSION + assert resolution.version == "v1.0.0" + + +def test_explicit_branch(repo: git.Repo) -> None: + # Create branch in origin and fetch so it's visible as origin/feature. + git.Repo(repo.remotes.origin.url).create_head("feature") + repo.remotes.origin.fetch() + resolution = _resolve_git_version(repo, "feature") + assert resolution.tracking_method == TRACKING_METHOD_BRANCH + assert resolution.version == "feature" + + +def test_explicit_commit_hash(repo: git.Repo) -> None: + hexsha = repo.head.object.hexsha + resolution = _resolve_git_version(repo, hexsha) + assert resolution.tracking_method == TRACKING_METHOD_COMMIT + assert resolution.current_hash == hexsha + + +def test_unknown_version_raises(repo: git.Repo) -> None: + with pytest.raises(ValueError, match="nonexistent"): + _resolve_git_version(repo, "nonexistent") + + +def test_resolution_captures_hash(repo: git.Repo) -> None: + resolution = _resolve_git_version(repo, "") + assert resolution.current_hash == repo.head.object.hexsha + + +def test_not_outdated_on_local_repo(repo: git.Repo) -> None: + resolution = _resolve_git_version(repo, "") + assert resolution.is_outdated is False + + +@pytest.fixture() +def pkg_dir(tmp_path: pathlib.Path) -> pathlib.Path: + """A minimal package directory with a zkg.meta containing version = 1.0.0.""" + d = tmp_path / "mypkg" + d.mkdir() + (d / "zkg.meta").write_text("[package]\ndescription = test\nversion = 1.0.0\n") + return d + + +@pytest.fixture() +def pkg_repo(tmp_path: pathlib.Path) -> git.Repo: + """A minimal package repo with a zkg.meta and a v1.0.0 tag.""" + r = git.Repo.init(tmp_path / "pkg", initial_branch="main") + r.config_writer().set_value("user", "name", "Test").release() + r.config_writer().set_value("user", "email", "test@test").release() + (tmp_path / "pkg" / "zkg.meta").write_text("[package]\ndescription = test\n") + r.index.add(["zkg.meta"]) + r.index.commit("init") + r.create_tag("v1.0.0") + return r + + +def test_install_git_unknown_version( + manager: Manager, + pkg_repo: git.Repo, +) -> None: + # Requesting a version tag that does not exist must fail. + result = manager.install(str(pkg_repo.working_dir), "v99.0.0") + assert result != "" + + +def test_install_git_missing_metadata(manager: Manager, repo: git.Repo) -> None: + # A Git repo with no zkg.meta must fail with an error. + result = manager.install(str(repo.working_dir)) + assert result != "" + + +def test_test_unknown_version(manager: Manager, pkg_repo: git.Repo) -> None: + # manager.test() on a package with a non-existent version must return an error. + error, passed, _ = manager.test(str(pkg_repo.working_dir), version="v99.0.0") + assert not passed + assert error != "" + + +@pytest.fixture() +def pkg_repo_with_test_command(tmp_path: pathlib.Path) -> git.Repo: + """A minimal package repo with test_command in zkg.meta and a v1.0.0 tag.""" + r = git.Repo.init(tmp_path / "pkg", initial_branch="main") + r.config_writer().set_value("user", "name", "Test").release() + r.config_writer().set_value("user", "email", "test@test").release() + (tmp_path / "pkg" / "zkg.meta").write_text( + "[package]\ndescription = test\ntest_command = exit 0\n", + ) + r.index.add(["zkg.meta"]) + r.index.commit("init") + r.create_tag("v1.0.0") + return r + + +def test_test_resolve_version_error( + manager: Manager, + pkg_repo_with_test_command: git.Repo, +) -> None: + # When _resolve_git_version raises, manager.test() must return an error. + with patch( + "zeekpkg.manager._resolve_git_version", + side_effect=ValueError("bad version"), + ): + error, passed, _ = manager.test(str(pkg_repo_with_test_command.working_dir)) + assert not passed + assert "bad version" in error diff --git a/zeekpkg/manager.py b/zeekpkg/manager.py index a11cd8c..ceea9c9 100644 --- a/zeekpkg/manager.py +++ b/zeekpkg/manager.py @@ -16,6 +16,7 @@ import tarfile import time from collections import deque +from dataclasses import dataclass from urllib.parse import urlparse import git @@ -2712,16 +2713,13 @@ def test( ) try: - git_checkout(clone, version) - except git.GitCommandError as error: - LOG.warning("failed to checkout git repo version: %s", error) + resolution = _resolve_git_version(clone, version) + except Exception as error: + LOG.warning("failed to resolve git version: %s", error) assert stage.state_dir - return ( - f"failed to checkout {version} of {info.package.git_url}", - False, - stage.state_dir, - ) + return (str(error), False, stage.state_dir) + version = resolution.version fail_msg = self._stage(info.package, version, clone, stage, env) if fail_msg: @@ -3160,37 +3158,16 @@ def _install( status.is_loaded = ipkg.status.is_loaded if ipkg else False status.is_pinned = ipkg.status.is_pinned if ipkg else False - version_tags = git_version_tags(clone) - - if version: - if _is_commit_hash(clone, version): - status.tracking_method = TRACKING_METHOD_COMMIT - elif version in version_tags: - status.tracking_method = TRACKING_METHOD_VERSION - else: - branches = _get_branch_names(clone) - - if version in branches: - status.tracking_method = TRACKING_METHOD_BRANCH - else: - LOG.info( - 'branch "%s" not in available branches: %s', - version, - branches, - ) - return f'no such branch or version tag: "{version}"' - - elif len(version_tags): - version = version_tags[-1] - status.tracking_method = TRACKING_METHOD_VERSION - else: - version = git_default_branch(clone) - status.tracking_method = TRACKING_METHOD_BRANCH + try: + resolution = _resolve_git_version(clone, version) + except Exception as e: + return str(e) - status.current_version = version - git_checkout(clone, version) - status.current_hash = clone.head.object.hexsha - status.is_outdated = _is_clone_outdated(clone, version, status.tracking_method) + version = resolution.version + status.tracking_method = resolution.tracking_method + status.current_version = resolution.version + status.current_hash = resolution.current_hash + status.is_outdated = resolution.is_outdated metadata_file = _pick_metadata_file(str(clone.working_dir)) metadata_parser = configparser.ConfigParser(interpolation=None) @@ -3300,6 +3277,56 @@ def _clear_bin_dir(self, bin_dir: str) -> None: LOG.warning("failed to remove link %s", old) +@dataclass +class GitResolution: + """The resolved Git state for a package after checkout.""" + + version: str + tracking_method: str + current_hash: str + is_outdated: bool + + +def _resolve_git_version(clone: git.Repo, version: str) -> GitResolution: + """Resolve *version* against *clone*, check out the ref, and return a + :class:`GitResolution`. + + Raises: + ValueError: if *version* does not match any tag, branch, or commit. + git.GitCommandError: if the checkout fails. + """ + version_tags = git_version_tags(clone) + + if version: + if _is_commit_hash(clone, version): + tracking_method = TRACKING_METHOD_COMMIT + elif version in version_tags: + tracking_method = TRACKING_METHOD_VERSION + else: + branches = _get_branch_names(clone) + if version in branches: + tracking_method = TRACKING_METHOD_BRANCH + else: + LOG.info( + 'branch "%s" not in available branches: %s', + version, + branches, + ) + raise ValueError(f'no such branch or version tag: "{version}"') + elif version_tags: + version = version_tags[-1] + tracking_method = TRACKING_METHOD_VERSION + else: + version = git_default_branch(clone) + tracking_method = TRACKING_METHOD_BRANCH + + git_checkout(clone, version) + current_hash = clone.head.object.hexsha + is_outdated = _is_clone_outdated(clone, version, tracking_method) + + return GitResolution(version, tracking_method, current_hash, is_outdated) + + def _get_branch_names(clone: git.Repo) -> list[str]: rval = [] From c617ebffe32c3f919ded48d61d79de186a803ef2 Mon Sep 17 00:00:00 2001 From: Benjamin Bannier Date: Thu, 16 Jul 2026 16:41:38 +0200 Subject: [PATCH 02/11] Fail fast by running unit tests before the BTest suite Unit tests are cheap and catch logic errors quickly; BTest integration tests are slow. Running them in this order surfaces most failures without waiting for the full end-to-end suite. --- testing/conftest.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/testing/conftest.py b/testing/conftest.py index ee4d89d..9ff4030 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,7 +1,9 @@ -"""Global fixture for setting up coverage for launched subprocesses.""" +"""Configures pytest runtime behavior for this test suite.""" import os +import pytest + # Point coverage in spawned processes to global coverage configuration and state file. _project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) os.environ.setdefault( @@ -9,3 +11,10 @@ os.path.join(_project_root, ".coveragerc"), ) os.environ.setdefault("COVERAGE_FILE", os.path.join(_project_root, ".coverage")) + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + # Run slow BTest integration tests last so we get fast feedback from unit tests. + unit_tests = [i for i in items if i.fspath.basename != "test_btest.py"] + btest_tests = [i for i in items if i.fspath.basename == "test_btest.py"] + items[:] = unit_tests + btest_tests From c29485076f6eb208e60ebec3df1c56f051649bf7 Mon Sep 17 00:00:00 2001 From: Benjamin Bannier Date: Fri, 17 Jul 2026 08:00:01 +0200 Subject: [PATCH 03/11] Split `_resolve_git_version` into selection and resolution steps `_pick_version` selects the version string and tracking method from the clone's available refs; `_resolve_git_version` reads the resulting HEAD state after checkout. Callers are now responsible for checking out the desired ref before calling `_resolve_git_version`. This prepares for upcoming directory-backed package support, where version selection happens outside the snapshot abstraction. --- testing/test_manager.py | 1 + zeekpkg/manager.py | 60 ++++++++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/testing/test_manager.py b/testing/test_manager.py index ace5763..62cc616 100644 --- a/testing/test_manager.py +++ b/testing/test_manager.py @@ -66,6 +66,7 @@ def test_explicit_branch(repo: git.Repo) -> None: # Create branch in origin and fetch so it's visible as origin/feature. git.Repo(repo.remotes.origin.url).create_head("feature") repo.remotes.origin.fetch() + repo.git.checkout("feature") resolution = _resolve_git_version(repo, "feature") assert resolution.tracking_method == TRACKING_METHOD_BRANCH assert resolution.version == "feature" diff --git a/zeekpkg/manager.py b/zeekpkg/manager.py index ceea9c9..5eaf93e 100644 --- a/zeekpkg/manager.py +++ b/zeekpkg/manager.py @@ -2713,6 +2713,7 @@ def test( ) try: + git_checkout(clone, version) resolution = _resolve_git_version(clone, version) except Exception as error: LOG.warning("failed to resolve git version: %s", error) @@ -3159,6 +3160,7 @@ def _install( status.is_pinned = ipkg.status.is_pinned if ipkg else False try: + git_checkout(clone, version) resolution = _resolve_git_version(clone, version) except Exception as e: return str(e) @@ -3287,40 +3289,48 @@ class GitResolution: is_outdated: bool -def _resolve_git_version(clone: git.Repo, version: str) -> GitResolution: - """Resolve *version* against *clone*, check out the ref, and return a - :class:`GitResolution`. +def _pick_version(clone: git.Repo, version: str | None) -> tuple[str, str]: + """Return (resolved_version, tracking_method) for *version* in *clone*. + + When *version* is ``None`` or empty, the best available version is chosen + automatically: the latest semver tag if any exist, otherwise the default + branch. Raises: ValueError: if *version* does not match any tag, branch, or commit. - git.GitCommandError: if the checkout fails. """ version_tags = git_version_tags(clone) if version: if _is_commit_hash(clone, version): - tracking_method = TRACKING_METHOD_COMMIT - elif version in version_tags: - tracking_method = TRACKING_METHOD_VERSION - else: - branches = _get_branch_names(clone) - if version in branches: - tracking_method = TRACKING_METHOD_BRANCH - else: - LOG.info( - 'branch "%s" not in available branches: %s', - version, - branches, - ) - raise ValueError(f'no such branch or version tag: "{version}"') - elif version_tags: - version = version_tags[-1] - tracking_method = TRACKING_METHOD_VERSION - else: - version = git_default_branch(clone) - tracking_method = TRACKING_METHOD_BRANCH + return version, TRACKING_METHOD_COMMIT + if version in version_tags: + return version, TRACKING_METHOD_VERSION + branches = _get_branch_names(clone) + if version in branches: + return version, TRACKING_METHOD_BRANCH + LOG.info( + 'branch "%s" not in available branches: %s', + version, + branches, + ) + raise ValueError(f'no such branch or version tag: "{version}"') - git_checkout(clone, version) + if version_tags: + return version_tags[-1], TRACKING_METHOD_VERSION + return git_default_branch(clone), TRACKING_METHOD_BRANCH + + +def _resolve_git_version(clone: git.Repo, version: str | None) -> GitResolution: + """Read the current HEAD of *clone* and return a :class:`GitResolution`. + + The caller must check out the desired ref before calling this function; + the hash and outdated flag reflect the current HEAD state of *clone*. + + Raises: + ValueError: if *version* does not match any tag, branch, or commit. + """ + version, tracking_method = _pick_version(clone, version) current_hash = clone.head.object.hexsha is_outdated = _is_clone_outdated(clone, version, tracking_method) From 3311b612d65a0e9628739065ac3c272c1be43a4e Mon Sep 17 00:00:00 2001 From: Benjamin Bannier Date: Fri, 17 Jul 2026 08:02:06 +0200 Subject: [PATCH 04/11] Introduce `PackageSnapshot` and snapshot constructors Decouple package-info gathering from Git operations so Git and directory sources can be handled uniformly by downstream install, test, and info paths. --- testing/test_manager.py | 122 +++++++++++++++++++++++- zeekpkg/manager.py | 200 +++++++++++++++++++++++++++++----------- zeekpkg/package.py | 29 ++++++ 3 files changed, 297 insertions(+), 54 deletions(-) diff --git a/testing/test_manager.py b/testing/test_manager.py index 62cc616..3eda0d9 100644 --- a/testing/test_manager.py +++ b/testing/test_manager.py @@ -6,11 +6,24 @@ import git import pytest -from zeekpkg.manager import GitResolution, Manager, _resolve_git_version +from zeekpkg.manager import ( + GitResolution, + Manager, + _info_from_clone, + _is_git_package, + _resolve_git_version, + _snapshot_from_git_repo, +) from zeekpkg.package import ( TRACKING_METHOD_BRANCH, + TRACKING_METHOD_BUILTIN, TRACKING_METHOD_COMMIT, TRACKING_METHOD_VERSION, + InstalledPackage, + Package, + PackageInfo, + PackageSnapshot, + PackageStatus, ) @@ -165,3 +178,110 @@ def test_test_resolve_version_error( error, passed, _ = manager.test(str(pkg_repo_with_test_command.working_dir)) assert not passed assert "bad version" in error + + +def test__snapshot_from_git_repo(repo: git.Repo) -> None: + meta_file = pathlib.Path(repo.working_dir) / "zkg.meta" + meta_file.write_text("[package]\ndescription = test pkg\n") + repo.index.add(["zkg.meta"]) + repo.index.commit("add zkg.meta") + resolution = _resolve_git_version(repo, None) + snapshot = _snapshot_from_git_repo(repo, resolution) + assert isinstance(snapshot, PackageSnapshot) + assert snapshot.meta["description"] == "test pkg" + assert snapshot.version == resolution.version + assert snapshot.tracking_method == resolution.tracking_method + assert snapshot.current_hash == resolution.current_hash + assert snapshot.working_dir == repo.working_dir + + +def test__snapshot_from_git_repo_missing_metadata_raises(repo: git.Repo) -> None: + resolution = _resolve_git_version(repo, None) + with pytest.raises(ValueError, match="missing"): + _snapshot_from_git_repo(repo, resolution) + + +def _make_installed( + manager: Manager, + name: str, + is_loaded: bool = False, + is_pinned: bool = False, + is_outdated: bool = False, + tracking_method: str | None = None, + current_version: str | None = None, + current_hash: str | None = None, +) -> None: + """Register a fake installed package in *manager*.""" + pkg = Package(git_url=f"https://example.com/{name}", name=name, canonical=True) + status = PackageStatus( + is_loaded=is_loaded, + is_pinned=is_pinned, + is_outdated=is_outdated, + tracking_method=tracking_method, + current_version=current_version, + current_hash=current_hash, + ) + manager.installed_pkgs[name] = InstalledPackage(pkg, status) + + +@pytest.mark.parametrize( + "method,expected", + [ + (TRACKING_METHOD_VERSION, True), + (TRACKING_METHOD_BRANCH, True), + (TRACKING_METHOD_COMMIT, True), + (TRACKING_METHOD_BUILTIN, False), + (None, False), + ], +) +def test_is_git_package(method: str | None, expected: bool) -> None: + assert _is_git_package(PackageStatus(tracking_method=method)) is expected + + +def test_info_from_clone(repo: git.Repo) -> None: + # `_info_from_clone` must propagate metadata, versions, default branch, + # and `version_type` from the snapshot and its arguments into `PackageInfo`. + meta_file = pathlib.Path(repo.working_dir) / "zkg.meta" + meta_file.write_text("[package]\ndescription = hello\n") + repo.index.add(["zkg.meta"]) + repo.index.commit("add meta") + repo.create_tag("v1.0.0") + + resolution = _resolve_git_version(repo, "v1.0.0") + snapshot = _snapshot_from_git_repo(repo, resolution) + package = Package(git_url=str(repo.working_dir), canonical=True) + info = _info_from_clone( + snapshot, + package, + status=None, + versions=["v1.0.0"], + default_branch="main", + version_type=TRACKING_METHOD_VERSION, + ) + assert isinstance(info, PackageInfo) + assert info.metadata["description"] == "hello" + assert info.versions == ["v1.0.0"] + assert info.default_branch == "main" + assert info.version_type == TRACKING_METHOD_VERSION + assert info.invalid_reason == "" + + +def test_info_installed_missing_metadata(manager: Manager) -> None: + # An installed package with no zkg.meta must be reported as invalid. + pkg_name = "mypkg" + pkg_dir = pathlib.Path(manager.package_clonedir) / pkg_name + r = git.Repo.init(pkg_dir, initial_branch="main") + r.config_writer().set_value("user", "name", "Test").release() + r.config_writer().set_value("user", "email", "test@test").release() + (pkg_dir / "file.txt").write_text("hi") + r.index.add(["file.txt"]) + r.index.commit("init") + + _make_installed( + manager, + pkg_name, + tracking_method=TRACKING_METHOD_BRANCH, + current_version="main", + ) + info = manager.info(f"https://example.com/{pkg_name}", prefer_installed=True) + assert info.invalid_reason != "" diff --git a/zeekpkg/manager.py b/zeekpkg/manager.py index 5eaf93e..9a88b10 100644 --- a/zeekpkg/manager.py +++ b/zeekpkg/manager.py @@ -57,10 +57,12 @@ PLUGIN_MAGIC_FILE_DISABLED, TRACKING_METHOD_BRANCH, TRACKING_METHOD_COMMIT, + TRACKING_METHOD_DIRECTORY, TRACKING_METHOD_VERSION, InstalledPackage, Package, PackageInfo, + PackageSnapshot, PackageStatus, PackageVersion, aliases, @@ -1896,14 +1898,25 @@ def info( ipkg = self.find_installed_package(pkg_path) if prefer_installed and ipkg: - pkg_name = ipkg.package.name - clonepath = os.path.join(self.package_clonedir, pkg_name) - clone = git.Repo(clonepath) + clone = git.Repo(os.path.join(self.package_clonedir, ipkg.package.name)) + versions = git_version_tags(clone) + default_branch = git_default_branch(clone) + try: + resolution = _resolve_git_version(clone, ipkg.status.current_version) + snapshot = _snapshot_from_git_repo(clone, resolution) + except ValueError as e: + return PackageInfo( + package=ipkg.package, + invalid_reason=str(e), + status=ipkg.status, + ) return _info_from_clone( - clone, + snapshot, ipkg.package, ipkg.status, - ipkg.status.current_version, + versions, + default_branch, + resolution.tracking_method, ) matches = self.match_source_packages(pkg_path) @@ -1967,23 +1980,53 @@ def _info( git.GitCommandError: when failing to clone the package repo """ clonepath = os.path.join(self.scratch_dir, package.name) - clone = _clone_package(package, clonepath, version, update_submodules) - versions = git_version_tags(clone) - if not version: - if len(versions): - version = versions[-1] - else: - version = git_default_branch(clone) + if not _is_directory_package(package.git_url): + clone = _clone_package(package, clonepath, version, update_submodules) + versions = git_version_tags(clone) + default_branch = git_default_branch(clone) + + if not version: + version = versions[-1] if versions else default_branch + + try: + git_checkout(clone, version, update_submodules) + resolution = _resolve_git_version(clone, version) + snapshot = _snapshot_from_git_repo(clone, resolution) + except (ValueError, git.GitCommandError) as e: + return PackageInfo( + package=package, + status=status, + invalid_reason=str(e), + ) + + LOG.debug( + 'checked out "%s", branch/version "%s"', + package, + resolution.version, + ) + return _info_from_clone( + snapshot, + package, + status, + versions, + default_branch, + resolution.tracking_method, + ) try: - git_checkout(clone, version, update_submodules) - except git.GitCommandError: - reason = f'no such commit, branch, or version tag: "{version}"' - return PackageInfo(package=package, status=status, invalid_reason=reason) + snapshot = _snapshot_from_directory(package.git_url) + except ValueError as e: + return PackageInfo(package=package, status=status, invalid_reason=str(e)) - LOG.debug('checked out "%s", branch/version "%s"', package, version) - return _info_from_clone(clone, package, status, version) + return _info_from_clone( + snapshot, + package, + status, + versions=[], + default_branch="", + version_type=TRACKING_METHOD_DIRECTORY, + ) def package_versions(self, installed_package: InstalledPackage) -> list[str]: """Returns a list of version number tags available for a package. @@ -3337,6 +3380,64 @@ def _resolve_git_version(clone: git.Repo, version: str | None) -> GitResolution: return GitResolution(version, tracking_method, current_hash, is_outdated) +def _snapshot_from_git_repo( + clone: git.Repo, + resolution: GitResolution, +) -> PackageSnapshot: + """Construct a :class:`.package.PackageSnapshot` from an already-resolved + git clone. + + The clone must already be checked out at the desired ref (i.e. + :func:`_resolve_git_version` has been called). Metadata is read once and + stored in the snapshot; no git objects are needed after this point. + + Raises: + ValueError: if the metadata file is missing or invalid. + """ + metadata_file = _pick_metadata_file(str(clone.working_dir)) + metadata_parser = configparser.ConfigParser(interpolation=None) + invalid_reason = _parse_package_metadata(metadata_parser, metadata_file) + if invalid_reason: + raise ValueError(invalid_reason) + return PackageSnapshot( + working_dir=str(clone.working_dir), + meta=_get_package_metadata(metadata_parser), + version=resolution.version, + tracking_method=resolution.tracking_method, + current_hash=resolution.current_hash, + is_outdated=resolution.is_outdated, + ) + + +def _snapshot_from_directory(path: str) -> PackageSnapshot: + """Construct a :class:`.package.PackageSnapshot` from a plain directory. + + The ``version`` field in ``zkg.meta`` is mandatory since there is no + version control history to derive it from. + + Raises: + ValueError: if the metadata file is missing, invalid, or has no + ``version`` field. + """ + metadata_file = _pick_metadata_file(path) + metadata_parser = configparser.ConfigParser(interpolation=None) + invalid_reason = _parse_package_metadata(metadata_parser, metadata_file) + if invalid_reason: + raise ValueError(invalid_reason) + meta = _get_package_metadata(metadata_parser) + version = meta.get("version") + if not version: + raise ValueError( + "zkg.meta is missing a required 'version' field for directory-backed packages", + ) + return PackageSnapshot( + working_dir=path, + meta=meta, + version=version, + tracking_method=TRACKING_METHOD_DIRECTORY, + ) + + def _get_branch_names(clone: git.Repo) -> list[str]: rval = [] @@ -3351,6 +3452,24 @@ def _get_branch_names(clone: git.Repo) -> list[str]: return rval +def _is_directory_package(path: str) -> bool: + """Return True if *path* should be handled as a directory-backed package.""" + return os.path.isdir(path) and not os.path.isdir(os.path.join(path, ".git")) + + +def _is_git_package(status: PackageStatus) -> bool: + """Return True if *status* represents a git-backed package. + + Non-git package sources (e.g. local directories) will use a different + tracking method; this predicate guards operations that require a git clone. + """ + return status.tracking_method in ( + TRACKING_METHOD_VERSION, + TRACKING_METHOD_BRANCH, + TRACKING_METHOD_COMMIT, + ) + + def _is_version_outdated(clone: git.Repo, version: str) -> bool: version_tags = git_version_tags(clone) latest = normalize_version_tag(version_tags[-1]) @@ -3521,41 +3640,19 @@ def _parse_package_metadata( def _info_from_clone( - clone: git.Repo, + snapshot: PackageSnapshot, package: Package, status: PackageStatus | None, - version: str | None, + versions: list[str], + default_branch: str, + version_type: str, ) -> PackageInfo: - """Retrieves information about a package. + """Build a :class:`.package.PackageInfo` from a :class:`.package.PackageSnapshot`. - Returns: - A :class:`.package.PackageInfo` object. + All git-specific resolution (version tags, default branch, version type) + must be performed by the caller before constructing the snapshot. """ - versions = git_version_tags(clone) - default_branch = git_default_branch(clone) - - if version and _is_commit_hash(clone, version): - version_type = TRACKING_METHOD_COMMIT - elif version in versions: - version_type = TRACKING_METHOD_VERSION - else: - version_type = TRACKING_METHOD_BRANCH - - metadata_file = _pick_metadata_file(str(clone.working_dir)) - metadata_parser = configparser.ConfigParser(interpolation=None) - invalid_reason = _parse_package_metadata(metadata_parser, metadata_file) - - if invalid_reason: - return PackageInfo( - package=package, - invalid_reason=invalid_reason, - status=status, - versions=versions, - metadata_version=version, - version_type=version_type, - metadata_file=metadata_file, - default_branch=default_branch, - ) + metadata_file = _pick_metadata_file(snapshot.working_dir) if ( os.path.basename(metadata_file) == LEGACY_METADATA_FILENAME @@ -3570,15 +3667,12 @@ def _info_from_clone( ) _legacy_metadata_warnings.add(package.qualified_name()) - metadata = _get_package_metadata(metadata_parser) - return PackageInfo( package=package, - invalid_reason=invalid_reason, status=status, - metadata=metadata, + metadata=snapshot.meta, versions=versions, - metadata_version=version, + metadata_version=snapshot.version, version_type=version_type, metadata_file=metadata_file, default_branch=default_branch, diff --git a/zeekpkg/package.py b/zeekpkg/package.py index ab2c570..b929080 100644 --- a/zeekpkg/package.py +++ b/zeekpkg/package.py @@ -5,6 +5,7 @@ import os import re +from dataclasses import dataclass, field from functools import total_ordering import semantic_version as semver @@ -20,6 +21,7 @@ TRACKING_METHOD_BRANCH = "branch" TRACKING_METHOD_COMMIT = "commit" TRACKING_METHOD_BUILTIN = "builtin" +TRACKING_METHOD_DIRECTORY = "directory" BUILTIN_SOURCE = "zeek-builtin" BUILTIN_SCHEME = "zeek-builtin://" @@ -157,6 +159,33 @@ def dependencies( return rval +@dataclass +class PackageSnapshot: + """A fixed, immutable view of a package at the point it enters processing. + + All source-specific resolution must be completed before constructing this + object. Downstream processing operates solely on these fields. + + Attributes: + working_dir: path to the package directory + meta: parsed contents of the ``zkg.meta`` file + version: resolved version string, or ``None`` if unavailable + tracking_method: how the package version is tracked (e.g. + ``TRACKING_METHOD_VERSION``); ``None`` if not yet determined + current_hash: Git commit hash at the time of snapshot, or ``None`` + for non-Git sources + is_outdated: whether a newer version is available; always ``False`` + for non-Git sources + """ + + working_dir: str + meta: dict[str, str] = field(default_factory=dict) + version: str | None = None + tracking_method: str | None = None + current_hash: str | None = None + is_outdated: bool = False + + class PackageVersion: """ Helper class to compare package versions with version specs. From e83d4ab437b10d8a02a08d8b081e1ce153c891e1 Mon Sep 17 00:00:00 2001 From: Benjamin Bannier Date: Fri, 17 Jul 2026 08:02:30 +0200 Subject: [PATCH 05/11] Rename `_info_from_clone` to `_info_from_snapshot` The function now takes a `PackageSnapshot` rather than a `git.Repo`; the new name reflects that. --- testing/test_manager.py | 8 ++++---- zeekpkg/manager.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/testing/test_manager.py b/testing/test_manager.py index 3eda0d9..289c3c5 100644 --- a/testing/test_manager.py +++ b/testing/test_manager.py @@ -9,7 +9,7 @@ from zeekpkg.manager import ( GitResolution, Manager, - _info_from_clone, + _info_from_snapshot, _is_git_package, _resolve_git_version, _snapshot_from_git_repo, @@ -238,8 +238,8 @@ def test_is_git_package(method: str | None, expected: bool) -> None: assert _is_git_package(PackageStatus(tracking_method=method)) is expected -def test_info_from_clone(repo: git.Repo) -> None: - # `_info_from_clone` must propagate metadata, versions, default branch, +def test_info_from_snapshot(repo: git.Repo) -> None: + # `_info_from_snapshot` must propagate metadata, versions, default branch, # and `version_type` from the snapshot and its arguments into `PackageInfo`. meta_file = pathlib.Path(repo.working_dir) / "zkg.meta" meta_file.write_text("[package]\ndescription = hello\n") @@ -250,7 +250,7 @@ def test_info_from_clone(repo: git.Repo) -> None: resolution = _resolve_git_version(repo, "v1.0.0") snapshot = _snapshot_from_git_repo(repo, resolution) package = Package(git_url=str(repo.working_dir), canonical=True) - info = _info_from_clone( + info = _info_from_snapshot( snapshot, package, status=None, diff --git a/zeekpkg/manager.py b/zeekpkg/manager.py index 9a88b10..3a36983 100644 --- a/zeekpkg/manager.py +++ b/zeekpkg/manager.py @@ -1910,7 +1910,7 @@ def info( invalid_reason=str(e), status=ipkg.status, ) - return _info_from_clone( + return _info_from_snapshot( snapshot, ipkg.package, ipkg.status, @@ -2005,7 +2005,7 @@ def _info( package, resolution.version, ) - return _info_from_clone( + return _info_from_snapshot( snapshot, package, status, @@ -2019,7 +2019,7 @@ def _info( except ValueError as e: return PackageInfo(package=package, status=status, invalid_reason=str(e)) - return _info_from_clone( + return _info_from_snapshot( snapshot, package, status, @@ -3639,7 +3639,7 @@ def _parse_package_metadata( _legacy_metadata_warnings: set[str] = set() -def _info_from_clone( +def _info_from_snapshot( snapshot: PackageSnapshot, package: Package, status: PackageStatus | None, From f67d51d41b223cef26c4955fce14367bcd2df37d Mon Sep 17 00:00:00 2001 From: Benjamin Bannier Date: Thu, 16 Jul 2026 15:48:31 +0200 Subject: [PATCH 06/11] Support directory-backed packages in package operations Extend install, test, info, refresh, and upgrade to support directory-backed packages alongside Git-backed ones, using the snapshot abstraction from the previous commit. --- testing/test_manager.py | 171 ++++++++++++++++++++++++++++++++++++++++ zeekpkg/manager.py | 155 +++++++++++++++++++----------------- 2 files changed, 254 insertions(+), 72 deletions(-) diff --git a/testing/test_manager.py b/testing/test_manager.py index 289c3c5..c076848 100644 --- a/testing/test_manager.py +++ b/testing/test_manager.py @@ -10,14 +10,18 @@ GitResolution, Manager, _info_from_snapshot, + _is_directory_package, _is_git_package, + _prepare_snapshot, _resolve_git_version, + _snapshot_from_directory, _snapshot_from_git_repo, ) from zeekpkg.package import ( TRACKING_METHOD_BRANCH, TRACKING_METHOD_BUILTIN, TRACKING_METHOD_COMMIT, + TRACKING_METHOD_DIRECTORY, TRACKING_METHOD_VERSION, InstalledPackage, Package, @@ -231,6 +235,7 @@ def _make_installed( (TRACKING_METHOD_BRANCH, True), (TRACKING_METHOD_COMMIT, True), (TRACKING_METHOD_BUILTIN, False), + (TRACKING_METHOD_DIRECTORY, False), (None, False), ], ) @@ -238,6 +243,93 @@ def test_is_git_package(method: str | None, expected: bool) -> None: assert _is_git_package(PackageStatus(tracking_method=method)) is expected +def test_snapshot_from_directory(pkg_dir: pathlib.Path) -> None: + snapshot = _snapshot_from_directory(str(pkg_dir)) + assert isinstance(snapshot, PackageSnapshot) + assert snapshot.version == "1.0.0" + assert snapshot.meta["description"] == "test" + assert snapshot.working_dir == str(pkg_dir) + + +def test_snapshot_from_directory_missing_metadata_raises( + tmp_path: pathlib.Path, +) -> None: + with pytest.raises(ValueError, match="missing"): + _snapshot_from_directory(str(tmp_path)) + + +def test_snapshot_from_directory_missing_version_raises(tmp_path: pathlib.Path) -> None: + (tmp_path / "zkg.meta").write_text("[package]\ndescription = test\n") + with pytest.raises(ValueError, match="version"): + _snapshot_from_directory(str(tmp_path)) + + +def test_is_directory_package(pkg_dir: pathlib.Path, repo: git.Repo) -> None: + # A plain directory without .git is a directory package; a git repo is not. + assert _is_directory_package(str(pkg_dir)) is True + assert _is_directory_package(str(repo.working_dir)) is False + + +def test_prepare_snapshot_directory( + pkg_dir: pathlib.Path, + tmp_path: pathlib.Path, +) -> None: + # _prepare_snapshot on a plain directory returns a directory-backed snapshot. + package = Package(git_url=str(pkg_dir), canonical=True) + snapshot = _prepare_snapshot(package, None, str(tmp_path / "dest")) + assert snapshot.version == "1.0.0" + assert snapshot.tracking_method == TRACKING_METHOD_DIRECTORY + assert snapshot.current_hash is None + assert snapshot.is_outdated is False + + +def test_prepare_snapshot_git(repo: git.Repo, tmp_path: pathlib.Path) -> None: + # _prepare_snapshot on a Git repo resolves version and tracking method. + meta_file = pathlib.Path(repo.working_dir) / "zkg.meta" + meta_file.write_text("[package]\ndescription = test\n") + repo.index.add(["zkg.meta"]) + repo.index.commit("add meta") + # Point at the clone directly, passing it as existing_clone to avoid a + # network clone. + package = Package(git_url=str(repo.working_dir), canonical=True) + snapshot = _prepare_snapshot( + package, + None, + str(tmp_path / "dest"), + existing_clone=repo, + ) + assert snapshot.tracking_method == TRACKING_METHOD_BRANCH + assert snapshot.current_hash is not None + + +def test_info_directory_backend(manager: Manager, pkg_dir: pathlib.Path) -> None: + # manager.info() on a plain directory must return valid package info. + info = manager.info(str(pkg_dir)) + assert info.invalid_reason == "" + assert info.metadata_version == "1.0.0" + assert info.version_type == TRACKING_METHOD_DIRECTORY + + +def test_install_directory_backend(manager: Manager, pkg_dir: pathlib.Path) -> None: + result = manager.install(str(pkg_dir)) + assert result == "" + ipkg = manager.find_installed_package("mypkg") + assert ipkg is not None + assert ipkg.status.tracking_method == TRACKING_METHOD_DIRECTORY + assert ipkg.status.current_version == "1.0.0" + + +def test_install_directory_missing_metadata( + manager: Manager, + tmp_path: pathlib.Path, +) -> None: + # A plain directory with no zkg.meta must fail with an error. + pkg_dir = tmp_path / "mypkg" + pkg_dir.mkdir() + result = manager.install(str(pkg_dir)) + assert result != "" + + def test_info_from_snapshot(repo: git.Repo) -> None: # `_info_from_snapshot` must propagate metadata, versions, default branch, # and `version_type` from the snapshot and its arguments into `PackageInfo`. @@ -285,3 +377,82 @@ def test_info_installed_missing_metadata(manager: Manager) -> None: ) info = manager.info(f"https://example.com/{pkg_name}", prefer_installed=True) assert info.invalid_reason != "" + + +def test_refresh_skips_non_git_packages(manager: Manager) -> None: + # A directory package must not trigger _open_package_clone (which would + # fail with NoSuchPathError). + _make_installed( + manager, + "mypkg", + tracking_method=TRACKING_METHOD_DIRECTORY, + current_version="1.0.0", + ) + # Should complete without raising. + manager.refresh_installed_packages() + + +def test_upgrade_not_installed(manager: Manager) -> None: + assert manager.upgrade("nonexistent") == "no such package installed" + + +def test_upgrade_pinned(manager: Manager) -> None: + _make_installed(manager, "mypkg", is_pinned=True, is_outdated=True) + assert manager.upgrade("https://example.com/mypkg") == "package is pinned" + + +def test_upgrade_not_outdated(manager: Manager) -> None: + _make_installed(manager, "mypkg", is_outdated=False) + assert manager.upgrade("https://example.com/mypkg") == "package is not outdated" + + +def test_package_versions(manager: Manager) -> None: + pkg_dir = pathlib.Path(manager.package_clonedir) / "mypkg" + r = git.Repo.init(pkg_dir) + r.config_writer().set_value("user", "name", "Test").release() + r.config_writer().set_value("user", "email", "test@test").release() + (pkg_dir / "file.txt").write_text("hi") + r.index.add(["file.txt"]) + r.index.commit("init") + r.create_tag("v1.0.0") + r.create_tag("v2.0.0") + + package = Package(git_url=str(pkg_dir), name="mypkg", canonical=True) + ipkg = InstalledPackage(package, PackageStatus()) + assert manager.package_versions(ipkg) == ["v1.0.0", "v2.0.0"] + + +def test_open_package_clone(manager: Manager) -> None: + # Create a bare git repo in the manager's package clone directory. + pkg_dir = pathlib.Path(manager.package_clonedir) / "mypkg" + r = git.Repo.init(pkg_dir) + r.config_writer().set_value("user", "name", "Test").release() + r.config_writer().set_value("user", "email", "test@test").release() + (pkg_dir / "file.txt").write_text("hi") + r.index.add(["file.txt"]) + r.index.commit("init") + + package = Package(git_url=str(pkg_dir), name="mypkg", canonical=True) + clone = manager._open_package_clone(package) + assert isinstance(clone, git.Repo) + assert clone.working_dir == str(pkg_dir) + + +def test_upgrade_directory_package( + manager: Manager, + pkg_dir: pathlib.Path, +) -> None: + # A directory package is never outdated, so upgrade must be a no-op. + assert manager.install(str(pkg_dir)) == "" + assert manager.upgrade(str(pkg_dir)) == "package is not outdated" + + +def test_test_directory_package( + manager: Manager, + pkg_dir: pathlib.Path, +) -> None: + # test() on a directory package without a test_command must report the + # absence as an error. + error, passed, _ = manager.test(str(pkg_dir)) + assert not passed + assert "test_command" in error diff --git a/zeekpkg/manager.py b/zeekpkg/manager.py index 3a36983..759b9f5 100644 --- a/zeekpkg/manager.py +++ b/zeekpkg/manager.py @@ -484,6 +484,10 @@ def _read_manifest(self) -> tuple[str, str, str]: return data["script_dir"], data["plugin_dir"], data.get("bin_dir", None) + def _open_package_clone(self, package: Package) -> git.Repo: + """Open the on-disk git clone for an installed package.""" + return git.Repo(os.path.join(self.package_clonedir, package.name)) + def _write_manifest(self) -> None: """Writes the manifest file containing the list of installed packages. @@ -1288,8 +1292,12 @@ def refresh_installed_packages(self) -> None: ) continue - clonepath = os.path.join(self.package_clonedir, ipkg.package.name) - clone = git.Repo(clonepath) + if not _is_git_package(ipkg.status): + continue + + # Deliberate git entry point: fetch requires network access and + # must operate on a live git.Repo. + clone = self._open_package_clone(ipkg.package) LOG.debug("fetch package %s", ipkg.package.qualified_name()) try: @@ -1344,8 +1352,7 @@ def upgrade(self, pkg_path: str) -> str: LOG.info('upgrading "%s": package not outdated', pkg_path) return "package is not outdated" - clonepath = os.path.join(self.package_clonedir, ipkg.package.name) - clone = git.Repo(clonepath) + clone = self._open_package_clone(ipkg.package) if ipkg.status.tracking_method == TRACKING_METHOD_VERSION: version_tags = git_version_tags(clone) @@ -1898,7 +1905,7 @@ def info( ipkg = self.find_installed_package(pkg_path) if prefer_installed and ipkg: - clone = git.Repo(os.path.join(self.package_clonedir, ipkg.package.name)) + clone = self._open_package_clone(ipkg.package) versions = git_version_tags(clone) default_branch = git_default_branch(clone) try: @@ -2038,11 +2045,8 @@ def package_versions(self, installed_package: InstalledPackage) -> list[str]: Returns: list of str: the version number tags. """ - name = installed_package.package.name - assert name - clonepath = os.path.join(self.package_clonedir, name) - clone = git.Repo(clonepath) - return git_version_tags(clone) + assert installed_package.package.name + return git_version_tags(self._open_package_clone(installed_package.package)) def validate_dependencies( self, @@ -2745,26 +2749,15 @@ def test( delete_path(clonepath) try: - clone = _clone_package(info.package, clonepath, version) - except git.GitCommandError as error: - LOG.warning("failed to clone git repo: %s", error) - assert stage.state_dir - return ( - f"failed to clone {info.package.git_url}", - False, - stage.state_dir, - ) - - try: - git_checkout(clone, version) - resolution = _resolve_git_version(clone, version) + snapshot = _prepare_snapshot(info.package, version, clonepath) except Exception as error: - LOG.warning("failed to resolve git version: %s", error) + LOG.warning("failed to prepare package: %s", error) assert stage.state_dir return (str(error), False, stage.state_dir) - version = resolution.version - fail_msg = self._stage(info.package, version, clone, stage, env) + assert snapshot.version is not None + version = snapshot.version + fail_msg = self._stage(info.package, version, snapshot, stage, env) if fail_msg: return (fail_msg, False, self.state_dir) @@ -2840,7 +2833,7 @@ def _stage( self, package: Package, version: str, - clone: git.Repo, + snapshot: PackageSnapshot, stage: Stage, env: dict[str, str] | None = None, ) -> str: @@ -2850,18 +2843,17 @@ def _stage( location in the file system, called a "stage". The stage may be the actual installation folders for the system's Zeek distribution, or one purely internal to zkg's stage management when testing a package. The - steps involved in staging include cloning and checking out the package - at the desired version, building it if it features a build_command, and - installing script & plugin folders inside the requested stage. + steps involved in staging include building the package if it features a + build_command, and installing script & plugin folders inside the + requested stage. Args: package (:class:`.package.Package`): the package to stage - version (str): the git tag, branch name, or commit hash of the - package version to stage + version (str): the resolved version of the package to stage - clone (:class:`git.Repo`): the on-disk clone of the package's - git repository. + snapshot (:class:`.package.PackageSnapshot`): snapshot of the + package's on-disk state at the point it entered processing. stage (:class:`Stage`): the staging object describing the disk locations for installation. @@ -2876,18 +2868,8 @@ def _stage( """ LOG.debug('staging "%s": version %s', package, version) - metadata_file = _pick_metadata_file(str(clone.working_dir)) - metadata_parser = configparser.ConfigParser(interpolation=None) - invalid_reason: str | None = _parse_package_metadata( - metadata_parser, - metadata_file, - ) - if invalid_reason: - return invalid_reason - - metadata = _get_package_metadata(metadata_parser) interpolated_metadata, invalid_reason = self._interpolate_package_metadata( - metadata, + snapshot.meta, stage, ) if invalid_reason: @@ -2905,14 +2887,14 @@ def _stage( build = subprocess.Popen( build_command, shell=True, - cwd=clone.working_dir, + cwd=snapshot.working_dir, env=env, bufsize=bufsize, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - buildlog = self.package_build_log(str(clone.working_dir)) + buildlog = self.package_build_log(str(snapshot.working_dir)) try: with open(buildlog, "wb") as f: LOG.info( @@ -2958,7 +2940,7 @@ def _stage( return f"package build_command failed, see log in {buildlog}" pkg_script_dir = interpolated_metadata.get("script_dir", "") - script_dir_src = os.path.join(clone.working_dir, pkg_script_dir) + script_dir_src = os.path.join(snapshot.working_dir, pkg_script_dir) script_dir_dst = os.path.join(stage.script_dir, package.name) if not os.path.exists(script_dir_src): @@ -3005,7 +2987,7 @@ def _stage( ) pkg_plugin_dir = interpolated_metadata.get("plugin_dir", "build") - plugin_dir_src = os.path.join(clone.working_dir, pkg_plugin_dir) + plugin_dir_src = os.path.join(snapshot.working_dir, pkg_plugin_dir) plugin_dir_dst = os.path.join(stage.plugin_dir, package.name) if not os.path.exists(plugin_dir_src): @@ -3031,7 +3013,7 @@ def _stage( # Ensure any listed executables exist as advertised. for p in self._get_executables(interpolated_metadata): - full_path = os.path.join(clone.working_dir, p) + full_path = os.path.join(snapshot.working_dir, p) if not os.path.isfile(full_path): return f"executable '{p}' is missing" @@ -3190,38 +3172,33 @@ def _install( git.GitCommandError: if the git repo is invalid IOError: if the package manifest file can't be written """ - clonepath = os.path.join(self.package_clonedir, package.name) ipkg = self.find_installed_package(package.name) - if use_existing_clone or ipkg: - clone = git.Repo(clonepath) - else: - clone = _clone_package(package, clonepath, version) - status = PackageStatus() status.is_loaded = ipkg.status.is_loaded if ipkg else False status.is_pinned = ipkg.status.is_pinned if ipkg else False + clonepath = os.path.join(self.package_clonedir, package.name) + existing_clone = ( + self._open_package_clone(package) if use_existing_clone or ipkg else None + ) + try: - git_checkout(clone, version) - resolution = _resolve_git_version(clone, version) + snapshot = _prepare_snapshot( + package, + version, + clonepath, + existing_clone=existing_clone, + ) except Exception as e: return str(e) - version = resolution.version - status.tracking_method = resolution.tracking_method - status.current_version = resolution.version - status.current_hash = resolution.current_hash - status.is_outdated = resolution.is_outdated - - metadata_file = _pick_metadata_file(str(clone.working_dir)) - metadata_parser = configparser.ConfigParser(interpolation=None) - invalid_reason = _parse_package_metadata(metadata_parser, metadata_file) - - if invalid_reason: - return invalid_reason + status.tracking_method = snapshot.tracking_method + status.current_version = snapshot.version + status.current_hash = snapshot.current_hash + status.is_outdated = snapshot.is_outdated - raw_metadata = _get_package_metadata(metadata_parser) + raw_metadata = snapshot.meta invalid_reason = self._validate_alias_conflict(package, raw_metadata) @@ -3231,7 +3208,10 @@ def _install( # A dummy stage that uses the actual installation folders; # we do not need to populate() it. stage = Stage(self) - fail_msg = self._stage(package, version, clone, stage) + # `_prepare_snapshot` guarantees `version` is set for both directory + # and Git sources. + assert snapshot.version is not None + fail_msg = self._stage(package, snapshot.version, snapshot, stage) if fail_msg: return fail_msg @@ -3380,6 +3360,37 @@ def _resolve_git_version(clone: git.Repo, version: str | None) -> GitResolution: return GitResolution(version, tracking_method, current_hash, is_outdated) +def _prepare_snapshot( + package: Package, + version: str | None, + dest_path: str, + *, + existing_clone: git.Repo | None = None, +) -> PackageSnapshot: + """Resolve *package* to a :class:`.package.PackageSnapshot`. + + For plain directories (a path that exists and has no ``.git`` sub-directory) + the snapshot is built directly from ``zkg.meta`` in the directory. For all + other sources a Git clone is obtained (reusing *existing_clone* when + provided, otherwise cloning into *dest_path*), the requested *version* is + resolved and checked out, and the snapshot is built from the clone. + + Raises: + ValueError: if metadata is missing, invalid, or (for directory sources) + has no ``version`` field. + git.GitCommandError: if cloning or checkout fails. + """ + if _is_directory_package(package.git_url): + make_symlink(package.git_url, dest_path) + return _snapshot_from_directory(dest_path) + + clone = existing_clone or _clone_package(package, dest_path, version) + resolved_version, _ = _pick_version(clone, version) + git_checkout(clone, resolved_version) + resolution = _resolve_git_version(clone, version) + return _snapshot_from_git_repo(clone, resolution) + + def _snapshot_from_git_repo( clone: git.Repo, resolution: GitResolution, From 9735d034f310265593833ab85fe1720885a8d8f0 Mon Sep 17 00:00:00 2001 From: Benjamin Bannier Date: Thu, 16 Jul 2026 15:49:41 +0200 Subject: [PATCH 07/11] Validate `zkg.meta` version field against Git tag on install Catch a mismatch between the `version` field in `zkg.meta` and the Git tag being installed before the install completes. Provide an opt-out for cases where the mismatch is expected. --- testing/test_manager.py | 36 +++++++++++++++++++++++++++++++++ zeekpkg/manager.py | 44 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/testing/test_manager.py b/testing/test_manager.py index c076848..07195fa 100644 --- a/testing/test_manager.py +++ b/testing/test_manager.py @@ -438,6 +438,42 @@ def test_open_package_clone(manager: Manager) -> None: assert clone.working_dir == str(pkg_dir) +@pytest.mark.parametrize( + "skip,expected", + [ + (False, "does not match"), + (True, ""), + ], + ids=["fails", "skip_validation"], +) +def test_install_version_field_mismatch( + manager: Manager, + pkg_repo: git.Repo, + skip: bool, + expected: str, +) -> None: + # A zkg.meta version field that does not match the Git tag must fail; + # with skip_version_validation the mismatch is only a warning. + (pathlib.Path(pkg_repo.working_dir) / "zkg.meta").write_text( + "[package]\ndescription = test\nversion = v9.9.9\n", + ) + pkg_repo.index.add(["zkg.meta"]) + pkg_repo.index.commit("wrong version") + pkg_repo.create_tag("v1.0.1") + result = manager.install( + str(pkg_repo.working_dir), + "v1.0.1", + skip_version_validation=skip, + ) + assert expected in result + + +def test_install_no_version_field_passes(manager: Manager, pkg_repo: git.Repo) -> None: + # No version field in zkg.meta is fine, validation only applies when the field is present. + result = manager.install(str(pkg_repo.working_dir), "v1.0.0") + assert result == "" + + def test_upgrade_directory_package( manager: Manager, pkg_dir: pathlib.Path, diff --git a/zeekpkg/manager.py b/zeekpkg/manager.py index 759b9f5..b8b60a5 100644 --- a/zeekpkg/manager.py +++ b/zeekpkg/manager.py @@ -3029,7 +3029,12 @@ def _stage( return "" - def install(self, pkg_path: str, version: str = "") -> str: + def install( + self, + pkg_path: str, + version: str = "", + skip_version_validation: bool = False, + ) -> str: """Install a package. Args: @@ -3044,6 +3049,10 @@ def install(self, pkg_path: str, version: str = "") -> str: "main" or "master" is installed). If given, it may be either a git version tag, a git branch name, or a git commit hash. + skip_version_validation (bool): if True, a mismatch between the + ``version`` field in ``zkg.meta`` and the installed Git tag is + logged as a warning instead of failing the installation. + Returns: str: empty string if package installation succeeded else an error string explaining why it failed. @@ -3062,7 +3071,11 @@ def install(self, pkg_path: str, version: str = "") -> str: LOG.debug('installing "%s": re-install: %s', pkg_path, conflict) clonepath = os.path.join(self.package_clonedir, conflict.name) _clone_package(conflict, clonepath, version) - return self._install(conflict, version) + return self._install( + conflict, + version, + skip_version_validation=skip_version_validation, + ) LOG.info( 'installing "%s": matched already installed package: %s', @@ -3078,7 +3091,11 @@ def install(self, pkg_path: str, version: str = "") -> str: if not matches: try: package = Package(git_url=pkg_path) - return self._install(package, version) + return self._install( + package, + version, + skip_version_validation=skip_version_validation, + ) except git.GitCommandError as error: LOG.info('installing "%s": invalid git repo path: %s', pkg_path, error) @@ -3099,7 +3116,11 @@ def install(self, pkg_path: str, version: str = "") -> str: ) try: - return self._install(matches[0], version) + return self._install( + matches[0], + version, + skip_version_validation=skip_version_validation, + ) except git.GitCommandError as error: LOG.warning('installing "%s": source package git repo is invalid', pkg_path) return f'failed to clone package "{pkg_path}": {error}' @@ -3161,6 +3182,7 @@ def _install( package: Package, version: str, use_existing_clone: bool = False, + skip_version_validation: bool = False, ) -> str: """Install a :class:`.package.Package`. @@ -3198,6 +3220,20 @@ def _install( status.current_hash = snapshot.current_hash status.is_outdated = snapshot.is_outdated + # When installing a tagged version, require that the optional zkg.meta + # `version` field, if present, matches the Git tag. + meta_version = snapshot.meta.get("version") + if ( + snapshot.tracking_method == TRACKING_METHOD_VERSION + and meta_version + and meta_version != snapshot.version + ): + msg = f"zkg.meta version '{meta_version}' does not match Git tag '{snapshot.version}'" + if skip_version_validation: + LOG.warning("%s", msg) + else: + return msg + raw_metadata = snapshot.meta invalid_reason = self._validate_alias_conflict(package, raw_metadata) From a0292b58169fb88362f1a08eeeadd9f1d306d454 Mon Sep 17 00:00:00 2001 From: Benjamin Bannier Date: Thu, 16 Jul 2026 20:59:36 +0200 Subject: [PATCH 08/11] Guard `bundle()` against non-Git packages in `prefer_existing_clones` path A directory package passed to `bundle()` with `prefer_existing_clones=True` would crash trying to open its path as a `git.Repo`. Guard against this by rejecting directory packages early and skipping the existing-clone fast path for non-Git installed packages. --- testing/test_manager.py | 37 +++++++++++++++++++++++++++++++++++++ zeekpkg/manager.py | 5 ++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/testing/test_manager.py b/testing/test_manager.py index 07195fa..a54129f 100644 --- a/testing/test_manager.py +++ b/testing/test_manager.py @@ -474,6 +474,43 @@ def test_install_no_version_field_passes(manager: Manager, pkg_repo: git.Repo) - assert result == "" +def test_bundle_skips_non_git_existing_clone( + manager: Manager, + tmp_path: pathlib.Path, +) -> None: + # A directory package must not be used as an existing clone source; + # bundle() should fall through to cloning from the URL (which will fail + # for a bogus URL, but must not crash trying to open a git.Repo). + _make_installed( + manager, + "mypkg", + tracking_method=TRACKING_METHOD_DIRECTORY, + current_version="1.0.0", + ) + bundle_file = str(tmp_path / "out.tar.gz") + result = manager.bundle( + bundle_file, + [("https://example.com/mypkg", "1.0.0")], + prefer_existing_clones=True, + ) + # Fails because the URL is not a real git repo, not because of a git.Repo crash. + assert "failed to clone" in result + + +def test_bundle_directory_package( + manager: Manager, + pkg_dir: pathlib.Path, + tmp_path: pathlib.Path, +) -> None: + # bundle() must report that bundling is unsupported for directory packages. + bundle_file = str(tmp_path / "out.tar.gz") + result = manager.bundle( + bundle_file, + [(str(pkg_dir), "")], + ) + assert "cannot bundle directory package" in result + + def test_upgrade_directory_package( manager: Manager, pkg_dir: pathlib.Path, diff --git a/zeekpkg/manager.py b/zeekpkg/manager.py index b8b60a5..35f4481 100644 --- a/zeekpkg/manager.py +++ b/zeekpkg/manager.py @@ -2537,10 +2537,13 @@ def match_package_url_and_version( clonepath = os.path.join(bundle_dir, name) config.set("bundle", git_url, version) + if _is_directory_package(git_url): + return f"cannot bundle directory package {git_url}: bundling requires a Git repository" + if prefer_existing_clones: ipkg = match_package_url_and_version(git_url, version) - if ipkg: + if ipkg and _is_git_package(ipkg.status): src = os.path.join(self.package_clonedir, ipkg.package.name) shutil.copytree(src, clonepath, symlinks=True) clone = git.Repo(clonepath) From dc3dabc9eedbee34d43b8ccd4a0b992e9021ccc2 Mon Sep 17 00:00:00 2001 From: Benjamin Bannier Date: Wed, 15 Jul 2026 14:51:26 +0200 Subject: [PATCH 09/11] Wire `--skip-version-validation` into the `zkg install` CLI Expose the `skip_version_validation` parameter added to `Manager.install()` via a new `--skip-version-validation` flag on `zkg install`, so users can opt out of version-field validation on the command line. --- .../error | 3 +++ .../warning | 2 ++ testing/tests/install-skip-version-validation | 23 +++++++++++++++++++ zkg | 21 +++++++++++++++-- 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 testing/baselines/tests.install-skip-version-validation/error create mode 100644 testing/baselines/tests.install-skip-version-validation/warning create mode 100644 testing/tests/install-skip-version-validation diff --git a/testing/baselines/tests.install-skip-version-validation/error b/testing/baselines/tests.install-skip-version-validation/error new file mode 100644 index 0000000..37f943d --- /dev/null +++ b/testing/baselines/tests.install-skip-version-validation/error @@ -0,0 +1,3 @@ +### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63. +error: incomplete installation, the follow packages failed to be installed: + <...>/mypkg (v1.0.1) diff --git a/testing/baselines/tests.install-skip-version-validation/warning b/testing/baselines/tests.install-skip-version-validation/warning new file mode 100644 index 0000000..c086ffd --- /dev/null +++ b/testing/baselines/tests.install-skip-version-validation/warning @@ -0,0 +1,2 @@ +### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63. +XXXX-XX-XX XX:XX:XX WARNING zkg.meta version 'v9.9.9' does not match Git tag 'v1.0.1' diff --git a/testing/tests/install-skip-version-validation b/testing/tests/install-skip-version-validation new file mode 100644 index 0000000..e6affb6 --- /dev/null +++ b/testing/tests/install-skip-version-validation @@ -0,0 +1,23 @@ +# @TEST-DOC: Test that `--skip-version-validation` downgrades a version mismatch from error to warning. + +# Set up a git repo with a tag/version mismatch: tag is `v1.0.1`, `zkg.meta` says `v9.9.9`. +# @TEST-EXEC: bash %INPUT +# +# @TEST-EXEC-FAIL: zkg install ./mypkg --version=v1.0.1 2>error +# @TEST-EXEC: TEST_DIFF_CANONIFIER=$SCRIPTS/diff-canonifier btest-diff error +# @TEST-EXEC: zkg install ./mypkg --version=v1.0.1 --skip-version-validation 2>warning +# @TEST-EXEC: TEST_DIFF_CANONIFIER=$SCRIPTS/diff-canonifier btest-diff warning + +mkdir mypkg +cd mypkg +git init +cat > zkg.meta << 'EOF' +[package] +version = v9.9.9 +EOF +cat > __load__.zeek << 'EOF' +event zeek_init() { print "loaded"; } +EOF +git add zkg.meta __load__.zeek +git commit -m "init" +git tag -a v1.0.1 -m v1.0.1 diff --git a/zkg b/zkg index edd2503..d7d802f 100755 --- a/zkg +++ b/zkg @@ -450,15 +450,21 @@ class InstallWorker(threading.Thread): manager: zeekpkg.Manager, package_name: str, package_version: str, + skip_version_validation: bool = False, ) -> None: super().__init__() self.manager = manager self.package_name = package_name self.package_version = package_version + self.skip_version_validation = skip_version_validation self.error = "" def run(self) -> None: - self.error = self.manager.install(self.package_name, self.package_version) + self.error = self.manager.install( + self.package_name, + self.package_version, + skip_version_validation=self.skip_version_validation, + ) def wait( self, @@ -764,7 +770,12 @@ def cmd_install( backup_files = manager.backup_modified_files(name, modifications) prev_upstream_config_files = manager.save_temporary_config_files(ipkg) - worker = InstallWorker(manager, name, version) + worker = InstallWorker( + manager, + name, + version, + skip_version_validation=args.skip_version_validation, + ) worker.start() worker.wait(f'Installing "{name}"') @@ -2638,6 +2649,12 @@ def argparser() -> argparse.ArgumentParser: " the latest version tag, or if a package has none," ' the default branch, like "main" or "master".', ) + sub_parser.add_argument( + "--skip-version-validation", + action="store_true", + help="Downgrade a mismatch between the zkg.meta ``version`` field and" + " the found Git tag from an error to a warning.", + ) add_uservar_args(sub_parser) # bundle From af192e91f088133911aeb7150de19df4599233a6 Mon Sep 17 00:00:00 2001 From: Benjamin Bannier Date: Fri, 17 Jul 2026 08:03:46 +0200 Subject: [PATCH 10/11] Expose directory-backed package sources in the CLI \`zkg install\`, \`zkg test\`, and \`zkg bundle\` now accept plain local directories in addition to Git repos. A local path starting with \`.\` or \`/\` that is not a Git repo is routed to the directory backend introduced in the previous commit. For directory-backed packages, \`best_version()\` returns nothing since there is no Git history. Fall back to \`metadata_version\` instead, which reads the \`version\` field from \`zkg.meta\`. --- .../baselines/tests.bundle-directory/output | 2 + .../baselines/tests.install-directory/output | 2 + .../baselines/tests.install-invalid/output | 7 ++-- testing/baselines/tests.test-directory/output | 2 + testing/tests/bundle-directory | 10 +++++ testing/tests/install-directory | 15 +++++++ testing/tests/install-invalid | 2 +- testing/tests/test-directory | 12 ++++++ zkg | 40 +++++++++++++------ 9 files changed, 76 insertions(+), 16 deletions(-) create mode 100644 testing/baselines/tests.bundle-directory/output create mode 100644 testing/baselines/tests.install-directory/output create mode 100644 testing/baselines/tests.test-directory/output create mode 100644 testing/tests/bundle-directory create mode 100644 testing/tests/install-directory create mode 100644 testing/tests/test-directory diff --git a/testing/baselines/tests.bundle-directory/output b/testing/baselines/tests.bundle-directory/output new file mode 100644 index 0000000..9f43791 --- /dev/null +++ b/testing/baselines/tests.bundle-directory/output @@ -0,0 +1,2 @@ +### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63. +error: failed to create bundle: cannot bundle directory package <...>/mypkg: bundling requires a Git repository diff --git a/testing/baselines/tests.install-directory/output b/testing/baselines/tests.install-directory/output new file mode 100644 index 0000000..cd4d398 --- /dev/null +++ b/testing/baselines/tests.install-directory/output @@ -0,0 +1,2 @@ +### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63. +Hello, world! diff --git a/testing/baselines/tests.install-invalid/output b/testing/baselines/tests.install-invalid/output index 4e53fe2..0c8a9ce 100644 --- a/testing/baselines/tests.install-invalid/output +++ b/testing/baselines/tests.install-invalid/output @@ -1,8 +1,9 @@ ### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63. error: invalid package ".": Package name ' fronting-whitespace' is not valid. error: invalid package ".": Package name 'trailing-whitespace ' is not valid. -error: path ./packages/doesntexist is not a git repository -error: path ./packages/notagitrepo is not a git repository -error: local git clone at ./packages/dirtyrepo is dirty +error: path .<...>/doesntexist is not a git repository or a directory +XXXX-XX-XX XX:XX:XX WARNING <...>/bro-pkg.meta: missing metadata file +error: invalid package ".<...>/notagitrepo": missing zkg.meta (or bro-pkg.meta) metadata file +error: local git clone at .<...>/dirtyrepo is dirty error: local git clone at dirtyrepo is dirty error: local git clone at ./dirtyrepo is dirty diff --git a/testing/baselines/tests.test-directory/output b/testing/baselines/tests.test-directory/output new file mode 100644 index 0000000..1062996 --- /dev/null +++ b/testing/baselines/tests.test-directory/output @@ -0,0 +1,2 @@ +### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63. +<...>/mypkg: all tests passed diff --git a/testing/tests/bundle-directory b/testing/tests/bundle-directory new file mode 100644 index 0000000..c1854fa --- /dev/null +++ b/testing/tests/bundle-directory @@ -0,0 +1,10 @@ +# @TEST-DOC: Bundling a plain directory package must fail with a clear error. +# +# @TEST-REQUIRES: type zeek +# @TEST-EXEC-FAIL: zkg bundle out.bundle --manifest ./mypkg >output 2>&1 +# @TEST-EXEC: TEST_DIFF_CANONIFIER=$SCRIPTS/diff-canonifier btest-diff output + +@TEST-START-FILE mypkg/zkg.meta +[package] +version = 1.0.0 +@TEST-END-FILE diff --git a/testing/tests/install-directory b/testing/tests/install-directory new file mode 100644 index 0000000..63abfcc --- /dev/null +++ b/testing/tests/install-directory @@ -0,0 +1,15 @@ +# @TEST-DOC: Test that a plain local directory (no .git) can be installed as a package. +# +# @TEST-REQUIRES: type zeek +# @TEST-EXEC: zkg install $(pwd)/mypkg +# @TEST-EXEC: zeek -b scripts/packages/packages.zeek >output 2>&1 +# @TEST-EXEC: btest-diff output + +@TEST-START-FILE mypkg/zkg.meta +[package] +version = 1.0.0 +@TEST-END-FILE + +@TEST-START-FILE mypkg/__load__.zeek +event zeek_init() { print "Hello, world!"; } +@TEST-END-FILE diff --git a/testing/tests/install-invalid b/testing/tests/install-invalid index b02ec4c..41bc3c6 100644 --- a/testing/tests/install-invalid +++ b/testing/tests/install-invalid @@ -1,6 +1,6 @@ # Test invalid package names and paths # @TEST-EXEC-FAIL: bash %INPUT 2>output -# @TEST-EXEC: btest-diff output +# @TEST-EXEC: TEST_DIFF_CANONIFIER=$SCRIPTS/diff-canonifier btest-diff output CONFIG=$(pwd)/config mkdir -p invalid diff --git a/testing/tests/test-directory b/testing/tests/test-directory new file mode 100644 index 0000000..6731e1c --- /dev/null +++ b/testing/tests/test-directory @@ -0,0 +1,12 @@ +# @TEST-DOC: Test that "zkg test" works on a plain directory package. +# +# @TEST-REQUIRES: type zeek +# @TEST-EXEC: touch mypkg/__load__.zeek +# @TEST-EXEC: zkg test ./mypkg >output 2>&1 +# @TEST-EXEC: TEST_DIFF_CANONIFIER=$SCRIPTS/diff-canonifier btest-diff output + +@TEST-START-FILE mypkg/zkg.meta +[package] +version = 1.0.0 +test_command = echo "tests passed" +@TEST-END-FILE diff --git a/zkg b/zkg index d7d802f..6c93b17 100755 --- a/zkg +++ b/zkg @@ -282,7 +282,7 @@ def create_config( def active_git_branch(path: str) -> str | None: try: repo = git.Repo(path) - except git.NoSuchPathError: + except (git.NoSuchPathError, git.InvalidGitRepositoryError): return None if not repo.working_tree_dir: @@ -328,14 +328,30 @@ def is_local_git_repo_dirty(git_url: str) -> bool: return repo.is_dirty(untracked_files=True) -def check_local_git_repo(git_url: str) -> bool: - if is_local_git_repo_url(git_url): - if not is_local_git_repo(git_url): - print_error(f"error: path {git_url} is not a git repository") - return False - if is_local_git_repo_dirty(git_url): - print_error(f"error: local git clone at {git_url} is dirty") - return False +def check_local_git_repo(path: str) -> bool: + """Check whether *path* is a usable local package source. + + Local paths starting with '.' or '/' must either be a clean Git repo or a + plain directory; everything else (URLs, SCP addresses, package source + names) is accepted without checks. + """ + # TODO: refactor this function and its callers into a factory that maps a + # path to a backend kind (Git repo, plain directory, remote URL), so + # routing and validation are co-located and backends can be extended + # without touching each call site. + if not is_local_git_repo_url(path): + return True + + if os.path.isdir(path) and not is_local_git_repo(path): + return True + + if not is_local_git_repo(path): + print_error(f"error: path {path} is not a git repository or a directory") + return False + + if is_local_git_repo_dirty(path): + print_error(f"error: local git clone at {path} is dirty") + return False return True @@ -541,7 +557,7 @@ def cmd_test( sys.exit(1) if not version: - version = package_info.best_version() + version = package_info.metadata_version or package_info.best_version() package_infos.append((package_info, version)) @@ -616,7 +632,7 @@ def cmd_install( sys.exit(1) if not version: - version = package_info.best_version() + version = package_info.metadata_version or package_info.best_version() package_infos.append((package_info, version, False)) @@ -904,7 +920,7 @@ def cmd_bundle( sys.exit(1) if not version: - version = info.best_version() + version = info.metadata_version or info.best_version() to_validate.append((info.package.qualified_name(), version)) packages_to_bundle.append( From 747d7cdd92c4d6c60e56c0838ba731845a817c92 Mon Sep 17 00:00:00 2001 From: Benjamin Bannier Date: Fri, 17 Jul 2026 08:04:04 +0200 Subject: [PATCH 11/11] Rename `check_local_git_repo` to `check_local_package_path` The function now accepts plain directories in addition to Git repos; the new name reflects the broader scope. --- zkg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/zkg b/zkg index 6c93b17..e6fb50b 100755 --- a/zkg +++ b/zkg @@ -328,7 +328,7 @@ def is_local_git_repo_dirty(git_url: str) -> bool: return repo.is_dirty(untracked_files=True) -def check_local_git_repo(path: str) -> bool: +def check_local_package_path(path: str) -> bool: """Check whether *path* is a usable local package source. Local paths starting with '.' or '/' must either be a clean Git repo or a @@ -537,7 +537,7 @@ def cmd_test( package_infos: list[tuple[PackageInfo, str]] = [] for name in args.package: - if not check_local_git_repo(name): + if not check_local_package_path(name): sys.exit(1) # If the package to be tested is included with Zeek, don't allow @@ -613,7 +613,7 @@ def cmd_install( package_infos: list[tuple[PackageInfo, str, bool]] = [] for name in args.package: - if not check_local_git_repo(name): + if not check_local_package_path(name): sys.exit(1) # Outright prevent installing a package that Zeek has built-in. @@ -906,7 +906,7 @@ def cmd_bundle( new_pkgs: list[tuple[PackageInfo, str, bool]] = [] for name, version in packages: - if not check_local_git_repo(name): + if not check_local_package_path(name): sys.exit(1) if not version: