Repository: iterative/dvc

Issue:
experiments: commit in executor will fail if `pre-commit` or `commit-msg` hooks are enabled
# Bug Report

dulwich hook classes are not pickable, so if either hook is enabled you will currently get:
```
Traceback (most recent call last):
  File "/usr/lib64/python3.7/concurrent/futures/process.py", line 205, in _sendback_result
    exception=exception))
  File "/usr/lib64/python3.7/multiprocessing/queues.py", line 358, in put
    obj = _ForkingPickler.dumps(obj)
  File "/usr/lib64/python3.7/multiprocessing/reduction.py", line 51, in dumps
    cls(buf, protocol).dump(obj)
AttributeError: Can't pickle local object 'CommitMsgShellHook.__init__.<locals>.prepare_msg'
```

We should be committing with `--no-verify` in experiment executors, since our auto-generated experiment commit messages and commit contents will likely not pass (by design) the user's checks. (So even if we can call commit successfully using gitpython instead of dulwich here, the underlying issue would still exist.)

This will require an upstream patch for dulwich backend.


Discussion:

Patch:
diff --git a/dvc/repo/experiments/executor/base.py b/dvc/repo/experiments/executor/base.py
--- a/dvc/repo/experiments/executor/base.py
+++ b/dvc/repo/experiments/executor/base.py
@@ -376,7 +376,7 @@ def commit(
             logger.debug("Commit to new experiment branch '%s'", branch)
 
         scm.gitpython.repo.git.add(update=True)
-        scm.commit(f"dvc: commit experiment {exp_hash}")
+        scm.commit(f"dvc: commit experiment {exp_hash}", no_verify=True)
         new_rev = scm.get_rev()
         scm.set_ref(branch, new_rev, old_ref=old_ref)
         scm.set_ref(EXEC_BRANCH, branch, symbolic=True)
diff --git a/dvc/scm/git/backend/base.py b/dvc/scm/git/backend/base.py
--- a/dvc/scm/git/backend/base.py
+++ b/dvc/scm/git/backend/base.py
@@ -56,7 +56,7 @@ def add(self, paths: Iterable[str]):
         pass
 
     @abstractmethod
-    def commit(self, msg: str):
+    def commit(self, msg: str, no_verify: bool = False):
         pass
 
     @abstractmethod
diff --git a/dvc/scm/git/backend/dulwich.py b/dvc/scm/git/backend/dulwich.py
--- a/dvc/scm/git/backend/dulwich.py
+++ b/dvc/scm/git/backend/dulwich.py
@@ -145,10 +145,14 @@ def add(self, paths: Iterable[str]):
             # NOTE: this doesn't check gitignore, same as GitPythonBackend.add
             self.repo.stage(relpath(fpath, self.root_dir))
 
-    def commit(self, msg: str):
+    def commit(self, msg: str, no_verify: bool = False):
+        from dulwich.errors import CommitError
         from dulwich.porcelain import commit
 
-        commit(self.root_dir, message=msg)
+        try:
+            commit(self.root_dir, message=msg, no_verify=no_verify)
+        except CommitError as exc:
+            raise SCMError("Git commit failed") from exc
 
     def checkout(
         self, branch: str, create_new: Optional[bool] = False, **kwargs,
diff --git a/dvc/scm/git/backend/gitpython.py b/dvc/scm/git/backend/gitpython.py
--- a/dvc/scm/git/backend/gitpython.py
+++ b/dvc/scm/git/backend/gitpython.py
@@ -200,8 +200,13 @@ def add(self, paths: Iterable[str]):
 
             logger.exception(msg)
 
-    def commit(self, msg: str):
-        self.repo.index.commit(msg)
+    def commit(self, msg: str, no_verify: bool = False):
+        from git.exc import HookExecutionError
+
+        try:
+            self.repo.index.commit(msg, skip_hooks=no_verify)
+        except HookExecutionError as exc:
+            raise SCMError("Git pre-commit hook failed") from exc
 
     def checkout(
         self, branch: str, create_new: Optional[bool] = False, **kwargs
diff --git a/dvc/scm/git/backend/pygit2.py b/dvc/scm/git/backend/pygit2.py
--- a/dvc/scm/git/backend/pygit2.py
+++ b/dvc/scm/git/backend/pygit2.py
@@ -86,7 +86,7 @@ def dir(self) -> str:
     def add(self, paths: Iterable[str]):
         raise NotImplementedError
 
-    def commit(self, msg: str):
+    def commit(self, msg: str, no_verify: bool = False):
         raise NotImplementedError
 
     def checkout(
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -50,7 +50,7 @@ def run(self):
     "colorama>=0.3.9",
     "configobj>=5.0.6",
     "gitpython>3",
-    "dulwich>=0.20.14",
+    "dulwich>=0.20.15",
     "pygit2>=1.4.0",
     "setuptools>=34.0.0",
     "nanotime>=0.5.2",


Test patch:
diff --git a/tests/unit/scm/test_git.py b/tests/unit/scm/test_git.py
--- a/tests/unit/scm/test_git.py
+++ b/tests/unit/scm/test_git.py
@@ -301,3 +301,23 @@ def test_ignore_remove_empty(tmp_dir, scm, git):
 
     git.ignore_remove(test_entries[1]["path"])
     assert not path_to_gitignore.exists()
+
+
+@pytest.mark.skipif(
+    os.name == "nt", reason="Git hooks not supported on Windows"
+)
+@pytest.mark.parametrize("hook", ["pre-commit", "commit-msg"])
+def test_commit_no_verify(tmp_dir, scm, git, hook):
+    import stat
+
+    hook_file = os.path.join(".git", "hooks", hook)
+    tmp_dir.gen(
+        hook_file, "#!/usr/bin/env python\nimport sys\nsys.exit(1)",
+    )
+    os.chmod(hook_file, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
+
+    tmp_dir.gen("foo", "foo")
+    git.add(["foo"])
+    with pytest.raises(SCMError):
+        git.commit("commit foo")
+    git.commit("commit foo", no_verify=True)