Repository: iterative/dvc

Issue:
remove: delete .gitignore file if empty
Just like `dvc remove` deletes dvc.yaml and dvc.lock if they're empty after the removal, should it do the same to .gitignore? Currently it's left there even if empty, which shows up as a change in git (so it's pretty obvious).


Comments:
I am exploring DVC. Can I be assigned this one ?
@devramx Sure! Thanks for looking into it! :pray: 

Patch:
diff --git a/dvc/scm/git/__init__.py b/dvc/scm/git/__init__.py
--- a/dvc/scm/git/__init__.py
+++ b/dvc/scm/git/__init__.py
@@ -165,6 +165,10 @@ def ignore_remove(self, path):
 
         filtered = list(filter(lambda x: x.strip() != entry.strip(), lines))
 
+        if not filtered:
+            os.unlink(gitignore)
+            return
+
         with open(gitignore, "w") as fobj:
             fobj.writelines(filtered)
 


Test patch:
diff --git a/tests/func/test_remove.py b/tests/func/test_remove.py
--- a/tests/func/test_remove.py
+++ b/tests/func/test_remove.py
@@ -29,7 +29,7 @@ def test_remove(tmp_dir, scm, dvc, run_copy, remove_outs):
         else:
             assert all(out_exists)
 
-        assert not any(out in get_gitignore_content() for out in stage.outs)
+    assert not (tmp_dir / ".gitignore").exists()
 
 
 def test_remove_non_existent_file(tmp_dir, dvc):
@@ -69,3 +69,29 @@ def test_cmd_remove(tmp_dir, dvc):
     assert main(["remove", stage.addressing, "--outs"]) == 0
     assert not (tmp_dir / stage.relpath).exists()
     assert not (tmp_dir / "foo").exists()
+
+
+def test_cmd_remove_gitignore_single_stage(tmp_dir, scm, dvc, run_copy):
+    stage = dvc.run(
+        name="my", cmd='echo "hello" > out', deps=[], outs=["out"],
+    )
+
+    assert (tmp_dir / ".gitignore").exists()
+
+    assert main(["remove", stage.addressing]) == 0
+    assert not (tmp_dir / stage.relpath).exists()
+    assert not (stage.dvcfile._lockfile).exists()
+    assert not (tmp_dir / ".gitignore").exists()
+
+
+def test_cmd_remove_gitignore_multistage(tmp_dir, scm, dvc, run_copy):
+    (stage,) = tmp_dir.dvc_gen("foo", "foo")
+    stage1 = run_copy("foo", "foo1", single_stage=True)
+    stage2 = run_copy("foo1", "foo2", name="copy-foo1-foo2")
+
+    assert (tmp_dir / ".gitignore").exists()
+
+    assert main(["remove", stage2.addressing]) == 0
+    assert main(["remove", stage1.addressing]) == 0
+    assert main(["remove", stage.addressing]) == 0
+    assert not (tmp_dir / ".gitignore").exists()
diff --git a/tests/func/test_scm.py b/tests/func/test_scm.py
--- a/tests/func/test_scm.py
+++ b/tests/func/test_scm.py
@@ -85,7 +85,7 @@ def test_ignore(tmp_dir, scm):
     assert _count_gitignore_entries(target) == 1
 
     scm.ignore_remove(foo)
-    assert _count_gitignore_entries(target) == 0
+    assert not (tmp_dir / ".gitignore").exists()
 
 
 def test_ignored(tmp_dir, scm):
diff --git a/tests/unit/scm/test_git.py b/tests/unit/scm/test_git.py
--- a/tests/unit/scm/test_git.py
+++ b/tests/unit/scm/test_git.py
@@ -279,3 +279,25 @@ def test_list_all_commits(tmp_dir, scm):
     scm.set_ref("refs/foo/bar", rev_c)
 
     assert {rev_a, rev_b} == set(scm.list_all_commits())
+
+
+def test_ignore_remove_empty(tmp_dir, scm, git):
+
+    test_entries = [
+        {"entry": "/foo1", "path": f"{tmp_dir}/foo1"},
+        {"entry": "/foo2", "path": f"{tmp_dir}/foo2"},
+    ]
+
+    path_to_gitignore = tmp_dir / ".gitignore"
+
+    with open(path_to_gitignore, "a") as f:
+        for entry in test_entries:
+            f.write(entry["entry"] + "\n")
+
+    assert path_to_gitignore.exists()
+
+    git.ignore_remove(test_entries[0]["path"])
+    assert path_to_gitignore.exists()
+
+    git.ignore_remove(test_entries[1]["path"])
+    assert not path_to_gitignore.exists()