2828log = logging .getLogger (__name__ )
2929
3030CONFIG_OVERRIDE = "bundler/config_override"
31+ GIT_CONFIG_OVERRIDE = "bundler/git_config"
3132
3233
3334def fetch_bundler_source (request : Request ) -> RequestOutput :
3435 """Resolve and process all bundler packages."""
3536 components : list [Component ] = []
36- environment_variables : list [EnvironmentVariable ] = (
37- _prepare_environment_variables_for_hermetic_build ()
38- )
3937 project_files : list [ProjectFile ] = []
4038 git_paths = []
4139
@@ -48,7 +46,11 @@ def fetch_bundler_source(request: Request) -> RequestOutput:
4846 )
4947 components .extend (_comps )
5048 git_paths .extend (_git_paths )
51- project_files .append (
49+
50+ environment_variables : list [EnvironmentVariable ] = (
51+ _prepare_environment_variables_for_hermetic_build (has_git_deps = bool (git_paths ))
52+ )
53+ project_files .extend (
5254 _prepare_for_hermetic_build (request .source_dir , request .output_dir , git_paths )
5355 )
5456
@@ -63,17 +65,17 @@ def fetch_bundler_source(request: Request) -> RequestOutput:
6365 )
6466
6567
66- # Aliases for git dependency name and git dependency name as
67- # it is written to file system:
68+ # Aliases for git dependency name, file system name, and remote URL:
6869DepName = str
6970FSDepName = str
71+ DepURL = str
7072
7173
7274def _resolve_bundler_package (
7375 package_dir : RootedPath ,
7476 output_dir : RootedPath ,
7577 binary_filters : BundlerBinaryFilters | None = None ,
76- ) -> tuple [list [Component ], list [tuple [DepName , FSDepName ]]]:
78+ ) -> tuple [list [Component ], list [tuple [DepName , FSDepName , DepURL ]]]:
7779 """Process a request for a single bundler package."""
7880 deps_dir = output_dir .join_within_root ("deps" , "bundler" )
7981 deps_dir .path .mkdir (parents = True , exist_ok = True )
@@ -104,7 +106,7 @@ def _resolve_bundler_package(
104106 else :
105107 properties = []
106108 if isinstance (dep , GitDependency ):
107- git_paths .append ((dep .name , dep .repo_name + "-" + dep .ref [:12 ]))
109+ git_paths .append ((dep .name , dep .repo_name + "-" + dep .ref [:12 ], str ( dep . url ) ))
108110
109111 c = Component (name = dep .name , version = dep .version , purl = dep .purl , properties = properties )
110112 components .append (c )
@@ -183,16 +185,28 @@ def _get_repo_name_from_origin_remote(package_dir: RootedPath) -> str:
183185 return str (resolved_path )
184186
185187
186- def _prepare_environment_variables_for_hermetic_build () -> list [EnvironmentVariable ]:
187- return [
188+ def _prepare_environment_variables_for_hermetic_build (
189+ has_git_deps : bool = False ,
190+ ) -> list [EnvironmentVariable ]:
191+ env_vars = [
188192 # Contains path to a directory where a new config could be found.
189193 EnvironmentVariable (name = "BUNDLE_APP_CONFIG" , value = "${output_dir}/" + CONFIG_OVERRIDE ),
190194 ]
195+ if has_git_deps :
196+ # Redirect git remote URLs to pre-fetched local clones so that bundler
197+ # installs git gems into BUNDLE_PATH (not in-place from the clone).
198+ # See: https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf
199+ env_vars .append (
200+ EnvironmentVariable (
201+ name = "GIT_CONFIG_GLOBAL" , value = "${output_dir}/" + GIT_CONFIG_OVERRIDE
202+ )
203+ )
204+ return env_vars
191205
192206
193207def _prepare_for_hermetic_build (
194208 source_dir : RootedPath , output_dir : RootedPath , git_paths : list | None = None
195- ) -> ProjectFile :
209+ ) -> list [ ProjectFile ] :
196210 """Prepare a package for hermetic build by injecting necessary config."""
197211 potential_bundle_config = source_dir .join_within_root (".bundle/config" ).path
198212 hermetic_config = dedent (
@@ -205,30 +219,6 @@ def _prepare_for_hermetic_build(
205219 BUNDLE_VERSION: "system"
206220 """
207221 )
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 "
232222 if potential_bundle_config .is_file ():
233223 config_data = potential_bundle_config .read_text ()
234224 config_data += hermetic_config
@@ -241,4 +231,35 @@ def _prepare_for_hermetic_build(
241231 else :
242232 config_data = hermetic_config
243233 overriding_bundler_config_path = output_dir .join_within_root (CONFIG_OVERRIDE , "config" ).path
244- return ProjectFile (abspath = overriding_bundler_config_path , template = config_data )
234+ project_files = [ProjectFile (abspath = overriding_bundler_config_path , template = config_data )]
235+
236+ if git_paths :
237+ # Redirect each git remote URL to its pre-fetched local clone via a git
238+ # url.insteadOf rewrite. This causes bundler to clone from the local
239+ # filesystem (no network) and then check out the gem into BUNDLE_PATH,
240+ # so the gem is present in the runtime image after a multi-stage build.
241+ #
242+ # Using BUNDLE_LOCAL.* instead would make bundler call set_install_path!
243+ # with the clone directory, causing it to use the gem in-place and never
244+ # copy it into BUNDLE_PATH — leaving the gem absent from the runtime image.
245+ url_to_dirs : dict [str , set [str ]] = {}
246+ for _packname , dirname , url in git_paths :
247+ url_to_dirs .setdefault (url , set ()).add (dirname )
248+ for url , dirs in url_to_dirs .items ():
249+ if len (dirs ) > 1 :
250+ raise UnsupportedFeature (
251+ f"Multiple git dependencies point to the same repository ({ url } ) "
252+ f"but use different revisions: { ', ' .join (sorted (dirs ))} . "
253+ "This is not supported because git's url.insteadOf redirect "
254+ "can only map a repository URL to a single local clone."
255+ )
256+ git_config_lines = []
257+ for _packname , dirname , url in git_paths :
258+ clone_file_url = "file://${output_dir}/deps/bundler/" + dirname + "/"
259+ git_config_lines .append (f'[url "{ clone_file_url } "]\n \t insteadOf = { url } \n ' )
260+ git_config_path = output_dir .join_within_root (GIT_CONFIG_OVERRIDE ).path
261+ project_files .append (
262+ ProjectFile (abspath = git_config_path , template = "" .join (git_config_lines ))
263+ )
264+
265+ return project_files
0 commit comments