Repository: conan-io/conan

Issue:
Conan install ignores top-level hooks
To help us debug your issue please explain:

- [x] I've read the [CONTRIBUTING guide](https://github.com/conan-io/conan/blob/develop/.github/CONTRIBUTING.md).
- [x] I've specified the Conan version, operating system version and any tool that can be relevant.
- [x] I've explained the steps to reproduce the error or the motivation/use case of the question/suggestion.

I'm using Conan version 1.11.2 on Windows 10. While trying out the new "hooks" feature I had problems installing them using "conan install".

I did the following:
* Create a folder with the following folder structure
```
myconfig
├── hooks
│   ├── my-hook.py
│   
└── conan.conf
```
* Call `conan config install myconfig`

I would expect, that the "my-hook.py" will be copied in to the ".conan/hooks" directory.
Instead, nothing is copied.

I noticed, that it works, if the hook is in a subfolder, i.e. a structure like 
```
myconfig
├── hooks
│   ├── my-hook
│       |---- my-hook.py
└── conan.conf
```
is copied properly.

Did I miss something? Is it now *required* to use subfolders in the hooks directory?


Discussion:
Hi @jokoala !

Hooks are considered part of Conan client, so the best option is `conan config` as described on Hooks documentation:

https://docs.conan.io/en/latest/extending/hooks.html#storage-activation-and-sharing

Regards!


Citing from the page you mentioned.

> Hooks are considered part of the Conan client configuration and can be shared as usual with the conan config install command. 

This is the reason I experimented with `conan config install`. I want to roll out a certain hook to all developers on my project.
Sorry, you listed `conan install`, it's another command.

Now I'm confuse, did you run `conan install myconfig` or `conan config install myconfig` ?
Oh, sorry, my mistake. I've updated my request. I ran `conan config install` as described and it is unable to copy the files, if there is a flat file hierarchy inside the "hooks" folder.
Seems like there is a bug when installing hooks by `conan config install`. I can reproduce your error:

    mkdir -p foo/hooks
    touch foo/hooks/dummy.py
    conan config install foo
    Installing hooks:

Nothing is copied.

Resolving patch:
diff --git a/conans/client/conf/config_installer.py b/conans/client/conf/config_installer.py
--- a/conans/client/conf/config_installer.py
+++ b/conans/client/conf/config_installer.py
@@ -116,8 +116,8 @@ def _process_download(item, cache, output, tmp_folder, verify_ssl, requester):
         raise ConanException("Error while installing config from %s\n%s" % (item, str(e)))
 
 
-def configuration_install(path_or_url, cache, output, verify_ssl, requester,
-                          config_type=None, args=None):
+def configuration_install(path_or_url, cache, output, verify_ssl, requester, config_type=None,
+                          args=None):
     if path_or_url is None:
         try:
             item = cache.conan_config.get_item("general.config_install")
@@ -202,18 +202,13 @@ def _handle_hooks(src_hooks_path, dst_hooks_path, output):
     :param dst_hooks_path:  Folder where the hooks should finally go
     :param output: Output to indicate the files copied
     """
-    hooks_dirs = []
     for root, dirs, files in walk(src_hooks_path):
-        if root == src_hooks_path:
-            hooks_dirs = dirs
-        else:
-            copied_files = False
-            relpath = os.path.relpath(root, src_hooks_path)
-            for f in files:
-                if ".git" not in f:
-                    dst = os.path.join(dst_hooks_path, relpath)
-                    mkdir(dst)
-                    shutil.copy(os.path.join(root, f), dst)
-                    copied_files = True
-            if copied_files and relpath in hooks_dirs:
+        if ".git" in root:
+            continue
+        relpath = os.path.relpath(root, src_hooks_path)
+        for f in files:
+            if ".git" not in f:
+                dst = os.path.join(dst_hooks_path, relpath)
+                mkdir(dst)
+                shutil.copy(os.path.join(root, f), dst)
                 output.info(" - %s" % relpath)


Test patch:
diff --git a/conans/test/functional/command/config_install_test.py b/conans/test/functional/command/config_install_test.py
--- a/conans/test/functional/command/config_install_test.py
+++ b/conans/test/functional/command/config_install_test.py
@@ -74,7 +74,6 @@ def zipdir(path, zipfilename):
 
 
 class ConfigInstallTest(unittest.TestCase):
-
     def setUp(self):
         self.client = TestClient()
         # Save to the old registry, it has to be migrated
@@ -100,8 +99,7 @@ def setUp(self):
  }
 }        
 """)
-        save(os.path.join(self.client.cache.profiles_path, "default"),
-             "#default profile empty")
+        save(os.path.join(self.client.cache.profiles_path, "default"), "#default profile empty")
         save(os.path.join(self.client.cache.profiles_path, "linux"), "#empty linux profile")
 
         self.old_env = dict(os.environ)
@@ -116,10 +114,16 @@ def _create_profile_folder(self, folder=None):
                             "remotes.txt": remotes,
                             "profiles/linux": linux_profile,
                             "profiles/windows": win_profile,
+                            "hooks/dummy": "#hook dummy",
+                            "hooks/foo.py": "#hook foo",
+                            "hooks/custom/custom.py": "#hook custom",
+                            ".git/hooks/foo": "foo",
+                            "hooks/.git/hooks/before_push": "before_push",
                             "config/conan.conf": conan_conf,
                             "pylintrc": "#Custom pylint",
                             "python/myfuncs.py": myfuncpy,
-                            "python/__init__.py": ""})
+                            "python/__init__.py": ""
+                            })
         return folder
 
     def _create_zip(self, zippath=None):
@@ -132,10 +136,9 @@ def _check(self, install_path):
         settings_path = self.client.cache.settings_path
         self.assertEqual(load(settings_path).splitlines(), settings_yml.splitlines())
         registry = self.client.cache.registry
-        self.assertEqual(registry.remotes.list,
-                         [Remote("myrepo1", "https://myrepourl.net", False),
-                          Remote("my-repo-2", "https://myrepo2.com", True),
-                          ])
+        self.assertEqual(registry.remotes.list, [Remote("myrepo1", "https://myrepourl.net", False),
+                                                 Remote("my-repo-2", "https://myrepo2.com", True),
+                                                 ])
         self.assertEqual(registry.refs.list, {"MyPkg/0.1@user/channel": "my-repo-2"})
         self.assertEqual(sorted(os.listdir(self.client.cache.profiles_path)),
                          sorted(["default", "linux", "windows"]))
@@ -157,8 +160,17 @@ def _check(self, install_path):
         self.assertEqual("#Custom pylint",
                          load(os.path.join(self.client.cache.conan_folder, "pylintrc")))
         self.assertEqual("",
-                         load(os.path.join(self.client.cache.conan_folder, "python",
-                                           "__init__.py")))
+                         load(os.path.join(self.client.cache.conan_folder, "python", "__init__.py")))
+        self.assertEqual("#hook dummy",
+                         load(os.path.join(self.client.cache.conan_folder, "hooks", "dummy")))
+        self.assertEqual("#hook foo",
+                         load(os.path.join(self.client.cache.conan_folder, "hooks", "foo.py")))
+        self.assertEqual("#hook custom",
+                         load(os.path.join(self.client.cache.conan_folder, "hooks", "custom",
+                                           "custom.py")))
+        self.assertFalse(os.path.exists(os.path.join(self.client.cache.conan_folder, "hooks",
+                                                     ".git")))
+        self.assertFalse(os.path.exists(os.path.join(self.client.cache.conan_folder, ".git")))
 
     def reuse_python_test(self):
         zippath = self._create_zip()
@@ -225,8 +237,7 @@ def failed_install_http_test(self):
         """ should install from a http zip
         """
         self.client.run('config install httpnonexisting', assert_error=True)
-        self.assertIn("ERROR: Error while installing config from httpnonexisting",
-                      self.client.out)
+        self.assertIn("ERROR: Error while installing config from httpnonexisting", self.client.out)
 
     def install_repo_test(self):
         """ should install from a git repo
@@ -336,7 +347,7 @@ def my_download(obj, url, filename, **kwargs):  # @UnusedVariable
             self._create_zip(filename)
 
         with patch.object(Downloader, 'download', new=my_download):
-            self.client.run("config install %s" % (fake_url_with_credentials,))
+            self.client.run("config install %s" % fake_url_with_credentials)
 
             # Check credentials are not displayed in output
             self.assertNotIn(fake_url_with_credentials, self.client.out)
@@ -400,8 +411,7 @@ def test_git_checkout_is_possible(self):
             self.client.runner('git add .')
             self.client.runner('git commit -m "my other file"')
             self.client.runner('git checkout master')
-        other_path = os.path.join(self.client.cache.conan_folder, "hooks", "other",
-                                  "other.py")
+        other_path = os.path.join(self.client.cache.conan_folder, "hooks", "other", "other.py")
         self.assertFalse(os.path.exists(other_path))
         self.client.run('config install')
         check_path = os.path.join(folder, ".git")
diff --git a/conans/test/unittests/client/conf/config_installer_test.py b/conans/test/unittests/client/conf/config_installer_test.py
--- a/conans/test/unittests/client/conf/config_installer_test.py
+++ b/conans/test/unittests/client/conf/config_installer_test.py
@@ -1,15 +1,15 @@
 import os
 import unittest
-from parameterized import parameterized
+import tempfile
 
-from conans.client.conf.config_installer import _process_config_install_item
+from conans.client.conf.config_installer import _process_config_install_item, _handle_hooks
 from conans.errors import ConanException
 from conans.test.utils.test_files import temp_folder
-from conans.util.files import save
+from conans.test.utils.tools import TestBufferConanOutput
+from conans.util.files import save, mkdir
 
 
 class ConfigInstallerTests(unittest.TestCase):
-
     def process_config_install_item_test(self):
         config_type, url_or_path, verify_ssl, args = _process_config_install_item(
                 "git, whaterver.url.com/repo.git, False, --recusrive --other -b 0.3.4")
@@ -18,7 +18,8 @@ def process_config_install_item_test(self):
         self.assertFalse(verify_ssl)
         self.assertEqual("--recusrive --other -b 0.3.4", args)
 
-        config_type, url_or_path, verify_ssl, args = _process_config_install_item("whaterver.url.com/repo.git")
+        config_type, url_or_path, verify_ssl, args = _process_config_install_item(
+                "whaterver.url.com/repo.git")
         self.assertEqual("git", config_type)
         self.assertEqual("whaterver.url.com/repo.git", url_or_path)
         self.assertIsNone(verify_ssl)
@@ -29,7 +30,7 @@ def process_config_install_item_test(self):
             config_type, url_or_path, verify_ssl, args = _process_config_install_item(dir_item)
             self.assertEqual("dir", config_type)
             self.assertEqual(dir_path, url_or_path)
-            self.assertTrue(verify_ssl) if dir_item.startswith("dir,")\
+            self.assertTrue(verify_ssl) if dir_item.startswith("dir,") \
                 else self.assertIsNone(verify_ssl)
             self.assertIsNone(args)
 
@@ -39,7 +40,7 @@ def process_config_install_item_test(self):
             config_type, url_or_path, verify_ssl, args = _process_config_install_item(file_item)
             self.assertEqual("file", config_type)
             self.assertEqual(file_path, url_or_path)
-            self.assertTrue(verify_ssl) if file_item.startswith("file,") \
+            self.assertTrue(verify_ssl) if file_item.startswith("file,")\
                 else self.assertIsNone(verify_ssl)
             self.assertIsNone(args)
 
@@ -60,7 +61,28 @@ def process_config_install_item_test(self):
         self.assertEqual("--option", args)
 
         # Test wrong input
-        for item in ["git@github.com:conan-io/conan.git, None"
-                     "file/not/exists.zip"]:
+        for item in ["git@github.com:conan-io/conan.git, None", "file/not/exists.zip"]:
             with self.assertRaisesRegexp(ConanException, "Unable to process config install"):
                 _, _, _, _ = _process_config_install_item(item)
+
+    def handle_hooks_test(self):
+        src_dir = temp_folder()
+        subsrc_dir = os.path.join(src_dir, "foo")
+
+        mkdir(subsrc_dir)
+        save(os.path.join(subsrc_dir, "foo"), "foo")
+        save(os.path.join(src_dir, "bar"), "bar")
+
+        git_dir = os.path.join(src_dir, ".git")
+        mkdir(git_dir)
+        mkdir(os.path.join(git_dir, "hooks"))
+        save(os.path.join(git_dir, "hooks", "before_push"), "before_push")
+
+        target_dir = temp_folder()
+        output = TestBufferConanOutput()
+
+        _handle_hooks(src_hooks_path=src_dir, dst_hooks_path=target_dir, output=output)
+
+        self.assertTrue(os.path.isfile(os.path.join(target_dir, "bar")))
+        self.assertTrue(os.path.isfile(os.path.join(target_dir, "foo", "foo")))
+        self.assertFalse(os.path.isfile(os.path.join(target_dir, ".git", "hooks", "before_push")))