From 2687858a7bb9bcc5d17c6081f873345e79d36f1f Mon Sep 17 00:00:00 2001 From: Paul Hovey Date: Wed, 23 Sep 2026 20:21:30 -0400 Subject: [PATCH 1/5] If an empty group is the default group, then treat it as empty on purpose, not empty on accident --- docs/use-cases/default-groups.md | 14 ++++++ gitman/models/config.py | 8 ++-- gitman/models/group.py | 10 ++-- gitman/tests/test_models_config.py | 47 ++++++++++++++++++- tests/test_api.py | 73 ++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 7 deletions(-) diff --git a/docs/use-cases/default-groups.md b/docs/use-cases/default-groups.md index a42bdb7c..fcda587a 100644 --- a/docs/use-cases/default-groups.md +++ b/docs/use-cases/default-groups.md @@ -13,6 +13,20 @@ To ignore the `default_group` and install all dependencies, use the `all` keywor $ gitman install all ``` +## Empty Default Groups + +To prevent any dependencies from being cloned by default, define a group with no members and set it as the `default_group`: + +```yaml +groups: + - name: empty + members: null + +default_group: empty +``` + +When `gitman install` is run, no dependencies will be installed unless a specific group or dependency is requested, or the default group is overridden with `all` or `--no-defaults`. + ## Nested Projects When nested Gitman projects are used default groups are installed if they diff --git a/gitman/models/config.py b/gitman/models/config.py index 243aaf50..890bb35c 100644 --- a/gitman/models/config.py +++ b/gitman/models/config.py @@ -408,12 +408,14 @@ def _get_sources_filter( ) -> List[str]: """Get a filtered subset of sources.""" names_list = list(names) - if not names_list and not skip_default_group: + if not names_list and not skip_default_group and self.default_group: names_list.append(self.default_group) # Add sources from groups groups_filter = [group for group in self.groups if group.name in names_list] - sources_filter = [member for group in groups_filter for member in group.members] + sources_filter = [ + member for group in groups_filter for member in (group.members or []) + ] # Add independent sources sources_filter.extend( @@ -421,7 +423,7 @@ def _get_sources_filter( ) # Fall back to all sources if allowed - if not sources_filter: + if not sources_filter and not groups_filter: if names and names_list != ["all"]: log.warn(f"No dependencies match: {' '.join(names)}") else: diff --git a/gitman/models/group.py b/gitman/models/group.py index 79bdb5d6..56367f97 100644 --- a/gitman/models/group.py +++ b/gitman/models/group.py @@ -1,5 +1,5 @@ -from dataclasses import dataclass -from typing import List +from dataclasses import dataclass, field +from typing import List, Optional @dataclass @@ -7,7 +7,11 @@ class Group: """A group with sources.""" name: str - members: List[str] + members: Optional[List[str]] = field(default_factory=list) + + def __post_init__(self): + if self.members is None: + self.members = [] def __repr__(self): return "".format(self) diff --git a/gitman/tests/test_models_config.py b/gitman/tests/test_models_config.py index 9c1c2ba4..6d2b5632 100644 --- a/gitman/tests/test_models_config.py +++ b/gitman/tests/test_models_config.py @@ -5,7 +5,7 @@ import pytest from expecter import expect -from gitman.models import Config, load_config +from gitman.models import Config, Group, Source, load_config from .conftest import FILES @@ -135,6 +135,51 @@ def it_can_get_dependency_path(config): "m/root/m/location/foobar" ) + def describe_get_sources_filter(): + def it_returns_empty_when_default_group_has_no_members(config): + config.sources = [Source(repo="http://example.com/repo1", name="repo1")] + config.groups = [Group(name="empty", members=None)] + config.default_group = "empty" + + expect( + config._get_sources_filter( + sources=config.sources, skip_default_group=False + ) + ) == [] + + def it_returns_empty_when_empty_group_specified_by_name(config): + config.sources = [Source(repo="http://example.com/repo1", name="repo1")] + config.groups = [Group(name="empty", members=[])] + config.default_group = "" + + expect( + config._get_sources_filter( + "empty", sources=config.sources, skip_default_group=False + ) + ) == [] + + def it_returns_all_when_skip_default_group_is_true(config): + config.sources = [Source(repo="http://example.com/repo1", name="repo1")] + config.groups = [Group(name="empty", members=None)] + config.default_group = "empty" + + expect( + config._get_sources_filter( + sources=config.sources, skip_default_group=True + ) + ) == ["repo1"] + + def it_returns_all_when_name_all_is_specified(config): + config.sources = [Source(repo="http://example.com/repo1", name="repo1")] + config.groups = [Group(name="empty", members=None)] + config.default_group = "empty" + + expect( + config._get_sources_filter( + "all", sources=config.sources, skip_default_group=False + ) + ) == ["repo1"] + class TestLoad: def test_load_from_directory_with_config_file(self): diff --git a/tests/test_api.py b/tests/test_api.py index 38ff8b87..4063ee23 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -623,6 +623,79 @@ def it_installs_all_sources_when_all_specified(config_with_default_group): ) ) == True + @pytest.fixture + def config_with_empty_default_group(config): + config.datafile.text = strip(""" + location: deps + sources: + - repo: https://github.com/jacebrowning/gitman-demo + name: gitman_1 + type: git + params: + sparse_paths: + - + rev: example-branch + links: + - + scripts: + - + patches: + - + - repo: https://github.com/jacebrowning/gitman-demo + name: gitman_2 + type: git + params: + sparse_paths: + - + rev: example-tag + links: + - + scripts: + - + patches: + - + groups: + - name: empty + members: null + - name: secondary + members: + - gitman_2 + default_group: 'empty' + """) + config.datafile.load() + + return config + + def it_installs_nothing_when_default_group_empty( + config_with_empty_default_group, + ): + gitman.install(depth=1, force=True) + expect( + os.path.exists( + os.path.join(config_with_empty_default_group.location, "gitman_1") + ) + ) == False + expect( + os.path.exists( + os.path.join(config_with_empty_default_group.location, "gitman_2") + ) + ) == False + + def it_installs_all_sources_when_all_specified_and_empty_default_group( + config_with_empty_default_group, + ): + expect(gitman.install(depth=1, force=True, skip_default_group=True)) == True + expect( + os.path.exists( + os.path.join(config_with_empty_default_group.location, "gitman_1") + ) + ) == True + expect( + os.path.exists( + os.path.join(config_with_empty_default_group.location, "gitman_2") + ) + ) == True + def describe_uninstall(): def it_deletes_dependencies_when_they_exist(config): From f8f8311495e91cca397660a29c3d5fc963873e99 Mon Sep 17 00:00:00 2001 From: Paul Hovey Date: Wed, 23 Sep 2026 20:33:47 -0400 Subject: [PATCH 2/5] added another test for good measure --- gitman/tests/test_models_config.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/gitman/tests/test_models_config.py b/gitman/tests/test_models_config.py index 6d2b5632..811e1611 100644 --- a/gitman/tests/test_models_config.py +++ b/gitman/tests/test_models_config.py @@ -180,6 +180,17 @@ def it_returns_all_when_name_all_is_specified(config): ) ) == ["repo1"] + def it_returns_all_when_default_group_is_blank(config): + config.sources = [Source(repo="http://example.com/repo1", name="repo1")] + config.groups = [Group(name="empty", members=None)] + config.default_group = "" + + expect( + config._get_sources_filter( + sources=config.sources, skip_default_group=False + ) + ) == ["repo1"] + class TestLoad: def test_load_from_directory_with_config_file(self): From 174106a5d277591dfa30edca4ddd22e6080a0e56 Mon Sep 17 00:00:00 2001 From: Paul Hovey Date: Sat, 3 Oct 2026 14:36:23 -0400 Subject: [PATCH 3/5] fix Windows test bug by using gitman shell rm --- tests/test_api.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 4063ee23..e0bc13c5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,7 +2,6 @@ import inspect import os -import shutil from contextlib import suppress import log @@ -74,8 +73,8 @@ def config(): log.info("Temporary directory: %s", TMP) - with suppress(FileNotFoundError, PermissionError): - shutil.rmtree(TMP) + # 'shell.rm' handles read-only files (e.g. `.git` objects) on Windows + shell.rm(TMP) with suppress(FileExistsError): os.makedirs(TMP) os.chdir(TMP) From bab1e3001de404307c93b55b1da9c996a2befeb4 Mon Sep 17 00:00:00 2001 From: Paul Hovey Date: Sat, 3 Oct 2026 14:42:59 -0400 Subject: [PATCH 4/5] simplified logic against existing tests --- gitman/models/config.py | 9 ++++----- gitman/models/group.py | 10 +++------- gitman/tests/test_models_config.py | 8 ++++---- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/gitman/models/config.py b/gitman/models/config.py index 890bb35c..7b903732 100644 --- a/gitman/models/config.py +++ b/gitman/models/config.py @@ -408,21 +408,20 @@ def _get_sources_filter( ) -> List[str]: """Get a filtered subset of sources.""" names_list = list(names) - if not names_list and not skip_default_group and self.default_group: + if not names_list and not skip_default_group: names_list.append(self.default_group) # Add sources from groups groups_filter = [group for group in self.groups if group.name in names_list] - sources_filter = [ - member for group in groups_filter for member in (group.members or []) - ] + sources_filter = [member for group in groups_filter for member in group.members] # Add independent sources sources_filter.extend( [source.name for source in sources if source.name in names_list] ) - # Fall back to all sources if allowed + # Fall back to all sources if allowed, unless a group matched (an + # empty group is intentionally empty, not a request for everything) if not sources_filter and not groups_filter: if names and names_list != ["all"]: log.warn(f"No dependencies match: {' '.join(names)}") diff --git a/gitman/models/group.py b/gitman/models/group.py index 56367f97..79bdb5d6 100644 --- a/gitman/models/group.py +++ b/gitman/models/group.py @@ -1,5 +1,5 @@ -from dataclasses import dataclass, field -from typing import List, Optional +from dataclasses import dataclass +from typing import List @dataclass @@ -7,11 +7,7 @@ class Group: """A group with sources.""" name: str - members: Optional[List[str]] = field(default_factory=list) - - def __post_init__(self): - if self.members is None: - self.members = [] + members: List[str] def __repr__(self): return "".format(self) diff --git a/gitman/tests/test_models_config.py b/gitman/tests/test_models_config.py index 811e1611..d5c59318 100644 --- a/gitman/tests/test_models_config.py +++ b/gitman/tests/test_models_config.py @@ -138,7 +138,7 @@ def it_can_get_dependency_path(config): def describe_get_sources_filter(): def it_returns_empty_when_default_group_has_no_members(config): config.sources = [Source(repo="http://example.com/repo1", name="repo1")] - config.groups = [Group(name="empty", members=None)] + config.groups = [Group(name="empty", members=[])] config.default_group = "empty" expect( @@ -160,7 +160,7 @@ def it_returns_empty_when_empty_group_specified_by_name(config): def it_returns_all_when_skip_default_group_is_true(config): config.sources = [Source(repo="http://example.com/repo1", name="repo1")] - config.groups = [Group(name="empty", members=None)] + config.groups = [Group(name="empty", members=[])] config.default_group = "empty" expect( @@ -171,7 +171,7 @@ def it_returns_all_when_skip_default_group_is_true(config): def it_returns_all_when_name_all_is_specified(config): config.sources = [Source(repo="http://example.com/repo1", name="repo1")] - config.groups = [Group(name="empty", members=None)] + config.groups = [Group(name="empty", members=[])] config.default_group = "empty" expect( @@ -182,7 +182,7 @@ def it_returns_all_when_name_all_is_specified(config): def it_returns_all_when_default_group_is_blank(config): config.sources = [Source(repo="http://example.com/repo1", name="repo1")] - config.groups = [Group(name="empty", members=None)] + config.groups = [Group(name="empty", members=[])] config.default_group = "" expect( From 7af18aeee154aa7da433864b010ca04b3dbfae2b Mon Sep 17 00:00:00 2001 From: Paul Hovey Date: Mon, 12 Oct 2026 20:01:42 -0400 Subject: [PATCH 5/5] hopefully better fix windows flakiness --- tests/test_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index e0bc13c5..e9e50888 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -73,6 +73,9 @@ def config(): log.info("Temporary directory: %s", TMP) + # Leave TMP first: Windows can't delete the current working directory, + # and other tests (e.g. `Config.install_dependencies`) may leave us inside it + os.chdir(ROOT) # 'shell.rm' handles read-only files (e.g. `.git` objects) on Windows shell.rm(TMP) with suppress(FileExistsError):