Repository: iterative/dvc

Issue:
exp run: support [-m | --message] parameter
Would be nice if the user is able to assign a description for a live experiment. I.e.:

```sh
dvc exp run -S epochs=12 -m "Increase epochs count due to under-fitting"
```

<details><summary>This will help us to display this information in the `Message` field in the Studio, potentially in VSCode as well. Now we display nothing there.</summary>

![image](https://user-images.githubusercontent.com/28868855/214280013-ddca8479-298f-497b-bb65-9f6de3a9b201.png)

</details>

This should probably be a part of the [dvc-studio-client](https://github.com/iterative/dvc-studio-client) contract.

CC @daavoo


Discussion:
Outside of `live experiments`, I think it could be also valuable to optionally replace the hardcoded message we currently have in the generated commit:

https://github.com/iterative/dvc/blob/6a6bc7035ab7c2525c024d536b5e4686b2fb40bf/dvc/repo/experiments/executor/base.py#L688-L690

Patch:
diff --git a/dvc/commands/experiments/exec_run.py b/dvc/commands/experiments/exec_run.py
--- a/dvc/commands/experiments/exec_run.py
+++ b/dvc/commands/experiments/exec_run.py
@@ -20,6 +20,7 @@ def run(self):
             log_level=logger.getEffectiveLevel(),
             infofile=self.args.infofile,
             copy_paths=self.args.copy_paths,
+            message=self.args.message,
         )
         return 0
 
@@ -47,4 +48,11 @@ def add_parser(experiments_subparsers, parent_parser):
             " Only used if `--temp` or `--queue` is specified."
         ),
     )
+    exec_run_parser.add_argument(
+        "-M",
+        "--message",
+        type=str,
+        default=None,
+        help="Custom commit message to use when committing the experiment.",
+    )
     exec_run_parser.set_defaults(func=CmdExecutorRun)
diff --git a/dvc/commands/experiments/run.py b/dvc/commands/experiments/run.py
--- a/dvc/commands/experiments/run.py
+++ b/dvc/commands/experiments/run.py
@@ -37,6 +37,7 @@ def run(self):
             tmp_dir=self.args.tmp_dir,
             machine=self.args.machine,
             copy_paths=self.args.copy_paths,
+            message=self.args.message,
             **self._common_kwargs,
         )
 
@@ -147,3 +148,10 @@ def _add_run_common(parser):
             " Only used if `--temp` or `--queue` is specified."
         ),
     )
+    parser.add_argument(
+        "-M",
+        "--message",
+        type=str,
+        default=None,
+        help="Custom commit message to use when committing the experiment.",
+    )
diff --git a/dvc/commands/experiments/save.py b/dvc/commands/experiments/save.py
--- a/dvc/commands/experiments/save.py
+++ b/dvc/commands/experiments/save.py
@@ -16,6 +16,7 @@ def run(self):
                 name=self.args.name,
                 force=self.args.force,
                 include_untracked=self.args.include_untracked,
+                message=self.args.message,
             )
         except DvcException:
             logger.exception("failed to save experiment")
@@ -75,4 +76,11 @@ def add_parser(experiments_subparsers, parent_parser):
         help="List of untracked paths to include in the experiment.",
         metavar="<path>",
     )
+    save_parser.add_argument(
+        "-M",
+        "--message",
+        type=str,
+        default=None,
+        help="Custom commit message to use when committing the experiment.",
+    )
     save_parser.set_defaults(func=CmdExperimentsSave)
diff --git a/dvc/repo/experiments/__init__.py b/dvc/repo/experiments/__init__.py
--- a/dvc/repo/experiments/__init__.py
+++ b/dvc/repo/experiments/__init__.py
@@ -119,6 +119,7 @@ def reproduce_one(
         self,
         tmp_dir: bool = False,
         copy_paths: Optional[List[str]] = None,
+        message: Optional[str] = None,
         **kwargs,
     ):
         """Reproduce and checkout a single (standalone) experiment."""
@@ -126,7 +127,9 @@ def reproduce_one(
             self.tempdir_queue if tmp_dir else self.workspace_queue
         )
         self.queue_one(exp_queue, **kwargs)
-        results = self._reproduce_queue(exp_queue, copy_paths=copy_paths)
+        results = self._reproduce_queue(
+            exp_queue, copy_paths=copy_paths, message=message
+        )
         exp_rev = first(results)
         if exp_rev is not None:
             self._log_reproduced(results, tmp_dir=tmp_dir)
@@ -349,7 +352,11 @@ def reset_checkpoints(self):
 
     @unlocked_repo
     def _reproduce_queue(
-        self, queue: "BaseStashQueue", copy_paths: Optional[List[str]] = None, **kwargs
+        self,
+        queue: "BaseStashQueue",
+        copy_paths: Optional[List[str]] = None,
+        message: Optional[str] = None,
+        **kwargs,
     ) -> Dict[str, str]:
         """Reproduce queued experiments.
 
@@ -360,7 +367,7 @@ def _reproduce_queue(
             dict mapping successfully reproduced experiment revs to their
             results.
         """
-        exec_results = queue.reproduce(copy_paths=copy_paths)
+        exec_results = queue.reproduce(copy_paths=copy_paths, message=message)
 
         results: Dict[str, str] = {}
         for _, exp_result in exec_results.items():
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
@@ -277,6 +277,7 @@ def save(
         info: "ExecutorInfo",
         force: bool = False,
         include_untracked: Optional[List[str]] = None,
+        message: Optional[str] = None,
     ) -> ExecutorResult:
         from dvc.dvcfile import LOCK_FILE
         from dvc.repo import Repo
@@ -308,6 +309,7 @@ def save(
                 exp_hash,
                 exp_name=info.name,
                 force=force,
+                message=message,
             )
             ref: Optional[str] = dvc.scm.get_ref(EXEC_BRANCH, follow=False)
             exp_ref = ExpRefInfo.from_ref(ref) if ref else None
@@ -461,6 +463,7 @@ def reproduce(
         log_errors: bool = True,
         log_level: Optional[int] = None,
         copy_paths: Optional[List[str]] = None,
+        message: Optional[str] = None,
         **kwargs,
     ) -> "ExecutorResult":
         """Run dvc repro and return the result.
@@ -498,6 +501,7 @@ def filter_pipeline(stages):
             infofile,
             log_errors=log_errors,
             copy_paths=copy_paths,
+            message=message,
             **kwargs,
         ) as dvc:
             if auto_push:
@@ -570,6 +574,7 @@ def filter_pipeline(stages):
                     auto_push,
                     git_remote,
                     repro_force,
+                    message=message,
                 )
                 info.result_hash = exp_hash
                 info.result_ref = ref
@@ -590,6 +595,7 @@ def _repro_commit(
         auto_push,
         git_remote,
         repro_force,
+        message: Optional[str] = None,
     ) -> Tuple[Optional[str], Optional["ExpRefInfo"], bool]:
         is_checkpoint = any(stage.is_checkpoint for stage in stages)
         cls.commit(
@@ -598,6 +604,7 @@ def _repro_commit(
             exp_name=info.name,
             force=repro_force,
             checkpoint=is_checkpoint,
+            message=message,
         )
         if auto_push:
             cls._auto_push(dvc, dvc.scm, git_remote)
@@ -625,6 +632,7 @@ def _repro_dvc(  # noqa: C901
         infofile: Optional[str] = None,
         log_errors: bool = True,
         copy_paths: Optional[List[str]] = None,
+        message: Optional[str] = None,
         **kwargs,
     ) -> Iterator["Repo"]:
         from dvc_studio_client.env import STUDIO_REPO_URL, STUDIO_TOKEN
@@ -662,6 +670,7 @@ def _repro_dvc(  # noqa: C901
                     params=to_studio_params(dvc.params.show()),
                     studio_token=run_env.get(STUDIO_TOKEN, None),
                     studio_repo_url=run_env.get(STUDIO_REPO_URL, None),
+                    message=message,
                 )
                 logger.debug("Running repro in '%s'", os.getcwd())
                 yield dvc
@@ -760,6 +769,7 @@ def commit(
         exp_name: Optional[str] = None,
         force: bool = False,
         checkpoint: bool = False,
+        message: Optional[str] = None,
     ):
         """Commit stages as an experiment and return the commit SHA."""
         rev = scm.get_rev()
@@ -789,7 +799,8 @@ def commit(
                 logger.debug("Commit to new experiment branch '%s'", branch)
 
         scm.add([], update=True)
-        scm.commit(f"dvc: commit experiment {exp_hash}", no_verify=True)
+        message = message or f"dvc: commit experiment {exp_hash}"
+        scm.commit(message, no_verify=True)
         new_rev = scm.get_rev()
         if check_conflict:
             new_rev = cls._raise_ref_conflict(scm, branch, new_rev, checkpoint)
diff --git a/dvc/repo/experiments/executor/ssh.py b/dvc/repo/experiments/executor/ssh.py
--- a/dvc/repo/experiments/executor/ssh.py
+++ b/dvc/repo/experiments/executor/ssh.py
@@ -243,6 +243,7 @@ def reproduce(
         log_errors: bool = True,
         log_level: Optional[int] = None,
         copy_paths: Optional[List[str]] = None,  # noqa: ARG003
+        message: Optional[str] = None,  # noqa: ARG003
         **kwargs,
     ) -> "ExecutorResult":
         """Reproduce an experiment on a remote machine over SSH.
diff --git a/dvc/repo/experiments/queue/base.py b/dvc/repo/experiments/queue/base.py
--- a/dvc/repo/experiments/queue/base.py
+++ b/dvc/repo/experiments/queue/base.py
@@ -254,7 +254,7 @@ def iter_failed(self) -> Generator[QueueDoneResult, None, None]:
 
     @abstractmethod
     def reproduce(
-        self, copy_paths: Optional[List[str]] = None
+        self, copy_paths: Optional[List[str]] = None, message: Optional[str] = None
     ) -> Mapping[str, Mapping[str, str]]:
         """Reproduce queued experiments sequentially."""
 
diff --git a/dvc/repo/experiments/queue/celery.py b/dvc/repo/experiments/queue/celery.py
--- a/dvc/repo/experiments/queue/celery.py
+++ b/dvc/repo/experiments/queue/celery.py
@@ -180,12 +180,18 @@ def start_workers(self, count: int) -> int:
         return started
 
     def put(
-        self, *args, copy_paths: Optional[List[str]] = None, **kwargs
+        self,
+        *args,
+        copy_paths: Optional[List[str]] = None,
+        message: Optional[str] = None,
+        **kwargs,
     ) -> QueueEntry:
         """Stash an experiment and add it to the queue."""
         with get_exp_rwlock(self.repo, writes=["workspace", CELERY_STASH]):
             entry = self._stash_exp(*args, **kwargs)
-        self.celery.signature(run_exp.s(entry.asdict(), copy_paths=copy_paths)).delay()
+        self.celery.signature(
+            run_exp.s(entry.asdict(), copy_paths=copy_paths, message=message)
+        ).delay()
         return entry
 
     # NOTE: Queue consumption should not be done directly. Celery worker(s)
@@ -264,7 +270,7 @@ def iter_failed(self) -> Generator[QueueDoneResult, None, None]:
                 yield QueueDoneResult(queue_entry, exp_result)
 
     def reproduce(
-        self, copy_paths: Optional[List[str]] = None
+        self, copy_paths: Optional[List[str]] = None, message: Optional[str] = None
     ) -> Mapping[str, Mapping[str, str]]:
         raise NotImplementedError
 
diff --git a/dvc/repo/experiments/queue/tasks.py b/dvc/repo/experiments/queue/tasks.py
--- a/dvc/repo/experiments/queue/tasks.py
+++ b/dvc/repo/experiments/queue/tasks.py
@@ -91,7 +91,11 @@ def cleanup_exp(executor: TempDirExecutor, infofile: str) -> None:
 
 
 @shared_task
-def run_exp(entry_dict: Dict[str, Any], copy_paths: Optional[List[str]] = None) -> None:
+def run_exp(
+    entry_dict: Dict[str, Any],
+    copy_paths: Optional[List[str]] = None,
+    message: Optional[str] = None,
+) -> None:
     """Run a full experiment.
 
     Experiment subtasks are executed inline as one atomic operation.
@@ -111,6 +115,8 @@ def run_exp(entry_dict: Dict[str, Any], copy_paths: Optional[List[str]] = None)
         if copy_paths:
             for path in copy_paths:
                 cmd.extend(["--copy-paths", path])
+        if message:
+            cmd.extend(["--message", message])
         proc_dict = queue.proc.run_signature(cmd, name=entry.stash_rev)()
         collect_exp.s(proc_dict, entry_dict)()
     finally:
diff --git a/dvc/repo/experiments/queue/tempdir.py b/dvc/repo/experiments/queue/tempdir.py
--- a/dvc/repo/experiments/queue/tempdir.py
+++ b/dvc/repo/experiments/queue/tempdir.py
@@ -97,6 +97,7 @@ def _reproduce_entry(
         entry: QueueEntry,
         executor: "BaseExecutor",
         copy_paths: Optional[List[str]] = None,
+        message: Optional[str] = None,
         **kwargs,
     ) -> Dict[str, Dict[str, str]]:
         from dvc.stage.monitor import CheckpointKilledError
@@ -113,6 +114,7 @@ def _reproduce_entry(
                 log_level=logger.getEffectiveLevel(),
                 log_errors=True,
                 copy_paths=copy_paths,
+                message=message,
             )
             if not exec_result.exp_hash:
                 raise DvcException(f"Failed to reproduce experiment '{rev[:7]}'")
diff --git a/dvc/repo/experiments/queue/workspace.py b/dvc/repo/experiments/queue/workspace.py
--- a/dvc/repo/experiments/queue/workspace.py
+++ b/dvc/repo/experiments/queue/workspace.py
@@ -82,14 +82,16 @@ def iter_success(self) -> Generator["QueueDoneResult", None, None]:
         raise NotImplementedError
 
     def reproduce(
-        self, copy_paths: Optional[List[str]] = None
+        self, copy_paths: Optional[List[str]] = None, message: Optional[str] = None
     ) -> Dict[str, Dict[str, str]]:
         results: Dict[str, Dict[str, str]] = defaultdict(dict)
         try:
             while True:
                 entry, executor = self.get()
                 results.update(
-                    self._reproduce_entry(entry, executor, copy_paths=copy_paths)
+                    self._reproduce_entry(
+                        entry, executor, copy_paths=copy_paths, message=message
+                    )
                 )
         except ExpQueueEmptyError:
             pass
@@ -117,6 +119,7 @@ def _reproduce_entry(
                 infofile=infofile,
                 log_level=logger.getEffectiveLevel(),
                 log_errors=not isinstance(executor, WorkspaceExecutor),
+                message=kwargs.get("message"),
             )
             if not exec_result.exp_hash:
                 raise DvcException(f"Failed to reproduce experiment '{rev[:7]}'")
diff --git a/dvc/repo/experiments/run.py b/dvc/repo/experiments/run.py
--- a/dvc/repo/experiments/run.py
+++ b/dvc/repo/experiments/run.py
@@ -20,6 +20,7 @@ def run(  # noqa: C901, PLR0912
     tmp_dir: bool = False,
     queue: bool = False,
     copy_paths: Optional[Iterable[str]] = None,
+    message: Optional[str] = None,
     **kwargs,
 ) -> Dict[str, str]:
     """Reproduce the specified targets as an experiment.
@@ -74,6 +75,7 @@ def run(  # noqa: C901, PLR0912
             params=path_overrides,
             tmp_dir=tmp_dir,
             copy_paths=copy_paths,
+            message=message,
             **kwargs,
         )
 
@@ -96,6 +98,7 @@ def run(  # noqa: C901, PLR0912
             targets=targets,
             params=sweep_overrides,
             copy_paths=copy_paths,
+            message=message,
             **kwargs,
         )
         if sweep_overrides:
diff --git a/dvc/repo/experiments/save.py b/dvc/repo/experiments/save.py
--- a/dvc/repo/experiments/save.py
+++ b/dvc/repo/experiments/save.py
@@ -16,6 +16,7 @@ def save(
     name: Optional[str] = None,
     force: bool = False,
     include_untracked: Optional[List[str]] = None,
+    message: Optional[str] = None,
 ) -> Optional[str]:
     """Save the current workspace status as an experiment.
 
@@ -29,7 +30,10 @@ def save(
 
     try:
         save_result = executor.save(
-            executor.info, force=force, include_untracked=include_untracked
+            executor.info,
+            force=force,
+            include_untracked=include_untracked,
+            message=message,
         )
         result = queue.collect_executor(repo.experiments, executor, save_result)
     finally:


Test patch:
diff --git a/tests/func/experiments/test_experiments.py b/tests/func/experiments/test_experiments.py
--- a/tests/func/experiments/test_experiments.py
+++ b/tests/func/experiments/test_experiments.py
@@ -773,3 +773,19 @@ def test_mixed_git_dvc_out(tmp_dir, scm, dvc, exp_stage):
     assert (tmp_dir / "dir" / "metrics.yaml").exists()
     git_fs = scm.get_fs(exp)
     assert not git_fs.exists("dir/metrics.yaml")
+
+
+@pytest.mark.parametrize("tmp", [True, False])
+def test_custom_commit_message(tmp_dir, scm, dvc, tmp):
+    stage = dvc.stage.add(
+        cmd="echo foo",
+        name="foo",
+    )
+    scm.add_commit(["dvc.yaml"], message="add dvc.yaml")
+
+    exp = first(
+        dvc.experiments.run(
+            stage.addressing, tmp_dir=tmp, message="custom commit message"
+        )
+    )
+    assert scm.gitpython.repo.commit(exp).message == "custom commit message"
diff --git a/tests/func/experiments/test_queue.py b/tests/func/experiments/test_queue.py
--- a/tests/func/experiments/test_queue.py
+++ b/tests/func/experiments/test_queue.py
@@ -69,3 +69,16 @@ def test_copy_paths_queue(tmp_dir, scm, dvc):
     fs = scm.get_fs(exp)
     assert not fs.exists("dir")
     assert not fs.exists("file")
+
+
+def test_custom_commit_message_queue(tmp_dir, scm, dvc):
+    stage = dvc.stage.add(
+        cmd="echo foo",
+        name="foo",
+    )
+    scm.add_commit(["dvc.yaml"], message="add dvc.yaml")
+
+    dvc.experiments.run(stage.addressing, queue=True, message="custom commit message")
+
+    exp = first(dvc.experiments.run(run_all=True))
+    assert scm.gitpython.repo.commit(exp).message == "custom commit message"
diff --git a/tests/func/experiments/test_save.py b/tests/func/experiments/test_save.py
--- a/tests/func/experiments/test_save.py
+++ b/tests/func/experiments/test_save.py
@@ -153,3 +153,10 @@ def test_untracked_dvclock_is_included_in_exp(tmp_dir, scm, dvc):
     exp = dvc.experiments.save()
     fs = scm.get_fs(exp)
     assert fs.exists("dvc.lock")
+
+
+def test_exp_save_custom_message(tmp_dir, dvc, scm):
+    setup_stage(tmp_dir, dvc, scm)
+
+    exp = dvc.experiments.save(message="custom commit message")
+    assert scm.gitpython.repo.commit(exp).message == "custom commit message"
diff --git a/tests/func/experiments/test_set_params.py b/tests/func/experiments/test_set_params.py
--- a/tests/func/experiments/test_set_params.py
+++ b/tests/func/experiments/test_set_params.py
@@ -116,7 +116,12 @@ def test_hydra_sweep(
     assert patched.call_count == len(expected)
     for e in expected:
         patched.assert_any_call(
-            mocker.ANY, params=e, reset=True, targets=None, copy_paths=None
+            mocker.ANY,
+            params=e,
+            reset=True,
+            targets=None,
+            copy_paths=None,
+            message=None,
         )
 
 
diff --git a/tests/integration/test_studio_live_experiments.py b/tests/integration/test_studio_live_experiments.py
--- a/tests/integration/test_studio_live_experiments.py
+++ b/tests/integration/test_studio_live_experiments.py
@@ -47,3 +47,37 @@ def test_post_to_studio(tmp_dir, dvc, scm, exp_stage, mocker, monkeypatch, tmp):
         "experiment_rev": exp_rev,
         "metrics": {"metrics.yaml": {"data": {"foo": 1}}},
     }
+
+
+@pytest.mark.parametrize("tmp", [True, False])
+def test_post_to_studio_custom_message(
+    tmp_dir, dvc, scm, exp_stage, mocker, monkeypatch, tmp
+):
+    valid_response = mocker.MagicMock()
+    valid_response.status_code = 200
+    mocked_post = mocker.patch("requests.post", return_value=valid_response)
+
+    monkeypatch.setenv(env.STUDIO_ENDPOINT, "https://0.0.0.0")
+    monkeypatch.setenv(env.STUDIO_REPO_URL, "STUDIO_REPO_URL")
+    monkeypatch.setenv(env.STUDIO_TOKEN, "STUDIO_TOKEN")
+
+    baseline_sha = scm.get_rev()
+    exp_rev = first(
+        dvc.experiments.run(
+            exp_stage.addressing, params=["foo=1"], tmp_dir=tmp, message="foo"
+        )
+    )
+    name = dvc.experiments.get_exact_name([exp_rev])[exp_rev]
+    assert mocked_post.call_count == 2
+
+    start_call = mocked_post.call_args_list[0]
+
+    assert start_call.kwargs["json"] == {
+        "type": "start",
+        "repo_url": "STUDIO_REPO_URL",
+        "baseline_sha": baseline_sha,
+        "name": name,
+        "params": {"params.yaml": {"foo": 1}},
+        "client": "dvc",
+        "message": "foo",
+    }
diff --git a/tests/unit/command/test_experiments.py b/tests/unit/command/test_experiments.py
--- a/tests/unit/command/test_experiments.py
+++ b/tests/unit/command/test_experiments.py
@@ -136,6 +136,7 @@ def test_experiments_run(dvc, scm, mocker):
         "reset": False,
         "machine": None,
         "copy_paths": [],
+        "message": None,
     }
     default_arguments.update(repro_arguments)
 
@@ -627,7 +628,7 @@ def test_experiments_save(dvc, scm, mocker):
     assert cmd.run() == 0
 
     m.assert_called_once_with(
-        cmd.repo, name="exp-name", force=True, include_untracked=[]
+        cmd.repo, name="exp-name", force=True, include_untracked=[], message=None
     )