Repository: iterative/dvc

Issue:
Support revisions in dvc update 
```
dvc update --rev hello_world file.dvc
```
it is still not supported but it seems like a very handy alternative to re-importing:
```
dvc import --rev hello_world https://github.com/dmpetrov/dataset file
```


Comments:
Copying main part of the discussion about this from Slack:

> @Suor: When you you do `dvc import -r ...` on an existing import stage it rewrites revisions thus **switching** a tag or a branch.

> Dmitry: ...we need a shortcut for re-importing/`import`, and `update` looks like a reasonable alternative...

> Alexander: You mean you want avoid retyping the url? 

> Dmitry: Yes. Also, `update` seems a proper command name for updating version.
I understand that switching branches is kind of exception, but this is not what I usually do with imported datasets.

Updating vs. re-importing is definitely a source of confusion, and it's reflected in our docs (which also requires the term "fixed-revision import". See the following excerpts:

From https://dvc.org/doc/command-reference/import#example-fixed-revisions-re-importing:

> If the Git revision moves (e.g. a branch), you may use dvc update to bring the data up to date. However, for typically static references (e.g. tags), or for SHA commits, in order to actually "update" an import, it's necessary to re-import the data instead, by using dvc import again without or with a different --rev. This will overwrite the import stage...

From https://dvc.org/doc/command-reference/update#examples:

> For typically static references (e.g. tags), or for SHA commits, dvc update will not have any effect on the import. Refer to the re-importing example to learn how to "update" fixed-revision imports.

From https://dvc.org/doc/use-cases/data-registry#example (in an expandable section):

> In order to actually "update" it, do not use dvc update. Instead, re-import the data
So I like the idea about `dvc update --rev` because it will probably simplify all these explanations, although it will also require changing its command reference to explain that there are 2 types of updates supported:

- Simply look for changes in the import as defined in its DVC-file, respecting `url` AND `rev` (if present) – only updating the actual data and `rev_lock` field.
- Change the `rev` AND look for changes, updating data and `rev_lock` as well.

Alternatively we could introduce a new command or subcommand such as `dvc import move` to change an import stage's `rev` value, in order to then `dvc update` it.
Hey, I'll give it a try to create a solution for this feature!
@dmpetrov hey, what is the expected behavior after `dvc update --rev another-rev`, should it lock original import to `another-rev` or do not create side effects like that?

current behaviour is, it locks to `another-rev` but also pulls the rev each time from the remote which I think it should just cache if the last commits are the same?
@ilgooz `dvc update` and `dvc update --rev latest_rev` should do the same, so yes, it should lock to `another-rev`, because that is how `dvc update` currently works too.

Do you mean that it is pulling the `rev` on each `update`? If so, it is how our current caching is implemented. There is no need to tackle that in this PR, feel free to create an issue for it though :slightly_smiling_face: 
> @ilgooz `dvc update` and `dvc update --rev latest_rev` should do the same, so yes, it should lock to `another-rev`, because that is how `dvc update` currently works too.
> 
> Do you mean that it is pulling the `rev` on each `update`? If so, it is how our current caching is implemented. There is no need to tackle that in this PR, feel free to create an issue for it though 🙂

Thanks, yes, I asked two different Qs and got my answers!
@efiop, why is this p0 btw?

Patch:
diff --git a/dvc/command/update.py b/dvc/command/update.py
--- a/dvc/command/update.py
+++ b/dvc/command/update.py
@@ -14,7 +14,7 @@ def run(self):
         ret = 0
         for target in self.args.targets:
             try:
-                self.repo.update(target)
+                self.repo.update(target, self.args.rev)
             except DvcException:
                 logger.exception("failed to update '{}'.".format(target))
                 ret = 1
@@ -33,4 +33,7 @@ def add_parser(subparsers, parent_parser):
     update_parser.add_argument(
         "targets", nargs="+", help="DVC-files to update."
     )
+    update_parser.add_argument(
+        "--rev", nargs="?", help="Git revision (e.g. SHA, branch, tag)"
+    )
     update_parser.set_defaults(func=CmdUpdate)
diff --git a/dvc/dependency/base.py b/dvc/dependency/base.py
--- a/dvc/dependency/base.py
+++ b/dvc/dependency/base.py
@@ -27,5 +27,5 @@ class DependencyBase(object):
     IsNotFileOrDirError = DependencyIsNotFileOrDirError
     IsStageFileError = DependencyIsStageFileError
 
-    def update(self):
+    def update(self, rev=None):
         pass
diff --git a/dvc/dependency/repo.py b/dvc/dependency/repo.py
--- a/dvc/dependency/repo.py
+++ b/dvc/dependency/repo.py
@@ -80,6 +80,9 @@ def download(self, to):
 
             repo.pull_to(self.def_path, to.path_info)
 
-    def update(self):
+    def update(self, rev=None):
+        if rev:
+            self.def_repo[self.PARAM_REV] = rev
+
         with self._make_repo(locked=False) as repo:
             self.def_repo[self.PARAM_REV_LOCK] = repo.scm.get_rev()
diff --git a/dvc/repo/update.py b/dvc/repo/update.py
--- a/dvc/repo/update.py
+++ b/dvc/repo/update.py
@@ -2,10 +2,12 @@
 
 
 @locked
-def update(self, target):
+def update(self, target, rev=None):
     from dvc.stage import Stage
 
     stage = Stage.load(self, target)
-    stage.update()
+    stage.update(rev)
 
     stage.dump()
+
+    return stage
diff --git a/dvc/stage.py b/dvc/stage.py
--- a/dvc/stage.py
+++ b/dvc/stage.py
@@ -405,11 +405,11 @@ def reproduce(self, interactive=False, **kwargs):
 
         return self
 
-    def update(self):
+    def update(self, rev=None):
         if not self.is_repo_import and not self.is_import:
             raise StageUpdateError(self.relpath)
 
-        self.deps[0].update()
+        self.deps[0].update(rev=rev)
         locked = self.locked
         self.locked = False
         try:


Test patch:
diff --git a/tests/func/test_update.py b/tests/func/test_update.py
--- a/tests/func/test_update.py
+++ b/tests/func/test_update.py
@@ -2,7 +2,7 @@
 import os
 
 from dvc.stage import Stage
-from dvc.compat import fspath
+from dvc.compat import fspath, fspath_py35
 
 
 @pytest.mark.parametrize("cached", [True, False])
@@ -131,3 +131,37 @@ def test_update_import_url(tmp_dir, dvc, tmp_path_factory):
 
     assert dst.is_file()
     assert dst.read_text() == "updated file content"
+
+
+def test_update_rev(tmp_dir, dvc, scm, git_dir):
+    with git_dir.chdir():
+        git_dir.scm_gen({"foo": "foo"}, commit="first")
+
+    dvc.imp(fspath(git_dir), "foo")
+    assert (tmp_dir / "foo.dvc").exists()
+
+    with git_dir.chdir(), git_dir.branch("branch1", new=True):
+        git_dir.scm_gen({"foo": "foobar"}, commit="branch1 commit")
+        branch1_head = git_dir.scm.get_rev()
+
+    with git_dir.chdir(), git_dir.branch("branch2", new=True):
+        git_dir.scm_gen({"foo": "foobar foo"}, commit="branch2 commit")
+        branch2_head = git_dir.scm.get_rev()
+
+    stage = dvc.update("foo.dvc", rev="branch1")
+    assert stage.deps[0].def_repo == {
+        "url": fspath(git_dir),
+        "rev": "branch1",
+        "rev_lock": branch1_head,
+    }
+    with open(fspath_py35(tmp_dir / "foo")) as f:
+        assert "foobar" == f.read()
+
+    stage = dvc.update("foo.dvc", rev="branch2")
+    assert stage.deps[0].def_repo == {
+        "url": fspath(git_dir),
+        "rev": "branch2",
+        "rev_lock": branch2_head,
+    }
+    with open(fspath_py35(tmp_dir / "foo")) as f:
+        assert "foobar foo" == f.read()
diff --git a/tests/unit/command/test_update.py b/tests/unit/command/test_update.py
--- a/tests/unit/command/test_update.py
+++ b/tests/unit/command/test_update.py
@@ -1,15 +1,19 @@
+import pytest
 from dvc.cli import parse_args
 from dvc.command.update import CmdUpdate
 
 
-def test_update(dvc, mocker):
+@pytest.mark.parametrize(
+    "command,rev", [(["update"], None), (["update", "--rev", "REV"], "REV")]
+)
+def test_update(dvc, mocker, command, rev):
     targets = ["target1", "target2", "target3"]
-    cli_args = parse_args(["update"] + targets)
+    cli_args = parse_args(command + targets)
     assert cli_args.func == CmdUpdate
     cmd = cli_args.func(cli_args)
     m = mocker.patch("dvc.repo.Repo.update")
 
     assert cmd.run() == 0
 
-    calls = [mocker.call(target) for target in targets]
+    calls = [mocker.call(target, rev) for target in targets]
     m.assert_has_calls(calls)