From be33106ffc516c9631a78e1c1d2ebea3a955d71a Mon Sep 17 00:00:00 2001 From: Gavin Mak Date: Wed, 19 Nov 2025 16:46:07 -0800 Subject: wipe: Add new `repo wipe` subcommand This new command allows users to delete projects from the worktree and from the `.repo` directory. It is a destructive operation. It handles shared projects by refusing to wipe them unless the `--force` flag is used. It also checks for uncommitted changes before wiping. Bug: 393383056 Change-Id: Ia30d8ffdc781a3f179af56310ce31c9dae331bbe Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/490801 Tested-by: Gavin Mak Reviewed-by: Mike Frysinger Commit-Queue: Gavin Mak --- tests/test_subcmds_wipe.py | 263 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 tests/test_subcmds_wipe.py (limited to 'tests/test_subcmds_wipe.py') diff --git a/tests/test_subcmds_wipe.py b/tests/test_subcmds_wipe.py new file mode 100644 index 00000000..ae515e3d --- /dev/null +++ b/tests/test_subcmds_wipe.py @@ -0,0 +1,263 @@ +# Copyright (C) 2025 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import shutil +from unittest import mock + +import pytest + +import project +from subcmds import wipe + + +def _create_mock_project(tempdir, name, objdir_path=None, has_changes=False): + """Creates a mock project with necessary attributes and directories.""" + worktree = os.path.join(tempdir, name) + gitdir = os.path.join(tempdir, ".repo/projects", f"{name}.git") + if objdir_path: + objdir = objdir_path + else: + objdir = os.path.join(tempdir, ".repo/project-objects", f"{name}.git") + + os.makedirs(worktree, exist_ok=True) + os.makedirs(gitdir, exist_ok=True) + os.makedirs(objdir, exist_ok=True) + + proj = project.Project( + manifest=mock.MagicMock(), + name=name, + remote=mock.MagicMock(), + gitdir=gitdir, + objdir=objdir, + worktree=worktree, + relpath=name, + revisionExpr="main", + revisionId="abcd", + ) + + proj.HasChanges = mock.MagicMock(return_value=has_changes) + + def side_effect_delete_worktree(force=False, verbose=False): + if os.path.exists(proj.worktree): + shutil.rmtree(proj.worktree) + if os.path.exists(proj.gitdir): + shutil.rmtree(proj.gitdir) + return True + + proj.DeleteWorktree = mock.MagicMock( + side_effect=side_effect_delete_worktree + ) + + return proj + + +def _run_wipe(all_projects, projects_to_wipe_names, options=None): + """Helper to run the Wipe command with mocked projects.""" + cmd = wipe.Wipe() + cmd.manifest = mock.MagicMock() + + def get_projects_mock(projects, all_manifests=False, **kwargs): + if projects is None: + return all_projects + names_to_find = set(projects) + return [p for p in all_projects if p.name in names_to_find] + + cmd.GetProjects = mock.MagicMock(side_effect=get_projects_mock) + + if options is None: + options = [] + + opts = cmd.OptionParser.parse_args(options + projects_to_wipe_names)[0] + cmd.CommonValidateOptions(opts, projects_to_wipe_names) + cmd.ValidateOptions(opts, projects_to_wipe_names) + cmd.Execute(opts, projects_to_wipe_names) + + +def test_wipe_single_unshared_project(tmp_path): + """Test wiping a single project that is not shared.""" + p1 = _create_mock_project(str(tmp_path), "project/one") + _run_wipe([p1], ["project/one"]) + + assert not os.path.exists(p1.worktree) + assert not os.path.exists(p1.gitdir) + assert not os.path.exists(p1.objdir) + + +def test_wipe_multiple_unshared_projects(tmp_path): + """Test wiping multiple projects that are not shared.""" + p1 = _create_mock_project(str(tmp_path), "project/one") + p2 = _create_mock_project(str(tmp_path), "project/two") + _run_wipe([p1, p2], ["project/one", "project/two"]) + + assert not os.path.exists(p1.worktree) + assert not os.path.exists(p1.gitdir) + assert not os.path.exists(p1.objdir) + assert not os.path.exists(p2.worktree) + assert not os.path.exists(p2.gitdir) + assert not os.path.exists(p2.objdir) + + +def test_wipe_shared_project_no_force_raises_error(tmp_path): + """Test that wiping a shared project without --force raises an error.""" + shared_objdir = os.path.join( + str(tmp_path), ".repo/project-objects", "shared.git" + ) + p1 = _create_mock_project( + str(tmp_path), "project/one", objdir_path=shared_objdir + ) + p2 = _create_mock_project( + str(tmp_path), "project/two", objdir_path=shared_objdir + ) + + with pytest.raises(wipe.Error) as e: + _run_wipe([p1, p2], ["project/one"]) + + assert "shared object directories" in str(e.value) + assert "project/one" in str(e.value) + assert "project/two" in str(e.value) + + assert os.path.exists(p1.worktree) + assert os.path.exists(p1.gitdir) + assert os.path.exists(p2.worktree) + assert os.path.exists(p2.gitdir) + assert os.path.exists(shared_objdir) + + +def test_wipe_shared_project_with_force(tmp_path): + """Test wiping a shared project with --force.""" + shared_objdir = os.path.join( + str(tmp_path), ".repo/project-objects", "shared.git" + ) + p1 = _create_mock_project( + str(tmp_path), "project/one", objdir_path=shared_objdir + ) + p2 = _create_mock_project( + str(tmp_path), "project/two", objdir_path=shared_objdir + ) + + _run_wipe([p1, p2], ["project/one"], options=["--force"]) + + assert not os.path.exists(p1.worktree) + assert not os.path.exists(p1.gitdir) + assert os.path.exists(shared_objdir) + assert os.path.exists(p2.worktree) + assert os.path.exists(p2.gitdir) + + +def test_wipe_all_sharing_projects(tmp_path): + """Test wiping all projects that share an object directory.""" + shared_objdir = os.path.join( + str(tmp_path), ".repo/project-objects", "shared.git" + ) + p1 = _create_mock_project( + str(tmp_path), "project/one", objdir_path=shared_objdir + ) + p2 = _create_mock_project( + str(tmp_path), "project/two", objdir_path=shared_objdir + ) + + _run_wipe([p1, p2], ["project/one", "project/two"]) + + assert not os.path.exists(p1.worktree) + assert not os.path.exists(p1.gitdir) + assert not os.path.exists(p2.worktree) + assert not os.path.exists(p2.gitdir) + assert not os.path.exists(shared_objdir) + + +def test_wipe_with_uncommitted_changes_raises_error(tmp_path): + """Test wiping a project with uncommitted changes raises an error.""" + p1 = _create_mock_project(str(tmp_path), "project/one", has_changes=True) + + with pytest.raises(wipe.Error) as e: + _run_wipe([p1], ["project/one"]) + + assert "uncommitted changes" in str(e.value) + assert "project/one" in str(e.value) + + assert os.path.exists(p1.worktree) + assert os.path.exists(p1.gitdir) + assert os.path.exists(p1.objdir) + + +def test_wipe_with_uncommitted_changes_with_force(tmp_path): + """Test wiping a project with uncommitted changes with --force.""" + p1 = _create_mock_project(str(tmp_path), "project/one", has_changes=True) + _run_wipe([p1], ["project/one"], options=["--force"]) + + assert not os.path.exists(p1.worktree) + assert not os.path.exists(p1.gitdir) + assert not os.path.exists(p1.objdir) + + +def test_wipe_uncommitted_and_shared_raises_combined_error(tmp_path): + """Test that uncommitted and shared projects raise a combined error.""" + shared_objdir = os.path.join( + str(tmp_path), ".repo/project-objects", "shared.git" + ) + p1 = _create_mock_project( + str(tmp_path), + "project/one", + objdir_path=shared_objdir, + has_changes=True, + ) + p2 = _create_mock_project( + str(tmp_path), "project/two", objdir_path=shared_objdir + ) + + with pytest.raises(wipe.Error) as e: + _run_wipe([p1, p2], ["project/one"]) + + assert "uncommitted changes" in str(e.value) + assert "shared object directories" in str(e.value) + assert "project/one" in str(e.value) + assert "project/two" in str(e.value) + + assert os.path.exists(p1.worktree) + assert os.path.exists(p1.gitdir) + assert os.path.exists(p2.worktree) + assert os.path.exists(p2.gitdir) + assert os.path.exists(shared_objdir) + + +def test_wipe_shared_project_with_force_shared(tmp_path): + """Test wiping a shared project with --force-shared.""" + shared_objdir = os.path.join( + str(tmp_path), ".repo/project-objects", "shared.git" + ) + p1 = _create_mock_project( + str(tmp_path), "project/one", objdir_path=shared_objdir + ) + p2 = _create_mock_project( + str(tmp_path), "project/two", objdir_path=shared_objdir + ) + + _run_wipe([p1, p2], ["project/one"], options=["--force-shared"]) + + assert not os.path.exists(p1.worktree) + assert not os.path.exists(p1.gitdir) + assert os.path.exists(shared_objdir) + assert os.path.exists(p2.worktree) + assert os.path.exists(p2.gitdir) + + +def test_wipe_with_uncommitted_changes_with_force_uncommitted(tmp_path): + """Test wiping uncommitted changes with --force-uncommitted.""" + p1 = _create_mock_project(str(tmp_path), "project/one", has_changes=True) + _run_wipe([p1], ["project/one"], options=["--force-uncommitted"]) + + assert not os.path.exists(p1.worktree) + assert not os.path.exists(p1.gitdir) + assert not os.path.exists(p1.objdir) -- cgit v1.2.3-54-g00ecf