Browse Source

ExternalProject/FetchContent: Support relative remote URLs

Teach `ExternalProject_Add` and `FetchContent_Declare` to resolve
relative remote URLs provided via `GIT_REPOSITORY`.  Add policy
CMP0150 to maintain compatibility.

Fixes: #24211
Co-Authored-By: Craig Scott <[email protected]>
Chris Wright 2 years ago
parent
commit
550f63447d

+ 1 - 0
Help/manual/cmake-policies.7.rst

@@ -57,6 +57,7 @@ Policies Introduced by CMake 3.27
 .. toctree::
 .. toctree::
    :maxdepth: 1
    :maxdepth: 1
 
 
+   CMP0150: ExternalProject_Add and FetchContent_Declare treat relative git repository paths as being relative to parent project's remote. </policy/CMP0150>
    CMP0149: Visual Studio generators select latest Windows SDK by default. </policy/CMP0149>
    CMP0149: Visual Studio generators select latest Windows SDK by default. </policy/CMP0149>
    CMP0148: The FindPythonInterp and FindPythonLibs modules are removed. </policy/CMP0148>
    CMP0148: The FindPythonInterp and FindPythonLibs modules are removed. </policy/CMP0148>
    CMP0147: Visual Studio generators build custom commands in parallel. </policy/CMP0147>
    CMP0147: Visual Studio generators build custom commands in parallel. </policy/CMP0147>

+ 39 - 0
Help/policy/CMP0150.rst

@@ -0,0 +1,39 @@
+CMP0150
+-------
+
+.. versionadded:: 3.27
+
+:command:`ExternalProject_Add` and :command:`FetchContent_Declare` commands
+treat relative ``GIT_REPOSITORY`` paths as being relative to the parent
+project's remote.
+
+Earlier versions of these commands always treated relative paths in
+``GIT_REPOSITORY`` as local paths, but the base directory it was treated
+as relative to was both undocumented and unintuitive.  The ``OLD`` behavior
+for this policy is to interpret relative paths used for ``GIT_REPOSITORY``
+as local paths relative to the following:
+
+* The parent directory of ``SOURCE_DIR`` for :command:`ExternalProject_Add`.
+* ``FETCHCONTENT_BASE_DIR`` for :command:`FetchContent_Declare`.
+
+The ``NEW`` behavior is to determine the remote from the parent project and
+interpret the path relative to that remote.  The value of
+:variable:`CMAKE_CURRENT_SOURCE_DIR` when :command:`ExternalProject_Add` or
+:command:`FetchContent_Declare` is called determines the parent project.
+The remote is selected according to the following (the first match is used):
+
+* If the parent project is checked out on a branch with an upstream remote
+  defined, use that remote.
+* If only one remote is defined, use that remote.
+* If multiple remotes are defined and one of them is named ``origin``, use
+  ``origin``'s remote but also issue a warning.
+
+If an appropriate remote cannot be determined from the above, a fatal error
+will be raised.
+
+This policy was introduced in CMake version 3.27.  CMake version |release|
+warns when a relative path is encountered and the policy is not set,
+falling back to using ``OLD`` behavior.  Use the :command:`cmake_policy`
+command to set it to ``OLD`` or ``NEW`` explicitly.
+
+.. include:: DEPRECATED.txt

+ 7 - 0
Help/release/dev/ExternalProject-FetchContent-relative-git-remotes.rst

@@ -0,0 +1,7 @@
+ExternalProject-FetchContent-Relative-git-remotes
+-------------------------------------------------
+
+* The :module:`ExternalProject` and :module:`FetchContent` modules
+  now resolve relative `GIT_REPOSITORY` paths as relative to the
+  parent project's remote, not as a relative local file system path.
+  See :policy:`CMP0150`.

+ 20 - 0
Modules/ExternalProject.cmake

@@ -278,6 +278,13 @@ External Project Definition
         URL of the git repository. Any URL understood by the ``git`` command
         URL of the git repository. Any URL understood by the ``git`` command
         may be used.
         may be used.
 
 
+        .. versionchanged:: 3.27
+          A relative URL will be resolved based on the parent project's
+          remote, subject to :policy:`CMP0150`.  See the policy documentation
+          for how the remote is selected, including conditions where the
+          remote selection can fail.  Local filesystem remotes should
+          always use absolute paths.
+
       ``GIT_TAG <tag>``
       ``GIT_TAG <tag>``
         Git branch name, tag or commit hash. Note that branch names and tags
         Git branch name, tag or commit hash. Note that branch names and tags
         should generally be specified as remote names (i.e. ``origin/myBranch``
         should generally be specified as remote names (i.e. ``origin/myBranch``
@@ -1188,6 +1195,8 @@ The custom step could then be triggered from the main build like so::
 
 
 #]=======================================================================]
 #]=======================================================================]
 
 
+include(${CMAKE_CURRENT_LIST_DIR}/ExternalProject/shared_internal_commands.cmake)
+
 cmake_policy(PUSH)
 cmake_policy(PUSH)
 cmake_policy(SET CMP0054 NEW) # if() quoted variables not dereferenced
 cmake_policy(SET CMP0054 NEW) # if() quoted variables not dereferenced
 cmake_policy(SET CMP0057 NEW) # if() supports IN_LIST
 cmake_policy(SET CMP0057 NEW) # if() supports IN_LIST
@@ -4159,6 +4168,17 @@ function(ExternalProject_Add name)
     set_property(TARGET ${name} PROPERTY EXCLUDE_FROM_ALL TRUE)
     set_property(TARGET ${name} PROPERTY EXCLUDE_FROM_ALL TRUE)
   endif()
   endif()
 
 
+  get_property(repo TARGET ${name} PROPERTY _EP_GIT_REPOSITORY)
+  if(NOT repo STREQUAL "")
+    cmake_policy(GET CMP0150 cmp0150
+      PARENT_SCOPE # undocumented, do not use outside of CMake
+    )
+    get_property(source_dir TARGET ${name} PROPERTY _EP_SOURCE_DIR)
+    get_filename_component(work_dir "${source_dir}" PATH)
+    _ep_resolve_git_remote(resolved_git_repository "${repo}" "${cmp0150}" "${work_dir}")
+    set_property(TARGET ${name} PROPERTY _EP_GIT_REPOSITORY ${resolved_git_repository})
+  endif()
+
   # The 'complete' step depends on all other steps and creates a
   # The 'complete' step depends on all other steps and creates a
   # 'done' mark.  A dependent external project's 'configure' step
   # 'done' mark.  A dependent external project's 'configure' step
   # depends on the 'done' mark so that it rebuilds when this project
   # depends on the 'done' mark so that it rebuilds when this project

+ 182 - 0
Modules/ExternalProject/shared_internal_commands.cmake

@@ -0,0 +1,182 @@
+cmake_policy(VERSION 3.25)
+
+# Determine the remote URL of the project containing the working_directory.
+# This will leave output_variable unset if the URL can't be determined.
+function(_ep_get_git_remote_url output_variable working_directory)
+  set("${output_variable}" "" PARENT_SCOPE)
+
+  find_package(Git QUIET REQUIRED)
+
+  execute_process(
+    COMMAND ${GIT_EXECUTABLE} symbolic-ref --short HEAD
+    WORKING_DIRECTORY "${working_directory}"
+    OUTPUT_VARIABLE git_symbolic_ref
+    OUTPUT_STRIP_TRAILING_WHITESPACE
+    ERROR_QUIET
+  )
+
+  if(NOT git_symbolic_ref STREQUAL "")
+    # We are potentially on a branch. See if that branch is associated with
+    # an upstream remote (might be just a local one or not a branch at all).
+    execute_process(
+      COMMAND ${GIT_EXECUTABLE} config branch.${git_symbolic_ref}.remote
+      WORKING_DIRECTORY "${working_directory}"
+      OUTPUT_VARIABLE git_remote_name
+      OUTPUT_STRIP_TRAILING_WHITESPACE
+      ERROR_QUIET
+    )
+  endif()
+
+  if(NOT git_remote_name)
+    # Can't select a remote based on a branch. If there's only one remote,
+    # or we have multiple remotes but one is called "origin", choose that.
+    execute_process(
+      COMMAND ${GIT_EXECUTABLE} remote
+      WORKING_DIRECTORY "${working_directory}"
+      OUTPUT_VARIABLE git_remote_list
+      OUTPUT_STRIP_TRAILING_WHITESPACE
+      ERROR_QUIET
+    )
+    string(REPLACE "\n" ";" git_remote_list "${git_remote_list}")
+    list(LENGTH git_remote_list git_remote_list_length)
+
+    if(git_remote_list_length EQUAL 0)
+      message(FATAL_ERROR "Git remote not found in parent project.")
+    elseif(git_remote_list_length EQUAL 1)
+      list(GET git_remote_list 0 git_remote_name)
+    else()
+      set(base_warning_msg "Multiple git remotes found for parent project")
+      if("origin" IN_LIST git_remote_list)
+        message(WARNING "${base_warning_msg}, defaulting to origin.")
+        set(git_remote_name "origin")
+      else()
+        message(FATAL_ERROR "${base_warning_msg}, none of which are origin.")
+      endif()
+    endif()
+  endif()
+
+  if(GIT_VERSION VERSION_LESS 1.7.5)
+    set(_git_remote_url_cmd_args config remote.${git_remote_name}.url)
+  elseif(GIT_VERSION VERSION_LESS 2.7)
+    set(_git_remote_url_cmd_args ls-remote --get-url ${git_remote_name})
+  else()
+    set(_git_remote_url_cmd_args remote get-url ${git_remote_name})
+  endif()
+
+  execute_process(
+    COMMAND ${GIT_EXECUTABLE} ${_git_remote_url_cmd_args}
+    WORKING_DIRECTORY "${working_directory}"
+    OUTPUT_VARIABLE git_remote_url
+    OUTPUT_STRIP_TRAILING_WHITESPACE
+    COMMAND_ERROR_IS_FATAL LAST
+    ENCODING UTF-8   # Needed to handle non-ascii characters in local paths
+  )
+
+  set("${output_variable}" "${git_remote_url}" PARENT_SCOPE)
+endfunction()
+
+function(_ep_is_relative_git_remote output_variable remote_url)
+  if(remote_url MATCHES "^\\.\\./")
+    set("${output_variable}" TRUE PARENT_SCOPE)
+  else()
+    set("${output_variable}" FALSE PARENT_SCOPE)
+  endif()
+endfunction()
+
+# Return an absolute remote URL given an existing remote URL and relative path.
+# The output_variable will be set to an empty string if an absolute URL
+# could not be computed (no error message is output).
+function(_ep_resolve_relative_git_remote
+  output_variable
+  parent_remote_url
+  relative_remote_url
+)
+  set("${output_variable}" "" PARENT_SCOPE)
+
+  if(parent_remote_url STREQUAL "")
+    return()
+  endif()
+
+  string(REGEX MATCH
+    "^(([A-Za-z0-9][A-Za-z0-9+.-]*)://)?(([^/@]+)@)?(\\[[A-Za-z0-9:]+\\]|[^/:]+)?([/:]/?)(.+(\\.git)?/?)$"
+    git_remote_url_components
+    "${parent_remote_url}"
+  )
+
+  set(protocol "${CMAKE_MATCH_1}")
+  set(auth "${CMAKE_MATCH_3}")
+  set(host "${CMAKE_MATCH_5}")
+  set(separator "${CMAKE_MATCH_6}")
+  set(path "${CMAKE_MATCH_7}")
+
+  string(REPLACE "/" ";" remote_path_components "${path}")
+  string(REPLACE "/" ";" relative_path_components "${relative_remote_url}")
+
+  foreach(relative_path_component IN LISTS relative_path_components)
+    if(NOT relative_path_component STREQUAL "..")
+      break()
+    endif()
+
+    list(LENGTH remote_path_components remote_path_component_count)
+
+    if(remote_path_component_count LESS 1)
+      return()
+    endif()
+
+    list(POP_BACK remote_path_components)
+    list(POP_FRONT relative_path_components)
+  endforeach()
+
+  list(APPEND final_path_components ${remote_path_components} ${relative_path_components})
+  list(JOIN final_path_components "/" path)
+
+  set("${output_variable}" "${protocol}${auth}${host}${separator}${path}" PARENT_SCOPE)
+endfunction()
+
+# The output_variable will be set to the original git_repository if it
+# could not be resolved (no error message is output). The original value is
+# also returned if it doesn't need to be resolved.
+function(_ep_resolve_git_remote
+  output_variable
+  git_repository
+  cmp0150
+  cmp0150_old_base_dir
+)
+  if(git_repository STREQUAL "")
+    set("${output_variable}" "" PARENT_SCOPE)
+    return()
+  endif()
+
+  _ep_is_relative_git_remote(_git_repository_is_relative "${git_repository}")
+
+  if(NOT _git_repository_is_relative)
+    set("${output_variable}" "${git_repository}" PARENT_SCOPE)
+    return()
+  endif()
+
+  if(cmp0150 STREQUAL "NEW")
+    _ep_get_git_remote_url(_parent_git_remote_url "${CMAKE_CURRENT_SOURCE_DIR}")
+    _ep_resolve_relative_git_remote(_resolved_git_remote_url "${_parent_git_remote_url}" "${git_repository}")
+
+    if(_resolved_git_remote_url STREQUAL "")
+      message(FATAL_ERROR
+        "Failed to resolve relative git remote URL:\n"
+        "  Relative URL: ${git_repository}\n"
+        "  Parent URL:   ${_parent_git_remote_url}"
+      )
+    endif()
+    set("${output_variable}" "${_resolved_git_remote_url}" PARENT_SCOPE)
+    return()
+  elseif(cmp0150 STREQUAL "")
+    cmake_policy(GET_WARNING CMP0150 _cmp0150_warning)
+    message(AUTHOR_WARNING
+      "${_cmp0150_warning}\n"
+      "A relative GIT_REPOSITORY path was detected. "
+      "This will be interpreted as a local path to where the project is being cloned. "
+      "Set GIT_REPOSITORY to an absolute path or set policy CMP0150 to NEW to avoid "
+      "this warning."
+    )
+  endif()
+
+  set("${output_variable}" "${cmp0150_old_base_dir}/${git_repository}" PARENT_SCOPE)
+endfunction()

+ 27 - 0
Modules/FetchContent.cmake

@@ -1076,6 +1076,8 @@ current working directory.
 
 
 #]=======================================================================]
 #]=======================================================================]
 
 
+include(${CMAKE_CURRENT_LIST_DIR}/ExternalProject/shared_internal_commands.cmake)
+
 #=======================================================================
 #=======================================================================
 # Recording and retrieving content details for later population
 # Recording and retrieving content details for later population
 #=======================================================================
 #=======================================================================
@@ -1223,6 +1225,7 @@ function(FetchContent_Declare contentName)
   # cannot check for multi-value arguments with this method. We will have to
   # cannot check for multi-value arguments with this method. We will have to
   # handle the URL keyword differently.
   # handle the URL keyword differently.
   set(oneValueArgs
   set(oneValueArgs
+    GIT_REPOSITORY
     SVN_REPOSITORY
     SVN_REPOSITORY
     DOWNLOAD_NO_EXTRACT
     DOWNLOAD_NO_EXTRACT
     DOWNLOAD_EXTRACT_TIMESTAMP
     DOWNLOAD_EXTRACT_TIMESTAMP
@@ -1242,6 +1245,30 @@ function(FetchContent_Declare contentName)
     set(ARG_SOURCE_DIR "${FETCHCONTENT_BASE_DIR}/${contentNameLower}-src")
     set(ARG_SOURCE_DIR "${FETCHCONTENT_BASE_DIR}/${contentNameLower}-src")
   endif()
   endif()
 
 
+  if(ARG_GIT_REPOSITORY)
+    # We resolve the GIT_REPOSITORY here so that we get the right parent in the
+    # remote selection logic. In the sub-build, ExternalProject_Add() would see
+    # the private sub-build directory as the parent project, but the parent
+    # project should be the one that called FetchContent_Declare(). We resolve
+    # a relative repo here so that the sub-build's ExternalProject_Add() only
+    # ever sees a non-relative repo.
+    # Since these checks may be non-trivial on some platforms (notably Windows),
+    # don't perform them if we won't be using these details. This also allows
+    # projects to override calls with relative URLs when they have checked out
+    # the parent project in an unexpected way, such as from a mirror or fork.
+    set(savedDetailsPropertyName "_FetchContent_${contentNameLower}_savedDetails")
+    get_property(alreadyDefined GLOBAL PROPERTY ${savedDetailsPropertyName} DEFINED)
+    if(NOT alreadyDefined)
+      cmake_policy(GET CMP0150 cmp0150
+        PARENT_SCOPE # undocumented, do not use outside of CMake
+      )
+      _ep_resolve_git_remote(_resolved_git_repository
+        "${ARG_GIT_REPOSITORY}" "${cmp0150}" "${FETCHCONTENT_BASE_DIR}"
+      )
+      set(ARG_GIT_REPOSITORY "${_resolved_git_repository}")
+    endif()
+  endif()
+
   if(ARG_SVN_REPOSITORY)
   if(ARG_SVN_REPOSITORY)
     # Add a hash of the svn repository URL to the source dir. This works
     # Add a hash of the svn repository URL to the source dir. This works
     # around the problem where if the URL changes, the download would
     # around the problem where if the URL changes, the download would

+ 6 - 1
Source/cmPolicies.h

@@ -450,7 +450,12 @@ class cmMakefile;
          27, 0, cmPolicies::WARN)                                             \
          27, 0, cmPolicies::WARN)                                             \
   SELECT(POLICY, CMP0149,                                                     \
   SELECT(POLICY, CMP0149,                                                     \
          "Visual Studio generators select latest Windows SDK by default.", 3, \
          "Visual Studio generators select latest Windows SDK by default.", 3, \
-         27, 0, cmPolicies::WARN)
+         27, 0, cmPolicies::WARN)                                             \
+  SELECT(POLICY, CMP0150,                                                     \
+         "ExternalProject_Add and FetchContent_Declare commands "             \
+         "treat relative GIT_REPOSITORY paths as being relative "             \
+         "to the parent project's remote.",                                   \
+         3, 27, 0, cmPolicies::WARN)
 
 
 #define CM_SELECT_ID(F, A1, A2, A3, A4, A5, A6) F(A1)
 #define CM_SELECT_ID(F, A1, A2, A3, A4, A5, A6) F(A1)
 #define CM_FOR_EACH_POLICY_ID(POLICY)                                         \
 #define CM_FOR_EACH_POLICY_ID(POLICY)                                         \

+ 7 - 0
Tests/RunCMake/CMP0150/CMP0150-NEW-build-stdout.txt

@@ -0,0 +1,7 @@
+.*-- Configured bottom project
+.*ExternalProject for ep-Y
+.*-- Configured bottom project
+[^\n]*-- Completed configuring project middle
+.*-- Configured bottom project
+.*ExternalProject for ep-X
+.*Non-ep top project

+ 107 - 0
Tests/RunCMake/CMP0150/CMP0150-NEW-resolve.cmake

@@ -0,0 +1,107 @@
+include(ExternalProject/shared_internal_commands)
+
+function(test_resolve parentUrl relativeUrl expectedResult)
+  _ep_resolve_relative_git_remote(result "${parentUrl}" "${relativeUrl}")
+  if(NOT result STREQUAL expectedResult)
+    message(SEND_ERROR "URL resolved to unexpected result:\n"
+      "  Expected: ${expectedResult}\n"
+      "  Actual  : ${result}"
+    )
+  endif()
+endfunction()
+
+test_resolve(
+  "https://example.com/group/parent"
+  "../other"
+  "https://example.com/group/other"
+)
+test_resolve(
+  "https://example.com/group/parent"
+  "../../alt/other"
+  "https://example.com/alt/other"
+)
+
+test_resolve(
+  "[email protected]:group/parent"
+  "../other"
+  "[email protected]:group/other"
+)
+test_resolve(
+  "[email protected]:group/parent"
+  "../../alt/other"
+  "[email protected]:alt/other"
+)
+test_resolve(
+  "[email protected]:/group/parent"
+  "../other"
+  "[email protected]:/group/other"
+)
+test_resolve(
+  "[email protected]:/group/parent"
+  "../../alt/other"
+  "[email protected]:/alt/other"
+)
+test_resolve(
+  "git+ssh://[email protected]:group/parent"
+  "../other"
+  "git+ssh://[email protected]:group/other"
+)
+test_resolve(
+  "ssh://[email protected]:1234/group/parent"
+  "../../alt/other"
+  "ssh://[email protected]:1234/alt/other"
+)
+
+test_resolve(
+  "file:///group/parent"
+  "../other"
+  "file:///group/other"
+)
+test_resolve(
+  "file:///group/parent"
+  "../../alt/other"
+  "file:///alt/other"
+)
+test_resolve(
+  "file:///~/group/parent"
+  "../../other"
+  "file:///~/other"
+)
+test_resolve(
+  "/group/parent"
+  "../other"
+  "/group/other"
+)
+test_resolve(
+  "/group/parent"
+  "../../alt/other"
+  "/alt/other"
+)
+test_resolve(
+  "C:/group/parent"
+  "../other"
+  "C:/group/other"
+)
+test_resolve(
+  "C:/group/parent"
+  "../../alt/other"
+  "C:/alt/other"
+)
+
+test_resolve(
+  "x-Test+v1.0://example.com/group/parent"
+  "../other"
+  "x-Test+v1.0://example.com/group/other"
+)
+
+# IPv6 literals
+test_resolve(
+  "http://[::1]/group/parent"
+  "../../alt/other"
+  "http://[::1]/alt/other"
+)
+test_resolve(
+  "git@[::1]:group/parent"
+  "../../alt/other"
+  "git@[::1]:alt/other"
+)

+ 4 - 0
Tests/RunCMake/CMP0150/CMP0150-NEW-stdout.txt

@@ -0,0 +1,4 @@
+-- Configured bottom project
+-- Completed configuring project middle
+-- Completed configuring project top
+-- Configuring done

+ 45 - 0
Tests/RunCMake/CMP0150/CMP0150-NEW.cmake

@@ -0,0 +1,45 @@
+set(policyCommand "cmake_policy(SET CMP0150 NEW)")
+
+# Need to keep paths and file names short to avoid hitting limits on Windows.
+# Directory names "a" through to "g" are used here according to the following:
+#   a   = Top project
+#   b/c = Middle project
+#   d   = Bottom project
+#   e/f = Cloned Top project
+#   g   = Build directory for cloned Top project
+#
+# Dependency names map as follows:
+#   X = middle dependency
+#   Y = bottom dependency
+
+set(projName top)
+set(depName X)
+set(epRelativeGitRepo ../b/c)
+set(fcRelativeGitRepo ../b/c)
+configure_file(CMakeLists.txt.in a/CMakeLists.txt @ONLY)
+initGitRepo("${CMAKE_CURRENT_BINARY_DIR}/a")
+
+set(projName middle)
+set(depName Y)
+set(epRelativeGitRepo ../../d)
+set(fcRelativeGitRepo ../../d)
+configure_file(CMakeLists.txt.in b/c/CMakeLists.txt @ONLY)
+initGitRepo("${CMAKE_CURRENT_BINARY_DIR}/b/c")
+
+file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/d")
+file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/d/CMakeLists.txt" [[
+cmake_minimum_required(VERSION 3.26)
+project(bottom LANGUAGES NONE)
+message(STATUS "Configured bottom project")
+]])
+initGitRepo("${CMAKE_CURRENT_BINARY_DIR}/d")
+
+set(clonedTopDir "${CMAKE_CURRENT_BINARY_DIR}/e/f")
+file(MAKE_DIRECTORY "${clonedTopDir}")
+execGitCommand(${CMAKE_CURRENT_BINARY_DIR} clone --quiet "file://${CMAKE_CURRENT_BINARY_DIR}/a" "${clonedTopDir}")
+add_subdirectory("${clonedTopDir}" "${CMAKE_CURRENT_BINARY_DIR}/g")
+
+# Ensure build order is predictable
+add_custom_target(non-ep-top ALL COMMAND ${CMAKE_COMMAND} -E echo "Non-ep top project")
+add_dependencies(non-ep-top ep-X)
+add_dependencies(ep-X ep-Y)

+ 3 - 0
Tests/RunCMake/CMP0150/CMP0150-OLD-build-stdout.txt

@@ -0,0 +1,3 @@
+.*-- Configured bottom project
+.*ExternalProject for ep-bottom
+.*Non-ep top project

+ 21 - 0
Tests/RunCMake/CMP0150/CMP0150-OLD-common.cmake

@@ -0,0 +1,21 @@
+# There's no point testing more than one level for OLD, since the behavior only
+# depends on the current build, not anything about the parent git repo, etc.
+set(projName top)
+set(depName bottom)
+set(epRelativeGitRepo ../../../Bottom)
+set(fcRelativeGitRepo ../Bottom)
+configure_file(CMakeLists.txt.in Top/CMakeLists.txt @ONLY)
+
+file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/Bottom")
+file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/Bottom/CMakeLists.txt" [[
+cmake_minimum_required(VERSION 3.26)
+project(bottom LANGUAGES NONE)
+message(STATUS "Configured bottom project")
+]])
+initGitRepo("${CMAKE_CURRENT_BINARY_DIR}/Bottom")
+
+add_subdirectory("${CMAKE_CURRENT_BINARY_DIR}/Top" "${CMAKE_CURRENT_BINARY_DIR}/Top-build")
+
+# Ensure build order is predictable
+add_custom_target(non-ep-top ALL COMMAND ${CMAKE_COMMAND} -E echo "Non-ep top project")
+add_dependencies(non-ep-top ep-bottom)

+ 3 - 0
Tests/RunCMake/CMP0150/CMP0150-OLD-stdout.txt

@@ -0,0 +1,3 @@
+-- Configured bottom project
+-- Completed configuring project top
+-- Configuring done

+ 2 - 0
Tests/RunCMake/CMP0150/CMP0150-OLD.cmake

@@ -0,0 +1,2 @@
+set(policyCommand "cmake_policy(SET CMP0150 OLD)")
+include(CMP0150-OLD-common.cmake)

+ 3 - 0
Tests/RunCMake/CMP0150/CMP0150-WARN-build-stdout.txt

@@ -0,0 +1,3 @@
+.*-- Configured bottom project
+.*ExternalProject for ep-bottom
+.*Non-ep top project

+ 25 - 0
Tests/RunCMake/CMP0150/CMP0150-WARN-stderr.txt

@@ -0,0 +1,25 @@
+CMake Warning \(dev\) at .*/Modules/ExternalProject/shared_internal_commands\.cmake:[0-9]+ \(message\):
+  Policy CMP0150 is not set: ExternalProject_Add and FetchContent_Declare
+  commands treat relative GIT_REPOSITORY paths as being relative to the
+  parent project's remote\.  Run "cmake --help-policy CMP0150" for policy
+  details\.  Use the cmake_policy command to set the policy and suppress this
+  warning\.
+
+  A relative GIT_REPOSITORY path was detected\.  This will be interpreted as a
+  local path to where the project is being cloned\.  Set GIT_REPOSITORY to an
+  absolute path or set policy CMP0150 to NEW to avoid this warning\.
+Call Stack \(most recent call first\):
+  .*/Modules/ExternalProject\.cmake:[0-9]+ \(_ep_resolve_git_remote\)
+.*
+CMake Warning \(dev\) at .*/Modules/ExternalProject/shared_internal_commands\.cmake:[0-9]+ \(message\):
+  Policy CMP0150 is not set: ExternalProject_Add and FetchContent_Declare
+  commands treat relative GIT_REPOSITORY paths as being relative to the
+  parent project's remote\.  Run "cmake --help-policy CMP0150" for policy
+  details\.  Use the cmake_policy command to set the policy and suppress this
+  warning\.
+
+  A relative GIT_REPOSITORY path was detected\.  This will be interpreted as a
+  local path to where the project is being cloned\.  Set GIT_REPOSITORY to an
+  absolute path or set policy CMP0150 to NEW to avoid this warning\.
+Call Stack \(most recent call first\):
+  .*/Modules/FetchContent\.cmake:[0-9]+ \(_ep_resolve_git_remote\)

+ 3 - 0
Tests/RunCMake/CMP0150/CMP0150-WARN-stdout.txt

@@ -0,0 +1,3 @@
+-- Configured bottom project
+-- Completed configuring project top
+-- Configuring done

+ 2 - 0
Tests/RunCMake/CMP0150/CMP0150-WARN.cmake

@@ -0,0 +1,2 @@
+set(policyCommand "")
+include(CMP0150-OLD-common.cmake)

+ 27 - 0
Tests/RunCMake/CMP0150/CMakeLists.txt

@@ -0,0 +1,27 @@
+cmake_minimum_required(VERSION 3.25)
+project(${RunCMake_TEST} NONE)
+
+find_package(Git REQUIRED)
+
+function(execGitCommand workDir)
+  execute_process(
+    WORKING_DIRECTORY "${workDir}"
+    COMMAND "${GIT_EXECUTABLE}" ${ARGN}
+    COMMAND_ECHO STDOUT
+    COMMAND_ERROR_IS_FATAL ANY
+  )
+endfunction()
+
+function(initGitRepo workDir)
+  # init.defaultBranch only works with git 2.28 or later, so we must use the
+  # historical default branch name "master". Force the old default in case test
+  # sites have overridden the default to something else.
+  execGitCommand("${workDir}" -c init.defaultBranch=master init)
+  execGitCommand("${workDir}" config user.email "[email protected]")
+  execGitCommand("${workDir}" config user.name testauthor)
+  execGitCommand("${workDir}" config core.autocrlf false)
+  execGitCommand("${workDir}" add CMakeLists.txt)
+  execGitCommand("${workDir}" commit -m "Initial commit")
+endfunction()
+
+include(${RunCMake_TEST}.cmake)

+ 23 - 0
Tests/RunCMake/CMP0150/CMakeLists.txt.in

@@ -0,0 +1,23 @@
+cmake_minimum_required(VERSION 3.25)
+project(@projName@ LANGUAGES NONE)
+
+@policyCommand@
+
+include(ExternalProject)
+ExternalProject_Add(ep-@depName@
+  GIT_REPOSITORY @epRelativeGitRepo@
+  GIT_TAG master
+  GIT_CONFIG init.defaultBranch=master
+  TEST_COMMAND ""
+  INSTALL_COMMAND "${CMAKE_COMMAND}" -E echo "ExternalProject for ep-@depName@"
+)
+
+include(FetchContent)
+FetchContent_Declare(@depName@
+  GIT_REPOSITORY @fcRelativeGitRepo@
+  GIT_TAG master
+  GIT_CONFIG init.defaultBranch=master
+)
+FetchContent_MakeAvailable(@depName@)
+
+message(STATUS "Completed configuring project @projName@")

+ 17 - 0
Tests/RunCMake/CMP0150/RunCMakeTest.cmake

@@ -0,0 +1,17 @@
+include(RunCMake)
+
+function(test_CMP0150 val)
+  set(RunCMake_TEST_BINARY_DIR ${RunCMake_BINARY_DIR}/${val}-build)
+  run_cmake(CMP0150-${val})
+  set(RunCMake_TEST_NO_CLEAN TRUE)
+  # Some git versions write clone messages to stderr. These would cause the
+  # test to fail, so we need to merge them into stdout.
+  set(RunCMake_TEST_OUTPUT_MERGE TRUE)
+  run_cmake_command(CMP0150-${val}-build ${CMAKE_COMMAND} --build .)
+endfunction()
+
+test_CMP0150(WARN)
+test_CMP0150(OLD)
+test_CMP0150(NEW)
+
+run_cmake_script(CMP0150-NEW-resolve)

+ 1 - 0
Tests/RunCMake/CMakeLists.txt

@@ -160,6 +160,7 @@ endif()
 add_RunCMake_test(CMP0132)
 add_RunCMake_test(CMP0132)
 add_RunCMake_test(CMP0135)
 add_RunCMake_test(CMP0135)
 add_RunCMake_test(CMP0139)
 add_RunCMake_test(CMP0139)
+add_RunCMake_test(CMP0150)
 
 
 # The test for Policy 65 requires the use of the
 # The test for Policy 65 requires the use of the
 # CMAKE_SHARED_LIBRARY_LINK_CXX_FLAGS variable, which both the VS and Xcode
 # CMAKE_SHARED_LIBRARY_LINK_CXX_FLAGS variable, which both the VS and Xcode