Repository: iterative/dvc
Pull request #9090 (2023-02-28 09:10:53)

exp: support dvc-task/celery message cleanup
* [x] ❗ I have followed the [Contributing to DVC](https://dvc.org/doc/user-guide/contributing/core) checklist.

* [ ] 📖 If this PR requires [documentation](https://dvc.org/doc) updates, I have created a separate PR (or issue, at least) in [dvc.org](https://github.com/iterative/dvc.org) and linked it here.

Thank you for the contribution - we'll try to review it as soon as possible. 🙏

Closes: https://github.com/iterative/dvc/issues/8676
Related: https://github.com/iterative/dvc/issues/8787

- Adds `dvc exp clean` to cleanup unneeded files from `.dvc/tmp/exps`. Currently only does dvc-task/celery garbage collection
- Celery messages are now automatically garbage collected in the background on queue worker exit (when a complete `dvc queue start` or `dvc exp run --run-all` batch finishes)

codecov[bot]: # [Codecov](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative) Report
Base: **92.97**% // Head: **92.61**% // Decreases project coverage by **`-0.37%`** :warning:
> Coverage data is based on head [(`aba440d`)](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative) compared to base [(`9f0c435`)](https://codecov.io/gh/iterative/dvc/commit/9f0c4350bd4b496e278e87b2a8f6773680ed068f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative).
> Patch coverage: 97.43% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #9090      +/-   ##
==========================================
- Coverage   92.97%   92.61%   -0.37%     
==========================================
  Files         459      461       +2     
  Lines       37112    37149      +37     
  Branches     5365     5366       +1     
==========================================
- Hits        34506    34404     -102     
- Misses       2079     2184     +105     
- Partials      527      561      +34     
```


| [Impacted Files](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative) | Coverage Δ | |
|---|---|---|
| [dvc/commands/experiments/\_\_init\_\_.py](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative#diff-ZHZjL2NvbW1hbmRzL2V4cGVyaW1lbnRzL19faW5pdF9fLnB5) | `100.00% <ø> (ø)` | |
| [dvc/repo/experiments/queue/celery.py](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative#diff-ZHZjL3JlcG8vZXhwZXJpbWVudHMvcXVldWUvY2VsZXJ5LnB5) | `83.45% <66.66%> (-4.33%)` | :arrow_down: |
| [dvc/commands/experiments/clean.py](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative#diff-ZHZjL2NvbW1hbmRzL2V4cGVyaW1lbnRzL2NsZWFuLnB5) | `100.00% <100.00%> (ø)` | |
| [dvc/commands/experiments/queue\_worker.py](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative#diff-ZHZjL2NvbW1hbmRzL2V4cGVyaW1lbnRzL3F1ZXVlX3dvcmtlci5weQ==) | `100.00% <100.00%> (ø)` | |
| [dvc/repo/experiments/\_\_init\_\_.py](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative#diff-ZHZjL3JlcG8vZXhwZXJpbWVudHMvX19pbml0X18ucHk=) | `87.23% <100.00%> (+0.13%)` | :arrow_up: |
| [dvc/repo/experiments/clean.py](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative#diff-ZHZjL3JlcG8vZXhwZXJpbWVudHMvY2xlYW4ucHk=) | `100.00% <100.00%> (ø)` | |
| [tests/func/experiments/test\_experiments.py](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative#diff-dGVzdHMvZnVuYy9leHBlcmltZW50cy90ZXN0X2V4cGVyaW1lbnRzLnB5) | `99.40% <100.00%> (-0.60%)` | :arrow_down: |
| [tests/unit/command/test\_experiments.py](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative#diff-dGVzdHMvdW5pdC9jb21tYW5kL3Rlc3RfZXhwZXJpbWVudHMucHk=) | `99.60% <100.00%> (+0.01%)` | :arrow_up: |
| [dvc/utils/threadpool.py](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative#diff-ZHZjL3V0aWxzL3RocmVhZHBvb2wucHk=) | `63.04% <0.00%> (-30.44%)` | :arrow_down: |
| [tests/func/test\_unprotect.py](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative#diff-dGVzdHMvZnVuYy90ZXN0X3VucHJvdGVjdC5weQ==) | `78.57% <0.00%> (-21.43%)` | :arrow_down: |
| ... and [24 more](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/iterative/dvc/pull/9090?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=iterative).


Patch:
diff --git a/dvc/commands/experiments/__init__.py b/dvc/commands/experiments/__init__.py
index 7546b0ae7..d7e77d81e 100644
--- a/dvc/commands/experiments/__init__.py
+++ b/dvc/commands/experiments/__init__.py
@@ -4,6 +4,7 @@ from dvc.cli.utils import append_doc_link, fix_subparsers, hide_subparsers_from_
 from dvc.commands.experiments import (
     apply,
     branch,
+    clean,
     diff,
     exec_run,
     gc,
@@ -21,6 +22,7 @@ from dvc.commands.experiments import (
 SUB_COMMANDS = [
     apply,
     branch,
+    clean,
     diff,
     exec_run,
     gc,
diff --git a/dvc/commands/experiments/clean.py b/dvc/commands/experiments/clean.py
new file mode 100644
index 000000000..890eb34f3
--- /dev/null
+++ b/dvc/commands/experiments/clean.py
@@ -0,0 +1,25 @@
+import argparse
+import logging
+
+from dvc.cli.command import CmdBase
+from dvc.cli.utils import append_doc_link
+
+logger = logging.getLogger(__name__)
+
+
+class CmdExperimentsClean(CmdBase):
+    def run(self):
+        self.repo.experiments.clean()
+        return 0
+
+
+def add_parser(experiments_subparsers, parent_parser):
+    EXPERIMENTS_CLEAN_HELP = "Cleanup dvc exp internal tempfiles."
+    experiments_clean_parser = experiments_subparsers.add_parser(
+        "clean",
+        parents=[parent_parser],
+        description=append_doc_link(EXPERIMENTS_CLEAN_HELP, "exp/clean"),
+        help=EXPERIMENTS_CLEAN_HELP,
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    experiments_clean_parser.set_defaults(func=CmdExperimentsClean)
diff --git a/dvc/commands/experiments/queue_worker.py b/dvc/commands/experiments/queue_worker.py
index 81b6372d9..b85ff1d7a 100644
--- a/dvc/commands/experiments/queue_worker.py
+++ b/dvc/commands/experiments/queue_worker.py
@@ -9,7 +9,9 @@ class CmdQueueWorker(CmdBase):
     """Run the exp queue worker."""
 
     def run(self):
-        self.repo.experiments.celery_queue.worker.start(self.args.name)
+        self.repo.experiments.celery_queue.worker.start(
+            self.args.name, fsapp_clean=self.args.clean
+        )
         return 0
 
 
@@ -22,4 +24,9 @@ def add_parser(experiments_subparsers, parent_parser):
         add_help=False,
     )
     parser.add_argument("name", help="Celery worker name.")
+    parser.add_argument(
+        "--clean",
+        action="store_true",
+        help="Automatically cleanup celery broker on shutdown.",
+    )
     parser.set_defaults(func=CmdQueueWorker)
diff --git a/dvc/data_cloud.py b/dvc/data_cloud.py
index 6e9893b8c..b5094ca6e 100644
--- a/dvc/data_cloud.py
+++ b/dvc/data_cloud.py
@@ -35,7 +35,7 @@ class Remote:
         if self.worktree:
             path = self.fs.path.join(path, ".dvc", "cache")
 
-        return get_odb(self.fs, path, **self.config)
+        return get_odb(self.fs, path, hash_name="md5", **self.config)
 
 
 class DataCloud:
diff --git a/dvc/repo/experiments/__init__.py b/dvc/repo/experiments/__init__.py
index c4c74d95a..d731086da 100644
--- a/dvc/repo/experiments/__init__.py
+++ b/dvc/repo/experiments/__init__.py
@@ -494,3 +494,8 @@ class Experiments:
         from dvc.repo.experiments.remove import remove
 
         return remove(self.repo, *args, **kwargs)
+
+    def clean(self, *args, **kwargs):
+        from dvc.repo.experiments.clean import clean
+
+        return clean(self.repo, *args, **kwargs)
diff --git a/dvc/repo/experiments/clean.py b/dvc/repo/experiments/clean.py
new file mode 100644
index 000000000..653f2e2f3
--- /dev/null
+++ b/dvc/repo/experiments/clean.py
@@ -0,0 +1,12 @@
+from typing import TYPE_CHECKING
+
+from dvc.ui import ui
+
+if TYPE_CHECKING:
+    from dvc.repo import Repo
+
+
+def clean(repo: "Repo"):
+    ui.write("Cleaning up dvc-task messages...")
+    repo.experiments.celery_queue.celery.clean()
+    ui.write("Done!")
diff --git a/dvc/repo/experiments/executor/local.py b/dvc/repo/experiments/executor/local.py
index 58234709d..ed4bb13e9 100644
--- a/dvc/repo/experiments/executor/local.py
+++ b/dvc/repo/experiments/executor/local.py
@@ -5,7 +5,6 @@ from tempfile import mkdtemp
 from typing import TYPE_CHECKING, Optional, Union
 
 from funcy import retry
-from scmrepo.exceptions import SCMError as _SCMError
 from shortuuid import uuid
 
 from dvc.lock import LockError
@@ -20,7 +19,7 @@ from dvc.repo.experiments.refs import (
     EXPS_TEMP,
 )
 from dvc.repo.experiments.utils import EXEC_TMP_DIR, get_exp_rwlock
-from dvc.scm import SCM, Git, GitMergeError
+from dvc.scm import SCM, Git
 from dvc.utils.fs import remove
 from dvc.utils.objects import cached_property
 
@@ -134,10 +133,7 @@ class TempDirExecutor(BaseLocalExecutor):
         self.scm.checkout(head, detach=True)
         merge_rev = self.scm.get_ref(EXEC_MERGE)
 
-        try:
-            self.scm.merge(merge_rev, squash=True, commit=False)
-        except _SCMError as exc:
-            raise GitMergeError(str(exc), scm=self.scm)  # noqa: B904
+        self.scm.stash.apply(merge_rev)
 
     def _config(self, cache_dir):
         local_config = os.path.join(
@@ -229,10 +225,7 @@ class WorkspaceExecutor(BaseLocalExecutor):
                 )
             )
             merge_rev = self.scm.get_ref(EXEC_MERGE)
-            try:
-                self.scm.merge(merge_rev, squash=True, commit=False)
-            except _SCMError as exc:
-                raise GitMergeError(str(exc), scm=self.scm)  # noqa: B904
+            self.scm.stash.apply(merge_rev)
             if branch:
                 self.scm.set_ref(EXEC_BRANCH, branch, symbolic=True)
             elif scm.get_ref(EXEC_BRANCH):
diff --git a/dvc/repo/experiments/executor/ssh.py b/dvc/repo/experiments/executor/ssh.py
index 1f40f9d22..d71a7e606 100644
--- a/dvc/repo/experiments/executor/ssh.py
+++ b/dvc/repo/experiments/executor/ssh.py
@@ -169,7 +169,7 @@ class SSHExecutor(BaseExecutor):
             head = EXEC_BRANCH if branch else EXEC_HEAD
             self._ssh_cmd(fs, f"git checkout {head}")
             merge_rev = scm.get_ref(EXEC_MERGE)
-            self._ssh_cmd(fs, f"git merge --squash --no-commit {merge_rev}")
+            self._ssh_cmd(fs, f"git stash apply {merge_rev}")
 
             if self._setup_script:
                 self._init_setup_script(fs)
diff --git a/dvc/repo/experiments/queue/celery.py b/dvc/repo/experiments/queue/celery.py
index e85b1cf14..8d5364429 100644
--- a/dvc/repo/experiments/queue/celery.py
+++ b/dvc/repo/experiments/queue/celery.py
@@ -90,7 +90,7 @@ class LocalCeleryQueue(BaseStashQueue):
                 "dvc_task.proc.tasks",
             ],
         )
-        app.conf.update({"task_acks_late": True})
+        app.conf.update({"task_acks_late": True, "result_expires": None})
         return app
 
     @cached_property
@@ -138,6 +138,9 @@ class LocalCeleryQueue(BaseStashQueue):
         wdir_hash = hashlib.sha256(self.wdir.encode("utf-8")).hexdigest()[:6]
         node_name = f"dvc-exp-{wdir_hash}-{num}@localhost"
         cmd = ["exp", "queue-worker", node_name]
+        if num == 1:
+            # automatically run celery cleanup when primary worker shuts down
+            cmd.append("--clean")
         name = f"dvc-exp-worker-{num}"
 
         logger.debug("start a new worker: %s, node: %s", name, node_name)
diff --git a/pyproject.toml b/pyproject.toml
index 19297cc5d..39d6d930d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -39,7 +39,7 @@ dependencies = [
     "dvc-http",
     "dvc-render>=0.1.2",
     "dvc-studio-client>=0.1.1",
-    "dvc-task>=0.1.11,<1",
+    "dvc-task>=0.2.0,<1",
     "dvclive>=1.2.2",
     "flatten_dict<1,>=0.4.1",
     "flufl.lock>=5",


Test patch:
diff --git a/dvc/testing/workspace_tests.py b/dvc/testing/workspace_tests.py
index 139ad194a..1f3cc794f 100644
--- a/dvc/testing/workspace_tests.py
+++ b/dvc/testing/workspace_tests.py
@@ -282,3 +282,85 @@ class TestGetUrl:
     def test_get_url_nonexistent(self, cloud):
         with pytest.raises(URLMissingError):
             Repo.get_url(str(cloud / "nonexistent"), config=cloud.config)
+
+
+class TestToRemote:
+    def test_add_to_remote(self, tmp_dir, dvc, remote, workspace):
+        workspace.gen("foo", "foo")
+
+        url = "remote://workspace/foo"
+        [stage] = dvc.add(url, to_remote=True)
+
+        assert not (tmp_dir / "foo").exists()
+        assert (tmp_dir / "foo.dvc").exists()
+
+        assert len(stage.deps) == 0
+        assert len(stage.outs) == 1
+
+        hash_info = stage.outs[0].hash_info
+        meta = stage.outs[0].meta
+        assert hash_info.name == "md5"
+        assert hash_info.value == "acbd18db4cc2f85cedef654fccc4a4d8"
+        assert (remote / "ac" / "bd18db4cc2f85cedef654fccc4a4d8").read_text() == "foo"
+        assert meta.size == len("foo")
+
+    def test_import_url_to_remote_file(self, tmp_dir, dvc, workspace, remote):
+        workspace.gen("foo", "foo")
+
+        url = "remote://workspace/foo"
+        stage = dvc.imp_url(url, to_remote=True)
+
+        assert stage.deps[0].hash_info.value is not None
+        assert not (tmp_dir / "foo").exists()
+        assert (tmp_dir / "foo.dvc").exists()
+
+        assert len(stage.deps) == 1
+        assert stage.deps[0].def_path == url
+        assert len(stage.outs) == 1
+
+        hash_info = stage.outs[0].hash_info
+        assert hash_info.name == "md5"
+        assert hash_info.value == "acbd18db4cc2f85cedef654fccc4a4d8"
+        assert (remote / "ac" / "bd18db4cc2f85cedef654fccc4a4d8").read_text() == "foo"
+        assert stage.outs[0].meta.size == len("foo")
+
+    def test_import_url_to_remote_dir(self, tmp_dir, dvc, workspace, remote):
+        import json
+
+        workspace.gen(
+            {
+                "data": {
+                    "foo": "foo",
+                    "bar": "bar",
+                    "sub_dir": {"baz": "sub_dir/baz"},
+                }
+            }
+        )
+
+        url = "remote://workspace/data"
+        stage = dvc.imp_url(url, to_remote=True)
+
+        assert not (tmp_dir / "data").exists()
+        assert (tmp_dir / "data.dvc").exists()
+
+        assert len(stage.deps) == 1
+        assert stage.deps[0].def_path == url
+        assert len(stage.outs) == 1
+
+        hash_info = stage.outs[0].hash_info
+        assert hash_info.name == "md5"
+        assert hash_info.value == "55d05978954d1b2cd7b06aedda9b9e43.dir"
+        file_parts = json.loads(
+            (remote / "55" / "d05978954d1b2cd7b06aedda9b9e43.dir").read_text()
+        )
+
+        assert len(file_parts) == 3
+        assert {file_part["relpath"] for file_part in file_parts} == {
+            "foo",
+            "bar",
+            "sub_dir/baz",
+        }
+
+        for file_part in file_parts:
+            md5 = file_part["md5"]
+            assert (remote / md5[:2] / md5[2:]).read_text() == file_part["relpath"]
diff --git a/tests/func/experiments/test_experiments.py b/tests/func/experiments/test_experiments.py
index fc340e005..e9d5f83c9 100644
--- a/tests/func/experiments/test_experiments.py
+++ b/tests/func/experiments/test_experiments.py
@@ -633,3 +633,9 @@ def test_experiment_run_dry(tmp_dir, scm, dvc, exp_stage):
     dvc.experiments.run(exp_stage.addressing, dry=True)
 
     assert len(dvc.experiments.ls()["master"]) == 0
+
+
+def test_clean(tmp_dir, scm, dvc, mocker):
+    clean = mocker.spy(dvc.experiments.celery_queue.celery, "clean")
+    dvc.experiments.clean()
+    clean.assert_called_once_with()
diff --git a/tests/func/test_add.py b/tests/func/test_add.py
index ab48de101..bc9e49896 100644
--- a/tests/func/test_add.py
+++ b/tests/func/test_add.py
@@ -898,26 +898,6 @@ def test_add_long_fname(tmp_dir, dvc):
     assert (tmp_dir / "data").read_text() == {name: "foo"}
 
 
-def test_add_to_remote(tmp_dir, dvc, remote, workspace):
-    workspace.gen("foo", "foo")
-
-    url = "remote://workspace/foo"
-    [stage] = dvc.add(url, to_remote=True)
-
-    assert not (tmp_dir / "foo").exists()
-    assert (tmp_dir / "foo.dvc").exists()
-
-    assert len(stage.deps) == 0
-    assert len(stage.outs) == 1
-
-    hash_info = stage.outs[0].hash_info
-    meta = stage.outs[0].meta
-    with open(remote.oid_to_path(hash_info.value), encoding="utf-8") as stream:
-        assert stream.read() == "foo"
-
-    assert meta.size == len("foo")
-
-
 def test_add_to_remote_absolute(tmp_dir, make_tmp_dir, dvc, remote):
     tmp_abs_dir = make_tmp_dir("abs")
     tmp_foo = tmp_abs_dir / "foo"
diff --git a/tests/func/test_import_url.py b/tests/func/test_import_url.py
index 4380afe7c..1735d5e65 100644
--- a/tests/func/test_import_url.py
+++ b/tests/func/test_import_url.py
@@ -1,4 +1,3 @@
-import json
 import os
 import textwrap
 from uuid import uuid4
@@ -171,63 +170,6 @@ def test_import_url_preserve_fields(tmp_dir, dvc):
     )
 
 
-def test_import_url_to_remote_single_file(tmp_dir, dvc, workspace, local_remote):
-    workspace.gen("foo", "foo")
-
-    url = "remote://workspace/foo"
-    stage = dvc.imp_url(url, to_remote=True)
-
-    assert stage.deps[0].hash_info.value is not None
-    assert not (tmp_dir / "foo").exists()
-    assert (tmp_dir / "foo.dvc").exists()
-
-    assert len(stage.deps) == 1
-    assert stage.deps[0].def_path == url
-    assert len(stage.outs) == 1
-
-    hash_info = stage.outs[0].hash_info
-    with open(local_remote.oid_to_path(hash_info.value), encoding="utf-8") as fobj:
-        assert fobj.read() == "foo"
-    assert stage.outs[0].meta.size == len("foo")
-
-
-def test_import_url_to_remote_directory(tmp_dir, dvc, workspace, local_remote):
-    workspace.gen(
-        {
-            "data": {
-                "foo": "foo",
-                "bar": "bar",
-                "sub_dir": {"baz": "sub_dir/baz"},
-            }
-        }
-    )
-
-    url = "remote://workspace/data"
-    stage = dvc.imp_url(url, to_remote=True)
-
-    assert not (tmp_dir / "data").exists()
-    assert (tmp_dir / "data.dvc").exists()
-
-    assert len(stage.deps) == 1
-    assert stage.deps[0].def_path == url
-    assert len(stage.outs) == 1
-
-    hash_info = stage.outs[0].hash_info
-    with open(local_remote.oid_to_path(hash_info.value), encoding="utf-8") as stream:
-        file_parts = json.load(stream)
-
-    assert len(file_parts) == 3
-    assert {file_part["relpath"] for file_part in file_parts} == {
-        "foo",
-        "bar",
-        "sub_dir/baz",
-    }
-
-    for file_part in file_parts:
-        with open(local_remote.oid_to_path(file_part["md5"]), encoding="utf-8") as fobj:
-            assert fobj.read() == file_part["relpath"]
-
-
 def test_import_url_to_remote_absolute(tmp_dir, make_tmp_dir, dvc, local_remote):
     tmp_abs_dir = make_tmp_dir("abs")
     tmp_foo = tmp_abs_dir / "foo"
diff --git a/tests/unit/command/test_experiments.py b/tests/unit/command/test_experiments.py
index be667c5fd..8fdbb3109 100644
--- a/tests/unit/command/test_experiments.py
+++ b/tests/unit/command/test_experiments.py
@@ -8,6 +8,7 @@ import pytest
 from dvc.cli import DvcParserError, parse_args
 from dvc.commands.experiments.apply import CmdExperimentsApply
 from dvc.commands.experiments.branch import CmdExperimentsBranch
+from dvc.commands.experiments.clean import CmdExperimentsClean
 from dvc.commands.experiments.diff import CmdExperimentsDiff
 from dvc.commands.experiments.gc import CmdExperimentsGC
 from dvc.commands.experiments.init import CmdExperimentsInit
@@ -926,3 +927,15 @@ def test_experiments_save(dvc, scm, mocker):
     m.assert_called_once_with(
         cmd.repo, name="exp-name", force=True, include_untracked=[]
     )
+
+
+def test_experiments_clean(dvc, scm, mocker):
+    cli_args = parse_args(["experiments", "clean"])
+    assert cli_args.func == CmdExperimentsClean
+
+    cmd = cli_args.func(cli_args)
+    m = mocker.patch("dvc.repo.experiments.clean.clean", return_value={})
+
+    assert cmd.run() == 0
+
+    m.assert_called_once_with(cmd.repo)