Repository: iterative/dvc

Issue:
exp push --rev broken
# Bug Report

## Description

`dvc exp push --rev` is broken since https://github.com/iterative/dvc/pull/9391.

### Reproduce

Run `dvc exp push --rev HEAD` from any repo and you will get:

```
$ dvc exp push -vv --rev HEAD origin 
2023-05-17 15:34:26,148 DEBUG: v2.55.0, CPython 3.10.10 on macOS-13.3.1-arm64-arm-64bit
2023-05-17 15:34:26,148 DEBUG: command: /Users/dave/miniforge3/envs/dvc/bin/dvc exp push -vv --rev HEAD origin
2023-05-17 15:34:26,148 TRACE: Namespace(cprofile=False, yappi=False, yappi_separate_threads=False, viztracer=False, viztracer_depth=None, viztracer_async=False, cprofile_dump=None, pdb=False, instrument=False, instrument_open=False, show_stack=False, quiet=0, verbose=2, cd='.', cmd='push', all_commits=False, rev=['HEAD'], num=1, force=False, push_cache=True, dvc_remote=None, jobs=None, run_cache=False, git_remote='origin', experiment=[], func=<class 'dvc.commands.experiments.push.CmdExperimentsPush'>, parser=DvcParser(prog='dvc', usage=None, description='Data Version Control', formatter_class=<class 'argparse.RawTextHelpFormatter'>, conflict_handler='error', add_help=False))
2023-05-17 15:34:26,370 ERROR: unexpected error - expected string or bytes-like object
Traceback (most recent call last):
  File "/Users/dave/Code/dvc/dvc/cli/__init__.py", line 210, in main
    ret = cmd.do_run()
  File "/Users/dave/Code/dvc/dvc/cli/command.py", line 26, in do_run
    return self.run()
  File "/Users/dave/Code/dvc/dvc/commands/experiments/push.py", line 65, in run
    result = self.repo.experiments.push(
  File "/Users/dave/Code/dvc/dvc/repo/experiments/__init__.py", line 494, in push
    return push(self.repo, *args, **kwargs)
  File "/Users/dave/Code/dvc/dvc/repo/__init__.py", line 65, in wrapper
    return f(repo, *args, **kwargs)
  File "/Users/dave/Code/dvc/dvc/repo/scm_context.py", line 151, in run
    return method(repo, *args, **kw)
  File "/Users/dave/Code/dvc/dvc/repo/experiments/push.py", line 115, in push
    exp_ref_set.update(exp_refs_from_rev(repo.scm, rev, num=num))
  File "/Users/dave/Code/dvc/dvc/repo/experiments/push.py", line 87, in exp_refs_from_rev
    rev_dict = iter_revs(scm, [rev], num)
  File "/Users/dave/Code/dvc/dvc/scm.py", line 266, in iter_revs
    return group_by(rev_resolver, results)
  File "/Users/dave/miniforge3/envs/dvc/lib/python3.10/site-packages/funcy/seqs.py", line 313, in group_by
    result[f(item)].append(item)
  File "/Users/dave/Code/dvc/dvc/scm.py", line 174, in resolve_rev
    return scm.resolve_rev(fix_exp_head(scm, rev))
  File "/Users/dave/Code/dvc/dvc/repo/experiments/utils.py", line 239, in fix_exp_head
    name, tail = Git.split_ref_pattern(ref)
  File "/Users/dave/miniforge3/envs/dvc/lib/python3.10/site-packages/scmrepo/git/__init__.py", line 157, in split_ref_pattern
    name = cls.BAD_REF_CHARS_RE.split(ref, maxsplit=1)[0]
TypeError: expected string or bytes-like object

2023-05-17 15:34:26,393 DEBUG: Removing '/Users/dave/Code/.CLFaTE3ctW8Fy6qXaisSEw.tmp'
2023-05-17 15:34:26,394 DEBUG: Removing '/Users/dave/Code/.CLFaTE3ctW8Fy6qXaisSEw.tmp'
2023-05-17 15:34:26,394 DEBUG: Removing '/Users/dave/Code/.CLFaTE3ctW8Fy6qXaisSEw.tmp'
2023-05-17 15:34:26,394 DEBUG: Removing '/Users/dave/Code/dvclive-exp-tracking/.dvc/cache/.YMaAXVC63teQCaVoXiTvho.tmp'
2023-05-17 15:34:26,395 DEBUG: Version info for developers:
DVC version: 2.55.0
-------------------
Platform: Python 3.10.10 on macOS-13.3.1-arm64-arm-64bit
Subprojects:
        dvc_data = 0.47.5
        dvc_objects = 0.22.0
        dvc_render = 0.3.1
        dvc_task = 0.2.1
        scmrepo = 1.0.3
Supports:
        azure (adlfs = 2023.1.0, knack = 0.10.1, azure-identity = 1.12.0),
        gdrive (pydrive2 = 1.15.3),
        gs (gcsfs = 2022.11.0),
        hdfs (fsspec = 2022.11.0, pyarrow = 11.0.0),
        http (aiohttp = 3.8.4, aiohttp-retry = 2.8.3),
        https (aiohttp = 3.8.4, aiohttp-retry = 2.8.3),
        oss (ossfs = 2021.8.0),
        s3 (s3fs = 2022.11.0, boto3 = 1.24.59),
        ssh (sshfs = 2023.4.1),
        webdav (webdav4 = 0.9.8),
        webdavs (webdav4 = 0.9.8),
        webhdfs (fsspec = 2022.11.0)
Config:
        Global: /Users/dave/Library/Application Support/dvc
        System: /Library/Application Support/dvc
Cache types: reflink, hardlink, symlink
Cache directory: apfs on /dev/disk3s1s1
Caches: local
Remotes: None
Workspace directory: apfs on /dev/disk3s1s1
Repo: dvc, git
Repo.site_cache_dir: /Library/Caches/dvc/repo/49450ff302024812849858c0b20a2692
```



Discussion:
@daavoo Are you looking into it? I was going to take a look but don't want to duplicate the effort. We could either extend multiple rev support to all exp commands (looks like it impacts `push`, `pull`, `remove`, and `list`) or restrict this to `exp show`.
I have a fix, sending a patch
> 

> We could either extend multiple rev support to all exp commands (looks like it impacts `push`, `pull`, `remove`, and `list`) or restrict this to `exp show`.

Extending all those commands should be trivial. I thought they already supported multiple revs as the underlying iter_revs does.

Will send an update for the rest of the commands tomorrow

Reopening until we have a patch for the other commands
@daavoo Are you still working on this one? I'd say it's almost a p0 since it breaks the `--rev` flag for multiple commands.

Resolving patch:
diff --git a/dvc/repo/experiments/ls.py b/dvc/repo/experiments/ls.py
--- a/dvc/repo/experiments/ls.py
+++ b/dvc/repo/experiments/ls.py
@@ -1,6 +1,6 @@
 import logging
 from collections import defaultdict
-from typing import Optional
+from typing import List, Optional, Union
 
 from dvc.repo import locked
 from dvc.repo.scm_context import scm_context
@@ -15,14 +15,17 @@
 @scm_context
 def ls(
     repo,
-    rev: Optional[str] = None,
+    rev: Optional[Union[List[str], str]] = None,
     all_commits: bool = False,
     num: int = 1,
     git_remote: Optional[str] = None,
 ):
     rev_set = None
     if not all_commits:
-        revs = iter_revs(repo.scm, [rev or "HEAD"], num)
+        rev = rev or "HEAD"
+        if isinstance(rev, str):
+            rev = [rev]
+        revs = iter_revs(repo.scm, rev, num)
         rev_set = set(revs.keys())
     ref_info_dict = exp_refs_by_baseline(repo.scm, rev_set, git_remote)
 
diff --git a/dvc/repo/experiments/pull.py b/dvc/repo/experiments/pull.py
--- a/dvc/repo/experiments/pull.py
+++ b/dvc/repo/experiments/pull.py
@@ -23,7 +23,7 @@ def pull(  # noqa: C901
     git_remote: str,
     exp_names: Union[Iterable[str], str],
     all_commits=False,
-    rev: Optional[str] = None,
+    rev: Optional[Union[List[str], str]] = None,
     num=1,
     force: bool = False,
     pull_cache: bool = False,
@@ -49,7 +49,9 @@ def pull(  # noqa: C901
                 raise UnresolvedExpNamesError(unresolved_exp_names)
 
         if rev:
-            rev_dict = iter_revs(repo.scm, [rev], num)
+            if isinstance(rev, str):
+                rev = [rev]
+            rev_dict = iter_revs(repo.scm, rev, num)
             rev_set = set(rev_dict.keys())
             ref_info_dict = exp_refs_by_baseline(repo.scm, rev_set, git_remote)
             for _, ref_info_list in ref_info_dict.items():
diff --git a/dvc/repo/experiments/remove.py b/dvc/repo/experiments/remove.py
--- a/dvc/repo/experiments/remove.py
+++ b/dvc/repo/experiments/remove.py
@@ -24,7 +24,7 @@
 def remove(  # noqa: C901, PLR0912
     repo: "Repo",
     exp_names: Union[None, str, List[str]] = None,
-    rev: Optional[str] = None,
+    rev: Optional[Union[List[str], str]] = None,
     all_commits: bool = False,
     num: int = 1,
     queue: bool = False,
@@ -60,6 +60,8 @@ def remove(  # noqa: C901, PLR0912
         if remained:
             raise UnresolvedExpNamesError(remained, git_remote=git_remote)
     elif rev:
+        if isinstance(rev, str):
+            rev = [rev]
         exp_ref_dict = _resolve_exp_by_baseline(repo, rev, num, git_remote)
         removed.extend(exp_ref_dict.keys())
         exp_ref_list.extend(exp_ref_dict.values())
@@ -85,14 +87,14 @@ def remove(  # noqa: C901, PLR0912
 
 def _resolve_exp_by_baseline(
     repo: "Repo",
-    rev: str,
+    rev: List[str],
     num: int,
     git_remote: Optional[str] = None,
 ) -> Dict[str, "ExpRefInfo"]:
     assert isinstance(repo.scm, Git)
 
     commit_ref_dict: Dict[str, "ExpRefInfo"] = {}
-    rev_dict = iter_revs(repo.scm, [rev], num)
+    rev_dict = iter_revs(repo.scm, rev, num)
     rev_set = set(rev_dict.keys())
     ref_info_dict = exp_refs_by_baseline(repo.scm, rev_set, git_remote)
     for _, ref_info_list in ref_info_dict.items():


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
@@ -369,6 +369,12 @@ def test_list(tmp_dir, scm, dvc, exp_stage):
         baseline_a[:7]: {ref_info_a.name, ref_info_b.name}
     }
 
+    exp_list = dvc.experiments.ls(rev=[baseline_a, scm.get_rev()])
+    assert {key: set(val) for key, val in exp_list.items()} == {
+        baseline_a[:7]: {ref_info_a.name, ref_info_b.name},
+        "master": {ref_info_c.name},
+    }
+
     exp_list = dvc.experiments.ls(all_commits=True)
     assert {key: set(val) for key, val in exp_list.items()} == {
         baseline_a[:7]: {ref_info_a.name, ref_info_b.name},
diff --git a/tests/func/experiments/test_remote.py b/tests/func/experiments/test_remote.py
--- a/tests/func/experiments/test_remote.py
+++ b/tests/func/experiments/test_remote.py
@@ -251,6 +251,30 @@ def test_pull_args(tmp_dir, scm, dvc, git_downstream, exp_stage, all_, rev, resu
     assert git_downstream.tmp_dir.scm.get_ref(str(ref_info3)) == result3
 
 
+def test_pull_multi_rev(tmp_dir, scm, dvc, git_downstream, exp_stage):
+    baseline = scm.get_rev()
+
+    results = dvc.experiments.run(exp_stage.addressing, params=["foo=1"])
+    exp1 = first(results)
+    ref_info1 = first(exp_refs_by_rev(scm, exp1))
+    results = dvc.experiments.run(exp_stage.addressing, params=["foo=2"])
+    exp2 = first(results)
+    ref_info2 = first(exp_refs_by_rev(scm, exp2))
+
+    scm.commit("new_baseline")
+
+    results = dvc.experiments.run(exp_stage.addressing, params=["foo=3"])
+    exp3 = first(results)
+    ref_info3 = first(exp_refs_by_rev(scm, exp3))
+
+    downstream_exp = git_downstream.tmp_dir.dvc.experiments
+    git_downstream.tmp_dir.scm.fetch_refspecs(str(tmp_dir), ["master:master"])
+    downstream_exp.pull(git_downstream.remote, [], rev=[baseline, scm.get_rev()])
+    assert git_downstream.tmp_dir.scm.get_ref(str(ref_info1)) == exp1
+    assert git_downstream.tmp_dir.scm.get_ref(str(ref_info2)) == exp2
+    assert git_downstream.tmp_dir.scm.get_ref(str(ref_info3)) == exp3
+
+
 def test_pull_diverged(tmp_dir, scm, dvc, git_downstream, exp_stage):
     git_downstream.tmp_dir.scm_gen("foo", "foo", commit="init")
     remote_rev = git_downstream.tmp_dir.scm.get_rev()
diff --git a/tests/func/experiments/test_remove.py b/tests/func/experiments/test_remove.py
--- a/tests/func/experiments/test_remove.py
+++ b/tests/func/experiments/test_remove.py
@@ -156,3 +156,26 @@ def test_remove_experiments_by_rev(tmp_dir, scm, dvc, exp_stage):
     assert "queue2" in queue_revs
     assert scm.get_ref(new_exp_ref) is not None
     assert "queue4" in queue_revs
+
+
+def test_remove_multi_rev(tmp_dir, scm, dvc, exp_stage):
+    baseline = scm.get_rev()
+
+    results = dvc.experiments.run(exp_stage.addressing, params=["foo=1"])
+    baseline_exp_ref = first(exp_refs_by_rev(scm, first(results)))
+
+    dvc.experiments.run(
+        exp_stage.addressing, params=["foo=2"], queue=True, name="queue2"
+    )
+    scm.commit("new_baseline")
+
+    results = dvc.experiments.run(exp_stage.addressing, params=["foo=3"])
+    new_exp_ref = first(exp_refs_by_rev(scm, first(results)))
+
+    assert set(dvc.experiments.remove(rev=[baseline, scm.get_rev()])) == {
+        baseline_exp_ref.name,
+        new_exp_ref.name,
+    }
+
+    assert scm.get_ref(str(baseline_exp_ref)) is None
+    assert scm.get_ref(str(new_exp_ref)) is None