Repository: iterative/dvc

Issue:
Dry run option for dvc gc
Can we have a dry run option for `dvc gc` so we can see what files and directories the command would delete without actually deleting it?


Discussion:
@fedorn Sure. Contributions are welcome.
Happy to work on this one!
Related: https://discord.com/channels/485586884165107732/563406153334128681/1139483806957576212

Resolving patch:
diff --git a/dvc/commands/gc.py b/dvc/commands/gc.py
--- a/dvc/commands/gc.py
+++ b/dvc/commands/gc.py
@@ -81,6 +81,7 @@ def run(self):  # noqa: C901, PLR0912
             rev=self.args.rev,
             num=self.args.num,
             not_in_remote=self.args.not_in_remote,
+            dry=self.args.dry,
         )
         return 0
 
@@ -213,4 +214,10 @@ def add_parser(subparsers, parent_parser):
         ),
         metavar="<paths>",
     )
+    gc_parser.add_argument(
+        "--dry",
+        action="store_true",
+        default=False,
+        help=("Only print what would get removed without actually removing."),
+    )
     gc_parser.set_defaults(func=CmdGC)
diff --git a/dvc/repo/gc.py b/dvc/repo/gc.py
--- a/dvc/repo/gc.py
+++ b/dvc/repo/gc.py
@@ -67,6 +67,7 @@ def gc(  # noqa: PLR0913, C901
     rev: Optional[str] = None,
     num: Optional[int] = None,
     not_in_remote: bool = False,
+    dry: bool = False,
 ):
     # require `workspace` to be true to come into effect.
     # assume `workspace` to be enabled if any of `all_tags`, `all_commits`,
@@ -128,8 +129,10 @@ def gc(  # noqa: PLR0913, C901
     for scheme, odb in self.cache.by_scheme():
         if not odb:
             continue
-        removed = ogc(odb, used_obj_ids, jobs=jobs)
-        if not removed:
+        num_removed = ogc(odb, used_obj_ids, jobs=jobs, dry=dry)
+        if num_removed:
+            logger.info("Removed %d objects from %s cache.", num_removed, scheme)
+        else:
             logger.info("No unused '%s' cache to remove.", scheme)
 
     if not cloud:
@@ -137,9 +140,10 @@ def gc(  # noqa: PLR0913, C901
 
     for remote_odb, obj_ids in odb_to_obj_ids.items():
         assert remote_odb is not None
-        removed = ogc(remote_odb, obj_ids, jobs=jobs)
-        if removed:
+        num_removed = ogc(remote_odb, obj_ids, jobs=jobs, dry=dry)
+        if num_removed:
             get_index(remote_odb).clear()
+            logger.info("Removed %d objects from remote.", num_removed)
         else:
             logger.info("No unused cache to remove from remote.")
 


Test patch:
diff --git a/tests/func/test_gc.py b/tests/func/test_gc.py
--- a/tests/func/test_gc.py
+++ b/tests/func/test_gc.py
@@ -36,14 +36,14 @@ def test_gc_api(dvc, good_and_bad_cache):
     dvc.gc(workspace=True)
     odb = dvc.cache.local
     good_cache, bad_cache = good_and_bad_cache
-    assert set(odb.oids_exist([*good_cache, *bad_cache])) == good_cache
+    assert set(odb.all()) == good_cache
 
 
 def test_gc_cli(dvc, good_and_bad_cache):
     assert main(["gc", "-wf"]) == 0
     odb = dvc.cache.local
     good_cache, bad_cache = good_and_bad_cache
-    assert set(odb.oids_exist([*good_cache, *bad_cache])) == good_cache
+    assert set(odb.all()) == good_cache
 
 
 def test_gc_branches_tags(tmp_dir, dvc, scm):
@@ -423,3 +423,19 @@ def test_gc_cloud_remote_field(tmp_dir, scm, dvc, mocker, make_remote):
     mocked_remove = mocker.spy(LocalFileSystem, "remove")
     dvc.gc(workspace=True, cloud=True)
     assert len(mocked_remove.mock_calls) == 2  # local and other_remote
+
+
+def test_gc_dry(dvc, good_and_bad_cache):
+    dvc.gc(workspace=True, dry=True)
+    odb = dvc.cache.local
+    good_cache, bad_cache = good_and_bad_cache
+    assert set(odb.all()) != good_cache
+
+
+def test_gc_logging(caplog, dvc, good_and_bad_cache):
+    with caplog.at_level(logging.INFO, logger="dvc"):
+        dvc.gc(workspace=True)
+
+    assert "Removed 3 objects from repo cache." in caplog.text
+    assert "No unused 'local' cache to remove." in caplog.text
+    assert "No unused 'legacy' cache to remove." in caplog.text
diff --git a/tests/unit/command/test_gc.py b/tests/unit/command/test_gc.py
--- a/tests/unit/command/test_gc.py
+++ b/tests/unit/command/test_gc.py
@@ -22,6 +22,7 @@ def test_(dvc, scm, mocker):
             "--force",
             "--jobs",
             "3",
+            "--dry",
             "--projects",
             "project1",
             "project2",
@@ -49,6 +50,7 @@ def test_(dvc, scm, mocker):
         rev=None,
         num=None,
         not_in_remote=False,
+        dry=True,
     )
 
     cli_args = parse_args(["gc"])