Browse Source

install(DIRECTORY): Add EXCLUDE_EMPTY_DIRECTORIES option

EXCLUDE_EMPTY_DIRECTORIES option excludes empty directories under the
directory to install. A directory is considered not empty if and only if
the directory contains at least one file or one symbolic link or one
none-empty sub-directory.

Closes: #19189
Hao Dong 6 months ago
parent
commit
b70ef48b27

+ 8 - 0
Help/command/install.rst

@@ -648,6 +648,7 @@ Signatures
             [USE_SOURCE_PERMISSIONS] [OPTIONAL] [MESSAGE_NEVER]
             [CONFIGURATIONS <config>...]
             [COMPONENT <component>] [EXCLUDE_FROM_ALL]
+            [EXCLUDE_EMPTY_DIRECTORIES]
             [FILES_MATCHING]
             [<match-rule> <match-option>...]...
             )
@@ -750,6 +751,13 @@ Signatures
 
     Disable file installation status output.
 
+  ``EXCLUDE_EMPTY_DIRECTORIES``
+    .. versionadded:: 4.1
+
+    Exclude empty directories from installation.  A directory is
+    considered empty if it contains no files, no symbolic links,
+    and no non-empty subdirectories.
+
   ``FILES_MATCHING``
     This option may be given before the first ``<match-rule>`` to
     disable installation of files (but not directories) not matched

+ 6 - 0
Help/release/dev/install-DIRECTORY-exclude-empty.rst

@@ -0,0 +1,6 @@
+install-DIRECTORY-exclude-empty
+-------------------------------
+
+* The :command:`install(DIRECTORY)` command gained a new
+  ``EXCLUDE_EMPTY_DIRECTORIES`` option to skip installation
+  of empty directories.

+ 39 - 0
Source/cmFileCopier.cxx

@@ -25,6 +25,10 @@
 
 #include <cstring>
 #include <sstream>
+#include <utility>
+
+#include <cm/string_view>
+#include <cmext/string_view>
 
 using namespace cmFSPermissions;
 
@@ -294,6 +298,13 @@ bool cmFileCopier::CheckKeyword(std::string const& arg)
       this->Doing = DoingNone;
       this->MatchlessFiles = false;
     }
+  } else if (arg == "EXCLUDE_EMPTY_DIRECTORIES") {
+    if (this->CurrentMatchRule) {
+      this->NotAfterMatch(arg);
+    } else {
+      this->Doing = DoingNone;
+      this->ExcludeEmptyDirectories = true;
+    }
   } else {
     return false;
   }
@@ -647,6 +658,29 @@ bool cmFileCopier::InstallFile(std::string const& fromFile,
   return this->SetPermissions(toFile, permissions);
 }
 
+static bool IsEmptyDirectory(std::string const& path,
+                             std::unordered_map<std::string, bool>& cache)
+{
+  auto i = cache.find(path);
+  if (i == cache.end()) {
+    bool isEmpty = (!cmSystemTools::FileIsSymlink(path) &&
+                    cmSystemTools::FileIsDirectory(path));
+    if (isEmpty) {
+      cmsys::Directory d;
+      d.Load(path);
+      unsigned long numFiles = d.GetNumberOfFiles();
+      for (unsigned long fi = 0; isEmpty && fi < numFiles; ++fi) {
+        std::string const& name = d.GetFileName(fi);
+        if (name != "."_s && name != ".."_s) {
+          isEmpty = IsEmptyDirectory(d.GetFilePath(fi), cache);
+        }
+      }
+    }
+    i = cache.emplace(path, isEmpty).first;
+  }
+  return i->second;
+}
+
 bool cmFileCopier::InstallDirectory(std::string const& source,
                                     std::string const& destination,
                                     MatchProperties match_properties)
@@ -719,6 +753,11 @@ bool cmFileCopier::InstallDirectory(std::string const& source,
           strcmp(dir.GetFile(fileNum), "..") == 0)) {
       std::string fromPath = cmStrCat(source, '/', dir.GetFile(fileNum));
       std::string toPath = cmStrCat(destination, '/', dir.GetFile(fileNum));
+      if (this->ExcludeEmptyDirectories &&
+          IsEmptyDirectory(fromPath, this->DirEmptyCache)) {
+        continue;
+      }
+
       if (!this->Install(fromPath, toPath)) {
         return false;
       }

+ 3 - 0
Source/cmFileCopier.h

@@ -5,6 +5,7 @@
 #include "cmConfigure.h" // IWYU pragma: keep
 
 #include <string>
+#include <unordered_map>
 #include <vector>
 
 #include "cmsys/RegularExpression.hxx"
@@ -30,6 +31,7 @@ protected:
   char const* Name;
   bool Always = false;
   cmFileTimeCache FileTimes;
+  std::unordered_map<std::string, bool> DirEmptyCache;
 
   // Whether to install a file not matching any expression.
   bool MatchlessFiles = true;
@@ -89,6 +91,7 @@ protected:
   bool UseGivenPermissionsFile = false;
   bool UseGivenPermissionsDir = false;
   bool UseSourcePermissions = true;
+  bool ExcludeEmptyDirectories = false;
   bool FollowSymlinkChain = false;
   std::string Destination;
   std::string FilesFromDir;

+ 8 - 0
Source/cmInstallCommand.cxx

@@ -1805,6 +1805,14 @@ bool HandleDirectoryMode(std::vector<std::string> const& args,
       }
       exclude_from_all = true;
       doing = DoingNone;
+    } else if (args[i] == "EXCLUDE_EMPTY_DIRECTORIES") {
+      if (in_match_mode) {
+        status.SetError(cmStrCat(args[0], " does not allow \"", args[i],
+                                 "\" after PATTERN or REGEX."));
+        return false;
+      }
+      literal_args += " EXCLUDE_EMPTY_DIRECTORIES";
+      doing = DoingNone;
     } else if (doing == DoingDirs) {
       // Convert this directory to a full path.
       std::string dir = args[i];

+ 30 - 0
Tests/RunCMake/install/DIRECTORY-EXCLUDE_EMPTY_DIRECTORIES-check.cmake

@@ -0,0 +1,30 @@
+file(REMOVE_RECURSE ${RunCMake_TEST_BINARY_DIR}/prefix)
+
+execute_process(COMMAND ${CMAKE_COMMAND} -P ${RunCMake_TEST_BINARY_DIR}/cmake_install.cmake
+  OUTPUT_VARIABLE out ERROR_VARIABLE err)
+
+set(f ${RunCMake_TEST_BINARY_DIR}/prefix/dir_to_install/empty.txt)
+if(NOT EXISTS "${f}")
+  string(APPEND RunCMake_TEST_FAILED
+    "File was not installed:\n  ${f}\n")
+endif()
+
+set(empty_folder ${RunCMake_TEST_BINARY_DIR}/prefix/dir_to_install/empty_folder)
+if(EXISTS "${empty_folder}")
+  string(APPEND RunCMake_TEST_FAILED
+    "empty_folder should not have be installed:\n  ${empty_folder}\n")
+endif()
+
+if(UNIX)
+  set(folder_with_symlink ${RunCMake_TEST_BINARY_DIR}/prefix/dir_to_install/folder_with_symlink)
+  if(NOT EXISTS "${folder_with_symlink}")
+    string(APPEND RunCMake_TEST_FAILED
+      "folder_with_symlink was not installed:\n  ${folder_with_symlink}\n")
+  endif()
+
+  set(symlink_to_empty_txt ${RunCMake_TEST_BINARY_DIR}/prefix/dir_to_install/folder_with_symlink/symlink_to_empty.txt)
+  if(NOT EXISTS "${symlink_to_empty_txt}")
+    string(APPEND RunCMake_TEST_FAILED
+      "symlink_to_empty.txt was not installed:\n  ${symlink_to_empty_txt}\n")
+  endif()
+endif()

+ 27 - 0
Tests/RunCMake/install/DIRECTORY-EXCLUDE_EMPTY_DIRECTORIES.cmake

@@ -0,0 +1,27 @@
+set(CMAKE_INSTALL_MESSAGE "ALWAYS")
+set(CMAKE_INSTALL_PREFIX "${CMAKE_BINARY_DIR}/prefix")
+
+set(DIR_TO_INSTALL "${CMAKE_BINARY_DIR}/dir_to_install")
+file(MAKE_DIRECTORY ${DIR_TO_INSTALL})
+
+file(TOUCH ${DIR_TO_INSTALL}/empty.txt)
+
+# make an empty folder
+file(MAKE_DIRECTORY ${DIR_TO_INSTALL}/empty_folder)
+# make empty subfolders under the empty folder
+file(MAKE_DIRECTORY ${DIR_TO_INSTALL}/empty_folder/empty_subfolder1)
+file(MAKE_DIRECTORY ${DIR_TO_INSTALL}/empty_folder/empty_subfolder2)
+
+if(UNIX)
+  # make an folder with a symlink
+  file(MAKE_DIRECTORY ${DIR_TO_INSTALL}/folder_with_symlink)
+  file(CREATE_LINK ${DIR_TO_INSTALL}/empty.txt
+    ${DIR_TO_INSTALL}/folder_with_symlink/symlink_to_empty.txt
+    SYMBOLIC
+  )
+endif()
+
+install(DIRECTORY ${DIR_TO_INSTALL}
+    DESTINATION ${CMAKE_INSTALL_PREFIX}
+    EXCLUDE_EMPTY_DIRECTORIES
+)

+ 1 - 0
Tests/RunCMake/install/DIRECTORY-PATTERN-EXCLUDE_EMPTY_DIRECTORIES-result.txt

@@ -0,0 +1 @@
+1

+ 5 - 0
Tests/RunCMake/install/DIRECTORY-PATTERN-EXCLUDE_EMPTY_DIRECTORIES-stderr.txt

@@ -0,0 +1,5 @@
+CMake Error at DIRECTORY-PATTERN-EXCLUDE_EMPTY_DIRECTORIES.cmake:[0-9]+ \(install\):
+  install DIRECTORY does not allow "EXCLUDE_EMPTY_DIRECTORIES" after PATTERN
+  or REGEX\.
+Call Stack \(most recent call first\):
+  CMakeLists\.txt:[0-9]+ \(include\)

+ 1 - 0
Tests/RunCMake/install/DIRECTORY-PATTERN-EXCLUDE_EMPTY_DIRECTORIES.cmake

@@ -0,0 +1 @@
+install(DIRECTORY src DESTINATION src PATTERN *.txt EXCLUDE_EMPTY_DIRECTORIES)

+ 2 - 0
Tests/RunCMake/install/RunCMakeTest.cmake

@@ -73,6 +73,8 @@ run_cmake(DIRECTORY-MESSAGE_NEVER)
 run_cmake(DIRECTORY-PATTERN-MESSAGE_NEVER)
 run_cmake(DIRECTORY-message)
 run_cmake(DIRECTORY-message-lazy)
+run_cmake(DIRECTORY-EXCLUDE_EMPTY_DIRECTORIES)
+run_cmake(DIRECTORY-PATTERN-EXCLUDE_EMPTY_DIRECTORIES)
 run_cmake(SkipInstallRulesWarning)
 run_cmake(SkipInstallRulesNoWarning1)
 run_cmake(SkipInstallRulesNoWarning2)