Skip to content

Commit 4233efd

Browse files
committed
feat(bundler): support git gems
Signed-off-by: Roman Blanco <rblanco@redhat.com>
1 parent b04ef85 commit 4233efd

6 files changed

Lines changed: 121 additions & 53 deletions

File tree

hermeto/core/package_managers/bundler/gem_models.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,17 +146,13 @@ def download_to(self, deps_dir: RootedPath) -> None:
146146
git_repo_path.path.mkdir(parents=True)
147147

148148
log.info("Cloning git repository %s", self.url)
149-
repo = GitRepo.clone_from(
149+
GitRepo.clone_from(
150150
url=str(self.url),
151151
to_path=git_repo_path.path,
152+
bare=True,
152153
env={"GIT_TERMINAL_PROMPT": "0"},
153154
)
154155

155-
if self.branch is not None:
156-
repo.git.checkout(self.branch)
157-
158-
repo.git.reset("--hard", self.ref)
159-
160156

161157
class PathDependency(_GemMetadata):
162158
"""

hermeto/core/package_managers/bundler/main.py

Lines changed: 49 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,6 @@
3333
def fetch_bundler_source(request: Request) -> RequestOutput:
3434
"""Resolve and process all bundler packages."""
3535
components: list[Component] = []
36-
environment_variables: list[EnvironmentVariable] = (
37-
_prepare_environment_variables_for_hermetic_build()
38-
)
3936
project_files: list[ProjectFile] = []
4037
git_paths = []
4138

@@ -48,9 +45,11 @@ def fetch_bundler_source(request: Request) -> RequestOutput:
4845
)
4946
components.extend(_comps)
5047
git_paths.extend(_git_paths)
51-
project_files.append(
52-
_prepare_for_hermetic_build(request.source_dir, request.output_dir, git_paths)
48+
49+
environment_variables: list[EnvironmentVariable] = (
50+
_prepare_environment_variables_for_hermetic_build(git_paths)
5351
)
52+
project_files.append(_prepare_for_hermetic_build(request.source_dir, request.output_dir))
5453

5554
annotations = []
5655
if backend_annotation := create_backend_annotation(components, "bundler"):
@@ -63,17 +62,17 @@ def fetch_bundler_source(request: Request) -> RequestOutput:
6362
)
6463

6564

66-
# Aliases for git dependency name and git dependency name as
67-
# it is written to file system:
65+
# Aliases for git dependency name, file system name, and remote URL:
6866
DepName = str
6967
FSDepName = str
68+
DepURL = str
7069

7170

7271
def _resolve_bundler_package(
7372
package_dir: RootedPath,
7473
output_dir: RootedPath,
7574
binary_filters: BundlerBinaryFilters | None = None,
76-
) -> tuple[list[Component], list[tuple[DepName, FSDepName]]]:
75+
) -> tuple[list[Component], list[tuple[DepName, FSDepName, DepURL]]]:
7776
"""Process a request for a single bundler package."""
7877
deps_dir = output_dir.join_within_root("deps", "bundler")
7978
deps_dir.path.mkdir(parents=True, exist_ok=True)
@@ -104,7 +103,7 @@ def _resolve_bundler_package(
104103
else:
105104
properties = []
106105
if isinstance(dep, GitDependency):
107-
git_paths.append((dep.name, dep.repo_name + "-" + dep.ref[:12]))
106+
git_paths.append((dep.name, dep.repo_name + "-" + dep.ref[:12], str(dep.url)))
108107

109108
c = Component(name=dep.name, version=dep.version, purl=dep.purl, properties=properties)
110109
components.append(c)
@@ -183,16 +182,50 @@ def _get_repo_name_from_origin_remote(package_dir: RootedPath) -> str:
183182
return str(resolved_path)
184183

185184

186-
def _prepare_environment_variables_for_hermetic_build() -> list[EnvironmentVariable]:
187-
return [
185+
def _prepare_environment_variables_for_hermetic_build(
186+
git_paths: list[tuple[DepName, FSDepName, DepURL]] | None = None,
187+
) -> list[EnvironmentVariable]:
188+
env_vars = [
188189
# Contains path to a directory where a new config could be found.
189190
EnvironmentVariable(name="BUNDLE_APP_CONFIG", value="${output_dir}/" + CONFIG_OVERRIDE),
190191
]
191-
192-
193-
def _prepare_for_hermetic_build(
194-
source_dir: RootedPath, output_dir: RootedPath, git_paths: list | None = None
195-
) -> ProjectFile:
192+
if git_paths:
193+
# Redirect git remote URLs to pre-fetched local clones via git's
194+
# GIT_CONFIG_COUNT/KEY/VALUE mechanism. This injects url.insteadOf
195+
# entries without replacing the global git config.
196+
# See: https://git-scm.com/docs/git-config#ENVIRONMENT
197+
_check_for_duplicate_git_urls(git_paths)
198+
env_vars.append(EnvironmentVariable(name="GIT_CONFIG_COUNT", value=str(len(git_paths))))
199+
for idx, (_packname, dirname, url) in enumerate(git_paths):
200+
clone_file_url = "file://${output_dir}/deps/bundler/" + dirname + "/"
201+
env_vars.append(
202+
EnvironmentVariable(
203+
name=f"GIT_CONFIG_KEY_{idx}",
204+
value=f"url.{clone_file_url}.insteadOf",
205+
)
206+
)
207+
env_vars.append(EnvironmentVariable(name=f"GIT_CONFIG_VALUE_{idx}", value=url))
208+
return env_vars
209+
210+
211+
def _check_for_duplicate_git_urls(
212+
git_paths: list[tuple[DepName, FSDepName, DepURL]],
213+
) -> None:
214+
"""Raise if multiple git deps share the same URL with different revisions."""
215+
url_to_dirs: dict[str, set[str]] = {}
216+
for _packname, dirname, url in git_paths:
217+
url_to_dirs.setdefault(url, set()).add(dirname)
218+
for url, dirs in url_to_dirs.items():
219+
if len(dirs) > 1:
220+
raise UnsupportedFeature(
221+
f"Multiple git dependencies point to the same repository ({url}) "
222+
f"but use different revisions: {', '.join(sorted(dirs))}. "
223+
"This is not supported because git's url.insteadOf redirect "
224+
"can only map a repository URL to a single local clone."
225+
)
226+
227+
228+
def _prepare_for_hermetic_build(source_dir: RootedPath, output_dir: RootedPath) -> ProjectFile:
196229
"""Prepare a package for hermetic build by injecting necessary config."""
197230
potential_bundle_config = source_dir.join_within_root(".bundle/config").path
198231
hermetic_config = dedent(
@@ -205,30 +238,6 @@ def _prepare_for_hermetic_build(
205238
BUNDLE_VERSION: "system"
206239
"""
207240
)
208-
# Note: if a package depends on a git revision then the following variables
209-
# are necessary for a hermetic build:
210-
# BUNDLE_DISABLE_LOCAL_BRANCH_CHECK
211-
# BUNDLE_DISABLE_LOCAL_REVISION_CHECK
212-
# because otherwise some (potentially all, depending on exact set of
213-
# ecosystem components versions, environment variables and celestial
214-
# alignment) Bundler versions will try to fetch the latest changes of the
215-
# remotes which may be present even when instructed not to with --local
216-
# flag.
217-
# See https://bundler.io/guides/git.html#local-git-repos for details.
218-
# (or https://github.com/rubygems/bundler-site/blob/
219-
# 9ff3b76e9866524ecefe165633ffb547f0004a99/source/guides/git.html.md
220-
# if the link above ceases to exist).
221-
if git_paths is not None:
222-
hermetic_config += 'BUNDLE_DISABLE_LOCAL_BRANCH_CHECK: "true"\n'
223-
hermetic_config += 'BUNDLE_DISABLE_LOCAL_REVISION_CHECK: "true"\n'
224-
for packname, dirname in git_paths:
225-
# "-" in variable names is deprecated in Bundler and now generates
226-
# a warning and a suggestion to replace all dashes with triple
227-
# underscores. Package names sometimes contain dashes:
228-
varname = "BUNDLE_LOCAL." + packname.upper().replace("-", "___")
229-
location = "${output_dir}/deps/bundler/" + dirname
230-
config_entry = varname + f': "{location}"'
231-
hermetic_config += f"{config_entry}\n"
232241
if potential_bundle_config.is_file():
233242
config_data = potential_bundle_config.read_text()
234243
config_data += hermetic_config

tests/integration/test_data/bundler_e2e/.build-config.yaml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
environment_variables:
22
- name: BUNDLE_APP_CONFIG
33
value: ${output_dir}/bundler/config_override
4+
- name: GIT_CONFIG_COUNT
5+
value: "1"
6+
- name: GIT_CONFIG_KEY_0
7+
value: url.file://${output_dir}/deps/bundler/json-schema-26487618a684/.insteadOf
8+
- name: GIT_CONFIG_VALUE_0
9+
value: https://github.com/3scale/json-schema
410
project_files:
511
- abspath: ${test_case_tmp_path}/hermeto-output/bundler/config_override/config
612
template: |2
@@ -11,6 +17,3 @@ project_files:
1117
BUNDLE_ALLOW_OFFLINE_INSTALL: "true"
1218
BUNDLE_DISABLE_VERSION_CHECK: "true"
1319
BUNDLE_VERSION: "system"
14-
BUNDLE_DISABLE_LOCAL_BRANCH_CHECK: "true"
15-
BUNDLE_DISABLE_LOCAL_REVISION_CHECK: "true"
16-
BUNDLE_LOCAL.JSON___SCHEMA: "${output_dir}/deps/bundler/json-schema-26487618a684"

tests/integration/test_data/bundler_e2e_missing_gemspec/.build-config.yaml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
environment_variables:
22
- name: BUNDLE_APP_CONFIG
33
value: ${output_dir}/bundler/config_override
4+
- name: GIT_CONFIG_COUNT
5+
value: "1"
6+
- name: GIT_CONFIG_KEY_0
7+
value: url.file://${output_dir}/deps/bundler/json-schema-26487618a684/.insteadOf
8+
- name: GIT_CONFIG_VALUE_0
9+
value: https://github.com/3scale/json-schema
410
project_files:
511
- abspath: ${test_case_tmp_path}/hermeto-output/bundler/config_override/config
612
template: |2
@@ -11,6 +17,3 @@ project_files:
1117
BUNDLE_ALLOW_OFFLINE_INSTALL: "true"
1218
BUNDLE_DISABLE_VERSION_CHECK: "true"
1319
BUNDLE_VERSION: "system"
14-
BUNDLE_DISABLE_LOCAL_BRANCH_CHECK: "true"
15-
BUNDLE_DISABLE_LOCAL_REVISION_CHECK: "true"
16-
BUNDLE_LOCAL.JSON___SCHEMA: "${output_dir}/deps/bundler/json-schema-26487618a684"

tests/unit/package_managers/bundler/test_main.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from git.repo import Repo
77

88
from hermeto.core.constants import Mode
9-
from hermeto.core.errors import NotAGitRepo, PackageRejected
9+
from hermeto.core.errors import NotAGitRepo, PackageRejected, UnsupportedFeature
10+
from hermeto.core.models.output import EnvironmentVariable
1011
from hermeto.core.package_managers.bundler.main import (
1112
_get_main_package_name_and_version,
1213
_get_repo_name_from_origin_remote,
14+
_prepare_environment_variables_for_hermetic_build,
1315
_prepare_for_hermetic_build,
1416
)
1517
from hermeto.core.package_managers.bundler.parser import (
@@ -155,6 +157,59 @@ def test_prepare_for_hermetic_build_injects_necessary_variable_into_existing_alt
155157
assert result.template == existing_preamble + expected_alternate_config_contents
156158

157159

160+
def test_prepare_environment_variables_generates_git_config_entries_for_git_deps() -> None:
161+
git_paths = [
162+
(
163+
"yabeda-activejob",
164+
"yabeda-activejob-1894d97a925d",
165+
"https://github.com/org/yabeda-activejob.git",
166+
),
167+
("my-gem", "my-gem-abc123def456", "https://github.com/org/my-gem.git"),
168+
]
169+
170+
result = _prepare_environment_variables_for_hermetic_build(git_paths)
171+
172+
assert EnvironmentVariable(name="GIT_CONFIG_COUNT", value="2") in result
173+
assert (
174+
EnvironmentVariable(
175+
name="GIT_CONFIG_KEY_0",
176+
value="url.file://${output_dir}/deps/bundler/yabeda-activejob-1894d97a925d/.insteadOf",
177+
)
178+
in result
179+
)
180+
assert (
181+
EnvironmentVariable(
182+
name="GIT_CONFIG_VALUE_0",
183+
value="https://github.com/org/yabeda-activejob.git",
184+
)
185+
in result
186+
)
187+
assert (
188+
EnvironmentVariable(
189+
name="GIT_CONFIG_KEY_1",
190+
value="url.file://${output_dir}/deps/bundler/my-gem-abc123def456/.insteadOf",
191+
)
192+
in result
193+
)
194+
assert (
195+
EnvironmentVariable(
196+
name="GIT_CONFIG_VALUE_1",
197+
value="https://github.com/org/my-gem.git",
198+
)
199+
in result
200+
)
201+
202+
203+
def test_prepare_environment_variables_raises_on_duplicate_git_url() -> None:
204+
git_paths = [
205+
("gem-a", "gem-a-aabbccdd1234", "https://github.com/org/monorepo.git"),
206+
("gem-b", "gem-b-eeff00112233", "https://github.com/org/monorepo.git"),
207+
]
208+
209+
with pytest.raises(UnsupportedFeature, match="same repository"):
210+
_prepare_environment_variables_for_hermetic_build(git_paths)
211+
212+
158213
@mock.patch("hermeto.core.package_managers.bundler.main.get_repo_id")
159214
def test_get_repo_name_raises_without_git_repo(
160215
mock_handle_get_repo_id: mock.Mock,

tests/unit/package_managers/bundler/test_parser.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ def test_download_git_dependency_works(
262262
mock_git_clone.assert_called_once_with(
263263
url=str(dep.url),
264264
to_path=dep_path,
265+
bare=True,
265266
env={"GIT_TERMINAL_PROMPT": "0"},
266267
)
267268
assert dep_path.exists()
@@ -288,6 +289,7 @@ def test_download_duplicate_git_dependency_is_skipped(
288289
mock_git_clone.assert_called_once_with(
289290
url=str(dep.url),
290291
to_path=dep_path,
292+
bare=True,
291293
env={"GIT_TERMINAL_PROMPT": "0"},
292294
)
293295
assert dep_path.exists()

0 commit comments

Comments
 (0)