diff --git a/man/repo-wipe.1 b/man/repo-wipe.1 new file mode 100644 index 000000000..1a8f7c16e --- /dev/null +++ b/man/repo-wipe.1 @@ -0,0 +1,61 @@ +.\" DO NOT MODIFY THIS FILE! It was generated by help2man. +.TH REPO "1" "November 2025" "repo wipe" "Repo Manual" +.SH NAME +repo \- repo wipe - manual page for repo wipe +.SH SYNOPSIS +.B repo +\fI\,wipe \/\fR... +.SH DESCRIPTION +Summary +.PP +Wipe projects from the worktree +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +show this help message and exit +.TP +\fB\-f\fR, \fB\-\-force\fR +force wipe shared projects and uncommitted changes +.TP +\fB\-\-force\-uncommitted\fR +force wipe even if there are uncommitted changes +.TP +\fB\-\-force\-shared\fR +force wipe even if the project shares an object +directory +.SS Logging options: +.TP +\fB\-v\fR, \fB\-\-verbose\fR +show all output +.TP +\fB\-q\fR, \fB\-\-quiet\fR +only show errors +.SS Multi\-manifest options: +.TP +\fB\-\-outer\-manifest\fR +operate starting at the outermost manifest +.TP +\fB\-\-no\-outer\-manifest\fR +do not operate on outer manifests +.TP +\fB\-\-this\-manifest\-only\fR +only operate on this (sub)manifest +.TP +\fB\-\-no\-this\-manifest\-only\fR, \fB\-\-all\-manifests\fR +operate on this manifest and its submanifests +.PP +Run `repo help wipe` to view the detailed manual. +.SH DETAILS +.PP +The 'repo wipe' command removes the specified projects from the worktree (the +checked out source code) and deletes the project's git data from `.repo`. +.PP +This is a destructive operation and cannot be undone. +.PP +Projects can be specified either by name, or by a relative or absolute path to +the project's local directory. +.SH EXAMPLES +.SS # Wipe the project "platform/build" by name: +$ repo wipe platform/build +.SS # Wipe the project at the path "build/make": +$ repo wipe build/make diff --git a/man/repo.1 b/man/repo.1 index 6c0e02558..101b8f650 100644 --- a/man/repo.1 +++ b/man/repo.1 @@ -1,5 +1,5 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man. -.TH REPO "1" "April 2025" "repo" "Repo Manual" +.TH REPO "1" "November 2025" "repo" "Repo Manual" .SH NAME repo \- repository management tool built on top of git .SH SYNOPSIS @@ -132,6 +132,9 @@ Upload changes for code review .TP version Display the version of repo +.TP +wipe +Wipe projects from the worktree .PP See 'repo help ' for more information on a specific command. Bug reports: https://issues.gerritcodereview.com/issues/new?component=1370071 diff --git a/subcmds/wipe.py b/subcmds/wipe.py new file mode 100644 index 000000000..519386499 --- /dev/null +++ b/subcmds/wipe.py @@ -0,0 +1,184 @@ +# 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 sys +from typing import List + +from command import Command +from error import GitError +from error import RepoExitError +import platform_utils +from project import DeleteWorktreeError + + +class Error(RepoExitError): + """Exit error when wipe command fails.""" + + +class Wipe(Command): + """Delete projects from the worktree and .repo""" + + COMMON = True + helpSummary = "Wipe projects from the worktree" + helpUsage = """ +%prog ... +""" + helpDescription = """ +The '%prog' command removes the specified projects from the worktree +(the checked out source code) and deletes the project's git data from `.repo`. + +This is a destructive operation and cannot be undone. + +Projects can be specified either by name, or by a relative or absolute path +to the project's local directory. + +Examples: + + # Wipe the project "platform/build" by name: + $ repo wipe platform/build + + # Wipe the project at the path "build/make": + $ repo wipe build/make +""" + + def _Options(self, p): + # TODO(crbug.com/gerrit/393383056): Add --broken option to scan and + # wipe broken projects. + p.add_option( + "-f", + "--force", + action="store_true", + help="force wipe shared projects and uncommitted changes", + ) + p.add_option( + "--force-uncommitted", + action="store_true", + help="force wipe even if there are uncommitted changes", + ) + p.add_option( + "--force-shared", + action="store_true", + help="force wipe even if the project shares an object directory", + ) + + def ValidateOptions(self, opt, args: List[str]): + if not args: + self.Usage() + + def Execute(self, opt, args: List[str]): + # Get all projects to handle shared object directories. + all_projects = self.GetProjects(None, all_manifests=True, groups="all") + projects_to_wipe = self.GetProjects(args, all_manifests=True) + relpaths_to_wipe = {p.relpath for p in projects_to_wipe} + + # Build a map from objdir to the relpaths of projects that use it. + objdir_map = {} + for p in all_projects: + objdir_map.setdefault(p.objdir, set()).add(p.relpath) + + uncommitted_projects = [] + shared_objdirs = {} + objdirs_to_delete = set() + + for project in projects_to_wipe: + if project == self.manifest.manifestProject: + raise Error( + f"error: cannot wipe the manifest project: {project.name}" + ) + + try: + if project.HasChanges(): + uncommitted_projects.append(project.name) + except GitError: + uncommitted_projects.append(f"{project.name} (corrupted)") + + users = objdir_map.get(project.objdir, {project.relpath}) + is_shared = not users.issubset(relpaths_to_wipe) + if is_shared: + shared_objdirs.setdefault(project.objdir, set()).update(users) + else: + objdirs_to_delete.add(project.objdir) + + block_uncommitted = uncommitted_projects and not ( + opt.force or opt.force_uncommitted + ) + block_shared = shared_objdirs and not (opt.force or opt.force_shared) + + if block_uncommitted or block_shared: + error_messages = [] + if block_uncommitted: + error_messages.append( + "The following projects have uncommitted changes or are " + "corrupted:\n" + + "\n".join(f" - {p}" for p in sorted(uncommitted_projects)) + ) + if block_shared: + shared_dir_messages = [] + for objdir, users in sorted(shared_objdirs.items()): + other_users = users - relpaths_to_wipe + projects_to_wipe_in_dir = users & relpaths_to_wipe + message = f"""Object directory {objdir} is shared by: + Projects to be wiped: {', '.join(sorted(projects_to_wipe_in_dir))} + Projects not to be wiped: {', '.join(sorted(other_users))}""" + shared_dir_messages.append(message) + error_messages.append( + "The following projects have shared object directories:\n" + + "\n".join(sorted(shared_dir_messages)) + ) + + if block_uncommitted and block_shared: + error_messages.append( + "Use --force to wipe anyway, or --force-uncommitted and " + "--force-shared to specify." + ) + elif block_uncommitted: + error_messages.append("Use --force-uncommitted to wipe anyway.") + else: + error_messages.append("Use --force-shared to wipe anyway.") + + raise Error("\n\n".join(error_messages)) + + # If we are here, either there were no issues, or --force was used. + # Proceed with wiping. + successful_wipes = set() + + for project in projects_to_wipe: + try: + # Force the delete here since we've already performed our + # own safety checks above. + project.DeleteWorktree(force=True, verbose=opt.verbose) + successful_wipes.add(project.relpath) + except DeleteWorktreeError as e: + print( + f"error: failed to wipe {project.name}: {e}", + file=sys.stderr, + ) + + # Clean up object directories only if all projects using them were + # successfully wiped. + for objdir in objdirs_to_delete: + users = objdir_map.get(objdir, set()) + # Check if every project that uses this objdir has been + # successfully processed. If a project failed to be wiped, don't + # delete the object directory, or we'll corrupt the remaining + # project. + if users.issubset(successful_wipes): + if os.path.exists(objdir): + if opt.verbose: + print( + f"Deleting objects directory: {objdir}", + file=sys.stderr, + ) + platform_utils.rmtree(objdir) diff --git a/tests/test_subcmds_wipe.py b/tests/test_subcmds_wipe.py new file mode 100644 index 000000000..ae515e3d2 --- /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)