Repository: iterative/dvc

Issue:
Not in remote check
I want to be able to easily check that no data state is committed without that data existing in a specific remote using a Github Action. I can do this now using:
```
      - name: Set default remote
        run: dvc remote default s3-remote && dvc remote remove local-remote

      - name: Check remote data
        run: dvc data status --not-in-remote | tr '\n' ' ' | tee /dev/stderr | grep -v -q "Not in remote"
```
But this is not so pretty.    


To simplify it I would like to suggest the addition of two new flags to `dvc data status`:
- `--remote` to be able to specify which remote to check when using `--not-in-remote` similarly to `dvc pull`/`dvc push`
- `-e` (or another name) that returns a non-zero exit code if any data is not in the remote.

The resulting command would then be:
```
dvc data status --not-in-remote --remote s3-remote -e
```


Discussion:
> * `--remote` to be able to specify which remote to check when using `--not-in-remote` similarly to `dvc pull`/`dvc push`

This makes sense. Contributions are welcome. The implementation may be quite simple, simpler than what we do for fetch/pull/push. 

https://github.com/iterative/dvc/blob/b4d8690799c84c2b1f58822523a44a85c96a695e/dvc/repo/fetch.py#L47-L50

```python
if remote:
    repo.config["core"] = remote
```

which is equivalent to what you do above through CLI.

> 
> * `-e` (or another name) that returns a non-zero exit code if any data is not in the remote.
> 
> 
> The resulting command would then be:
> 
> ```
> dvc data status --not-in-remote --remote s3-remote -e
> ```

I am not sure about this. `data status` is more about local status, so I don't want to add an option specific to remote statuses.

We could add `--exit-code` that would return nonzero exit code if there are any changes (not including remote). And when `--not-in-remote` is also used, that'd also take "not_in_remote" changes into account for considering exit code.

I don't think this will be any different to:
```
dvc data status --not-in-remote | grep "No changes" 
```

The `-exit-code` is however more reliable, as we could change the message and break it. But we also have `--json` for machine readable output that you could use.

```console
dvc data status --not-in-remote --json |  jq --exit-status '. == {}'
```


@skshetry Thanks for adding the enhancement, i was working with DVC lately and working on this enhancement since last few hrs.  

Could you please let me know how you’d like me to proceed or coordinate on similar tasks in the future to avoid overlap? I would like to contribute on other open issue/enhancement requests. 


@anmol-hpe, I’m really sorry to hear that. In the future, please leave a comment on the issue/ticket if you’re working on it or interested on working, so other contributors, including me, are aware. 
Sure, thank you @skshetry. Will follow that. 



Patch:
diff --git a/dvc/commands/data.py b/dvc/commands/data.py
index c657749d40..4e8e398fd8 100644
--- a/dvc/commands/data.py
+++ b/dvc/commands/data.py
@@ -111,6 +111,7 @@ def run(self) -> int:
                 targets=self.args.targets,
                 granular=self.args.granular,
                 untracked_files=self.args.untracked_files,
+                remote=self.args.remote,
                 not_in_remote=self.args.not_in_remote,
                 remote_refresh=self.args.remote_refresh,
             )
@@ -182,6 +183,12 @@ def add_parser(subparsers, parent_parser):
         nargs="?",
         help="Show untracked files.",
     )
+    data_status_parser.add_argument(
+        "-r",
+        "--remote",
+        help="Remote storage to check (only applicable with --not-in-remote).",
+        metavar="<name>",
+    )
     data_status_parser.add_argument(
         "--not-in-remote",
         action="store_true",
diff --git a/dvc/repo/data.py b/dvc/repo/data.py
index dc5f2b4b53..c209f17a38 100644
--- a/dvc/repo/data.py
+++ b/dvc/repo/data.py
@@ -404,13 +404,22 @@ def status(
     *,
     granular: bool = False,
     untracked_files: str = "no",
+    remote: Optional[str] = None,
     not_in_remote: bool = False,
     remote_refresh: bool = False,
+    config: Optional[dict] = None,
     batch_size: Optional[int] = None,
     head: str = "HEAD",
 ) -> Status:
     from dvc.scm import NoSCMError, SCMError
 
+    config = config or {}
+    if remote and not_in_remote:
+        logger.debug("Using remote %r", remote)
+        core = config.setdefault("core", {})
+        core["remote"] = remote
+    repo.config.merge(config)
+
     targets = targets or []
     filter_keys: list[DataIndexKey] = [repo.fs.relparts(os.fspath(t)) for t in targets]
     # try to remove duplicate and overlapping keys


Test patch:
diff --git a/tests/func/test_data_status.py b/tests/func/test_data_status.py
index f45c53a8d1..bfaed9a822 100644
--- a/tests/func/test_data_status.py
+++ b/tests/func/test_data_status.py
@@ -398,6 +398,42 @@ def test_missing_dir_object_from_index(M, tmp_dir, dvc, scm):
     }
 
 
+def test_remote_check(M, tmp_dir, dvc, scm, make_remote):
+    make_remote("default", default=True)
+    make_remote("myremote", default=False)
+
+    tmp_dir.dvc_gen({"dir": {"foo": "foo", "bar": "bar"}})
+    tmp_dir.dvc_gen("foobar", "foobar")
+    assert dvc.push() == 4
+    scm.add_commit(["dir.dvc", "foobar.dvc", ".gitignore"], message="add files")
+
+    entries = M.unordered("foobar", join("dir", ""))
+    granular_entries = M.unordered(
+        "foobar", join("dir", ""), join("dir", "foo"), join("dir", "bar")
+    )
+    expected_ng = EMPTY_STATUS | {"git": M.dict(), "unchanged": entries}
+    expected_g = EMPTY_STATUS | {
+        "not_in_remote": [],
+        "git": M.dict(),
+        "unchanged": granular_entries,
+    }
+
+    opts = {"not_in_remote": True, "remote_refresh": True}
+    assert dvc.data_status(**opts) == expected_ng
+    assert dvc.data_status(granular=True, **opts) == expected_g
+
+    opts |= {"remote": "myremote"}
+    assert dvc.data_status(**opts) == expected_ng | {"not_in_remote": entries}
+    assert dvc.data_status(granular=True, **opts) == expected_g | {
+        "not_in_remote": granular_entries
+    }
+
+    dvc.push(remote="myremote")
+
+    assert dvc.data_status(**opts) == expected_ng
+    assert dvc.data_status(granular=True, **opts) == expected_g
+
+
 def test_missing_remote_cache(M, tmp_dir, dvc, scm, local_remote):
     tmp_dir.dvc_gen({"dir": {"foo": "foo", "bar": "bar"}})
     tmp_dir.dvc_gen("foobar", "foobar")
diff --git a/tests/unit/command/test_data_status.py b/tests/unit/command/test_data_status.py
index 63c7a16701..04c5298811 100644
--- a/tests/unit/command/test_data_status.py
+++ b/tests/unit/command/test_data_status.py
@@ -43,6 +43,8 @@ def test_cli(dvc, mocker, mocked_status):
             "--unchanged",
             "--untracked-files",
             "--granular",
+            "--remote",
+            "myremote",
         ]
     )
 
@@ -53,6 +55,7 @@ def test_cli(dvc, mocker, mocked_status):
         targets=[],
         untracked_files="all",
         not_in_remote=False,
+        remote="myremote",
         remote_refresh=True,
         granular=True,
     )