Repository: iterative/dvc

Issue:
`fill_from_lock` called when `stage.cmd_chaged == True`
## Bug Report

Using mutlistage dvc file. `dvc repro` for the fist time on `dvc.yaml` works no issue. However, if I change the `cmd` in `dvc.yaml` the new command is not run, instead dvc says `Stage 'st2' is cached` (interestingly it omits `skipping`). After doing some digging I noticed this
 
https://github.com/iterative/dvc/blob/master/dvc/stage/loader.py#L178

Is using the `lock_data` correct? If the `cmd` has changed, shouldn't we use the `stage_data`?

### Please provide information about your setup

`dvc.yaml`
```yaml
stages:
  st1:
    deps:
    - data.txt
    outs:
    - base_dir/stage1_output.txt
    cmd: >-
        mkdir base_dir;
        cat data.txt > base_dir/stage1_output.txt
  st2:
    deps:
    - base_dir/stage1_output.txt
    outs:
    - base_dir/stage2_output.txt
    cmd: >-
        echo SOMETHING
        > base_dir/stage2_output.txt;
        cat base_dir/stage1_output.txt >>
        base_dir/stage2_output.txt
```

`$ dvc repro dvc.yaml`

**Correctly runs both stages**

Modify `cmd` in `dvc.yaml` ie: set `st2.cmd` to 

```yaml
    cmd: >-
        echo SOMEOTHERTHING
        > base_dir/stage2_output.txt;
        cat base_dir/stage1_output.txt >>
        base_dir/stage2_output.txt
```

`$ dvc repro dvc.yaml`

**Expected Output**

**Does not pickup change in `st2.cmd`**

---

```console
$ dvc version
DVC version: 1.0.0a6+68bea2.mod
Python version: 3.7.6
Platform: Linux-4.15.0-99-generic-x86_64-with-debian-buster-sid
Binary: False
Package: None
Supported remotes: http, https
Cache: reflink - not supported, hardlink - supported, symlink - supported
Repo: dvc, git
```

**Additional Information (if any):**

If applicable, please also provide a `--verbose` output of the command, eg: `dvc add --verbose`.



Discussion:
Hi, @tall-josh, thanks a lot for a great bug report/repro.

Looks like `_is_cached()` here only cares if deps/outs are cached already.
https://github.com/iterative/dvc/blob/0bb49d2549fab84db975351d7cc7da8e7b1ee6a5/dvc/stage/run.py#L81-L86

A quick-fix (untested) might be something like:
```diff
diff --git a/dvc/stage/run.py b/dvc/stage/run.py
index 53bab38d..59086365 100644
--- a/dvc/stage/run.py
+++ b/dvc/stage/run.py
@@ -82,6 +82,7 @@ def _is_cached(stage):
     cached = (
         not stage.is_callback
         and not stage.always_changed
+        and not stage.changed_stage()
         and stage.already_cached()
     )
     if cached:
```

Would you like to give it a try? :slightly_smiling_face: 

> Is using the lock_data correct? If the cmd has changed, shouldn't we use the stage_data?

The reason why it uses `lock_data` is because we might need to do checkouts with stale lock data even if cmd has changed.
Hi @skshetry. I tried commenting out `cls.fill_from_lock(stage, lock_data)` and it appeared to do what I wanted, but I'm unsure if it would bite me in the arse in the future.  I'll give your suggestion a go now.

@skshetry Looks like it worked :-) I have been playing with adding templating for a `vars` section in the multistage `dvc.yaml`. The parsing function is a bit _raw_ bit it looks like it's doing what I want it to.

[I've chucked it on a fork if you're interested](https://github.com/tall-josh/dvc/tree/master/TEMPLATING_DEMO)

I'll link this on the #3633 thread

Resolving patch:
diff --git a/dvc/stage/run.py b/dvc/stage/run.py
--- a/dvc/stage/run.py
+++ b/dvc/stage/run.py
@@ -82,6 +82,7 @@ def _is_cached(stage):
     cached = (
         not stage.is_callback
         and not stage.always_changed
+        and not stage.changed_stage()
         and stage.already_cached()
     )
     if cached:


Test patch:
diff --git a/tests/func/test_repro.py b/tests/func/test_repro.py
--- a/tests/func/test_repro.py
+++ b/tests/func/test_repro.py
@@ -1770,3 +1770,25 @@ def test_ssh_dir_out(tmp_dir, dvc, ssh_server):
 
     repo.reproduce("dir-out.dvc")
     repo.reproduce("dir-out.dvc", force=True)
+
+
+def test_repro_when_cmd_changes(tmp_dir, dvc, run_copy, mocker):
+    from dvc.dvcfile import SingleStageFile
+
+    tmp_dir.gen("foo", "foo")
+    stage = run_copy("foo", "bar", single_stage=True)
+    assert not dvc.reproduce(stage.addressing)
+
+    from dvc.stage.run import cmd_run
+
+    m = mocker.patch("dvc.stage.run.cmd_run", wraps=cmd_run)
+
+    data = SingleStageFile(dvc, stage.path)._load()[0]
+    data["cmd"] = "  ".join(stage.cmd.split())  # change cmd spacing by two
+    dump_stage_file(stage.path, data)
+
+    assert dvc.status([stage.addressing]) == {
+        stage.addressing: ["changed checksum"]
+    }
+    assert dvc.reproduce(stage.addressing)[0] == stage
+    m.assert_called_once_with(stage)
diff --git a/tests/func/test_repro_multistage.py b/tests/func/test_repro_multistage.py
--- a/tests/func/test_repro_multistage.py
+++ b/tests/func/test_repro_multistage.py
@@ -307,7 +307,7 @@ def test_downstream(tmp_dir, dvc):
     )
 
 
-def test_repro_when_cmd_changes(tmp_dir, dvc, run_copy):
+def test_repro_when_cmd_changes(tmp_dir, dvc, run_copy, mocker):
     from dvc.dvcfile import PipelineFile
 
     tmp_dir.gen("foo", "foo")
@@ -315,11 +315,15 @@ def test_repro_when_cmd_changes(tmp_dir, dvc, run_copy):
     target = "copy-file"
     assert not dvc.reproduce(target)
 
+    from dvc.stage.run import cmd_run
+
+    m = mocker.patch("dvc.stage.run.cmd_run", wraps=cmd_run)
     stage.cmd = "  ".join(stage.cmd.split())  # change cmd spacing by two
     PipelineFile(dvc, PIPELINE_FILE)._dump_pipeline_file(stage)
 
     assert dvc.status([target]) == {target: ["changed command"]}
     assert dvc.reproduce(target)[0] == stage
+    m.assert_called_once_with(stage)
 
 
 def test_repro_when_new_deps_is_added_in_dvcfile(tmp_dir, dvc, run_copy):