summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--man/repo-wipe.161
-rw-r--r--man/repo.15
-rw-r--r--subcmds/wipe.py184
-rw-r--r--tests/test_subcmds_wipe.py263
4 files changed, 512 insertions, 1 deletions
diff --git a/man/repo-wipe.1 b/man/repo-wipe.1
new file mode 100644
index 00000000..1a8f7c16
--- /dev/null
+++ b/man/repo-wipe.1
@@ -0,0 +1,61 @@
1.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
2.TH REPO "1" "November 2025" "repo wipe" "Repo Manual"
3.SH NAME
4repo \- repo wipe - manual page for repo wipe
5.SH SYNOPSIS
6.B repo
7\fI\,wipe <project>\/\fR...
8.SH DESCRIPTION
9Summary
10.PP
11Wipe projects from the worktree
12.SH OPTIONS
13.TP
14\fB\-h\fR, \fB\-\-help\fR
15show this help message and exit
16.TP
17\fB\-f\fR, \fB\-\-force\fR
18force wipe shared projects and uncommitted changes
19.TP
20\fB\-\-force\-uncommitted\fR
21force wipe even if there are uncommitted changes
22.TP
23\fB\-\-force\-shared\fR
24force wipe even if the project shares an object
25directory
26.SS Logging options:
27.TP
28\fB\-v\fR, \fB\-\-verbose\fR
29show all output
30.TP
31\fB\-q\fR, \fB\-\-quiet\fR
32only show errors
33.SS Multi\-manifest options:
34.TP
35\fB\-\-outer\-manifest\fR
36operate starting at the outermost manifest
37.TP
38\fB\-\-no\-outer\-manifest\fR
39do not operate on outer manifests
40.TP
41\fB\-\-this\-manifest\-only\fR
42only operate on this (sub)manifest
43.TP
44\fB\-\-no\-this\-manifest\-only\fR, \fB\-\-all\-manifests\fR
45operate on this manifest and its submanifests
46.PP
47Run `repo help wipe` to view the detailed manual.
48.SH DETAILS
49.PP
50The 'repo wipe' command removes the specified projects from the worktree (the
51checked out source code) and deletes the project's git data from `.repo`.
52.PP
53This is a destructive operation and cannot be undone.
54.PP
55Projects can be specified either by name, or by a relative or absolute path to
56the project's local directory.
57.SH EXAMPLES
58.SS # Wipe the project "platform/build" by name:
59$ repo wipe platform/build
60.SS # Wipe the project at the path "build/make":
61$ repo wipe build/make
diff --git a/man/repo.1 b/man/repo.1
index 6c0e0255..101b8f65 100644
--- a/man/repo.1
+++ b/man/repo.1
@@ -1,5 +1,5 @@
1.\" DO NOT MODIFY THIS FILE! It was generated by help2man. 1.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
2.TH REPO "1" "April 2025" "repo" "Repo Manual" 2.TH REPO "1" "November 2025" "repo" "Repo Manual"
3.SH NAME 3.SH NAME
4repo \- repository management tool built on top of git 4repo \- repository management tool built on top of git
5.SH SYNOPSIS 5.SH SYNOPSIS
@@ -132,6 +132,9 @@ Upload changes for code review
132.TP 132.TP
133version 133version
134Display the version of repo 134Display the version of repo
135.TP
136wipe
137Wipe projects from the worktree
135.PP 138.PP
136See 'repo help <command>' for more information on a specific command. 139See 'repo help <command>' for more information on a specific command.
137Bug reports: https://issues.gerritcodereview.com/issues/new?component=1370071 140Bug reports: https://issues.gerritcodereview.com/issues/new?component=1370071
diff --git a/subcmds/wipe.py b/subcmds/wipe.py
new file mode 100644
index 00000000..51938649
--- /dev/null
+++ b/subcmds/wipe.py
@@ -0,0 +1,184 @@
1# Copyright (C) 2025 The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import os
16import sys
17from typing import List
18
19from command import Command
20from error import GitError
21from error import RepoExitError
22import platform_utils
23from project import DeleteWorktreeError
24
25
26class Error(RepoExitError):
27 """Exit error when wipe command fails."""
28
29
30class Wipe(Command):
31 """Delete projects from the worktree and .repo"""
32
33 COMMON = True
34 helpSummary = "Wipe projects from the worktree"
35 helpUsage = """
36%prog <project>...
37"""
38 helpDescription = """
39The '%prog' command removes the specified projects from the worktree
40(the checked out source code) and deletes the project's git data from `.repo`.
41
42This is a destructive operation and cannot be undone.
43
44Projects can be specified either by name, or by a relative or absolute path
45to the project's local directory.
46
47Examples:
48
49 # Wipe the project "platform/build" by name:
50 $ repo wipe platform/build
51
52 # Wipe the project at the path "build/make":
53 $ repo wipe build/make
54"""
55
56 def _Options(self, p):
57 # TODO(crbug.com/gerrit/393383056): Add --broken option to scan and
58 # wipe broken projects.
59 p.add_option(
60 "-f",
61 "--force",
62 action="store_true",
63 help="force wipe shared projects and uncommitted changes",
64 )
65 p.add_option(
66 "--force-uncommitted",
67 action="store_true",
68 help="force wipe even if there are uncommitted changes",
69 )
70 p.add_option(
71 "--force-shared",
72 action="store_true",
73 help="force wipe even if the project shares an object directory",
74 )
75
76 def ValidateOptions(self, opt, args: List[str]):
77 if not args:
78 self.Usage()
79
80 def Execute(self, opt, args: List[str]):
81 # Get all projects to handle shared object directories.
82 all_projects = self.GetProjects(None, all_manifests=True, groups="all")
83 projects_to_wipe = self.GetProjects(args, all_manifests=True)
84 relpaths_to_wipe = {p.relpath for p in projects_to_wipe}
85
86 # Build a map from objdir to the relpaths of projects that use it.
87 objdir_map = {}
88 for p in all_projects:
89 objdir_map.setdefault(p.objdir, set()).add(p.relpath)
90
91 uncommitted_projects = []
92 shared_objdirs = {}
93 objdirs_to_delete = set()
94
95 for project in projects_to_wipe:
96 if project == self.manifest.manifestProject:
97 raise Error(
98 f"error: cannot wipe the manifest project: {project.name}"
99 )
100
101 try:
102 if project.HasChanges():
103 uncommitted_projects.append(project.name)
104 except GitError:
105 uncommitted_projects.append(f"{project.name} (corrupted)")
106
107 users = objdir_map.get(project.objdir, {project.relpath})
108 is_shared = not users.issubset(relpaths_to_wipe)
109 if is_shared:
110 shared_objdirs.setdefault(project.objdir, set()).update(users)
111 else:
112 objdirs_to_delete.add(project.objdir)
113
114 block_uncommitted = uncommitted_projects and not (
115 opt.force or opt.force_uncommitted
116 )
117 block_shared = shared_objdirs and not (opt.force or opt.force_shared)
118
119 if block_uncommitted or block_shared:
120 error_messages = []
121 if block_uncommitted:
122 error_messages.append(
123 "The following projects have uncommitted changes or are "
124 "corrupted:\n"
125 + "\n".join(f" - {p}" for p in sorted(uncommitted_projects))
126 )
127 if block_shared:
128 shared_dir_messages = []
129 for objdir, users in sorted(shared_objdirs.items()):
130 other_users = users - relpaths_to_wipe
131 projects_to_wipe_in_dir = users & relpaths_to_wipe
132 message = f"""Object directory {objdir} is shared by:
133 Projects to be wiped: {', '.join(sorted(projects_to_wipe_in_dir))}
134 Projects not to be wiped: {', '.join(sorted(other_users))}"""
135 shared_dir_messages.append(message)
136 error_messages.append(
137 "The following projects have shared object directories:\n"
138 + "\n".join(sorted(shared_dir_messages))
139 )
140
141 if block_uncommitted and block_shared:
142 error_messages.append(
143 "Use --force to wipe anyway, or --force-uncommitted and "
144 "--force-shared to specify."
145 )
146 elif block_uncommitted:
147 error_messages.append("Use --force-uncommitted to wipe anyway.")
148 else:
149 error_messages.append("Use --force-shared to wipe anyway.")
150
151 raise Error("\n\n".join(error_messages))
152
153 # If we are here, either there were no issues, or --force was used.
154 # Proceed with wiping.
155 successful_wipes = set()
156
157 for project in projects_to_wipe:
158 try:
159 # Force the delete here since we've already performed our
160 # own safety checks above.
161 project.DeleteWorktree(force=True, verbose=opt.verbose)
162 successful_wipes.add(project.relpath)
163 except DeleteWorktreeError as e:
164 print(
165 f"error: failed to wipe {project.name}: {e}",
166 file=sys.stderr,
167 )
168
169 # Clean up object directories only if all projects using them were
170 # successfully wiped.
171 for objdir in objdirs_to_delete:
172 users = objdir_map.get(objdir, set())
173 # Check if every project that uses this objdir has been
174 # successfully processed. If a project failed to be wiped, don't
175 # delete the object directory, or we'll corrupt the remaining
176 # project.
177 if users.issubset(successful_wipes):
178 if os.path.exists(objdir):
179 if opt.verbose:
180 print(
181 f"Deleting objects directory: {objdir}",
182 file=sys.stderr,
183 )
184 platform_utils.rmtree(objdir)
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 @@
1# Copyright (C) 2025 The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import os
16import shutil
17from unittest import mock
18
19import pytest
20
21import project
22from subcmds import wipe
23
24
25def _create_mock_project(tempdir, name, objdir_path=None, has_changes=False):
26 """Creates a mock project with necessary attributes and directories."""
27 worktree = os.path.join(tempdir, name)
28 gitdir = os.path.join(tempdir, ".repo/projects", f"{name}.git")
29 if objdir_path:
30 objdir = objdir_path
31 else:
32 objdir = os.path.join(tempdir, ".repo/project-objects", f"{name}.git")
33
34 os.makedirs(worktree, exist_ok=True)
35 os.makedirs(gitdir, exist_ok=True)
36 os.makedirs(objdir, exist_ok=True)
37
38 proj = project.Project(
39 manifest=mock.MagicMock(),
40 name=name,
41 remote=mock.MagicMock(),
42 gitdir=gitdir,
43 objdir=objdir,
44 worktree=worktree,
45 relpath=name,
46 revisionExpr="main",
47 revisionId="abcd",
48 )
49
50 proj.HasChanges = mock.MagicMock(return_value=has_changes)
51
52 def side_effect_delete_worktree(force=False, verbose=False):
53 if os.path.exists(proj.worktree):
54 shutil.rmtree(proj.worktree)
55 if os.path.exists(proj.gitdir):
56 shutil.rmtree(proj.gitdir)
57 return True
58
59 proj.DeleteWorktree = mock.MagicMock(
60 side_effect=side_effect_delete_worktree
61 )
62
63 return proj
64
65
66def _run_wipe(all_projects, projects_to_wipe_names, options=None):
67 """Helper to run the Wipe command with mocked projects."""
68 cmd = wipe.Wipe()
69 cmd.manifest = mock.MagicMock()
70
71 def get_projects_mock(projects, all_manifests=False, **kwargs):
72 if projects is None:
73 return all_projects
74 names_to_find = set(projects)
75 return [p for p in all_projects if p.name in names_to_find]
76
77 cmd.GetProjects = mock.MagicMock(side_effect=get_projects_mock)
78
79 if options is None:
80 options = []
81
82 opts = cmd.OptionParser.parse_args(options + projects_to_wipe_names)[0]
83 cmd.CommonValidateOptions(opts, projects_to_wipe_names)
84 cmd.ValidateOptions(opts, projects_to_wipe_names)
85 cmd.Execute(opts, projects_to_wipe_names)
86
87
88def test_wipe_single_unshared_project(tmp_path):
89 """Test wiping a single project that is not shared."""
90 p1 = _create_mock_project(str(tmp_path), "project/one")
91 _run_wipe([p1], ["project/one"])
92
93 assert not os.path.exists(p1.worktree)
94 assert not os.path.exists(p1.gitdir)
95 assert not os.path.exists(p1.objdir)
96
97
98def test_wipe_multiple_unshared_projects(tmp_path):
99 """Test wiping multiple projects that are not shared."""
100 p1 = _create_mock_project(str(tmp_path), "project/one")
101 p2 = _create_mock_project(str(tmp_path), "project/two")
102 _run_wipe([p1, p2], ["project/one", "project/two"])
103
104 assert not os.path.exists(p1.worktree)
105 assert not os.path.exists(p1.gitdir)
106 assert not os.path.exists(p1.objdir)
107 assert not os.path.exists(p2.worktree)
108 assert not os.path.exists(p2.gitdir)
109 assert not os.path.exists(p2.objdir)
110
111
112def test_wipe_shared_project_no_force_raises_error(tmp_path):
113 """Test that wiping a shared project without --force raises an error."""
114 shared_objdir = os.path.join(
115 str(tmp_path), ".repo/project-objects", "shared.git"
116 )
117 p1 = _create_mock_project(
118 str(tmp_path), "project/one", objdir_path=shared_objdir
119 )
120 p2 = _create_mock_project(
121 str(tmp_path), "project/two", objdir_path=shared_objdir
122 )
123
124 with pytest.raises(wipe.Error) as e:
125 _run_wipe([p1, p2], ["project/one"])
126
127 assert "shared object directories" in str(e.value)
128 assert "project/one" in str(e.value)
129 assert "project/two" in str(e.value)
130
131 assert os.path.exists(p1.worktree)
132 assert os.path.exists(p1.gitdir)
133 assert os.path.exists(p2.worktree)
134 assert os.path.exists(p2.gitdir)
135 assert os.path.exists(shared_objdir)
136
137
138def test_wipe_shared_project_with_force(tmp_path):
139 """Test wiping a shared project with --force."""
140 shared_objdir = os.path.join(
141 str(tmp_path), ".repo/project-objects", "shared.git"
142 )
143 p1 = _create_mock_project(
144 str(tmp_path), "project/one", objdir_path=shared_objdir
145 )
146 p2 = _create_mock_project(
147 str(tmp_path), "project/two", objdir_path=shared_objdir
148 )
149
150 _run_wipe([p1, p2], ["project/one"], options=["--force"])
151
152 assert not os.path.exists(p1.worktree)
153 assert not os.path.exists(p1.gitdir)
154 assert os.path.exists(shared_objdir)
155 assert os.path.exists(p2.worktree)
156 assert os.path.exists(p2.gitdir)
157
158
159def test_wipe_all_sharing_projects(tmp_path):
160 """Test wiping all projects that share an object directory."""
161 shared_objdir = os.path.join(
162 str(tmp_path), ".repo/project-objects", "shared.git"
163 )
164 p1 = _create_mock_project(
165 str(tmp_path), "project/one", objdir_path=shared_objdir
166 )
167 p2 = _create_mock_project(
168 str(tmp_path), "project/two", objdir_path=shared_objdir
169 )
170
171 _run_wipe([p1, p2], ["project/one", "project/two"])
172
173 assert not os.path.exists(p1.worktree)
174 assert not os.path.exists(p1.gitdir)
175 assert not os.path.exists(p2.worktree)
176 assert not os.path.exists(p2.gitdir)
177 assert not os.path.exists(shared_objdir)
178
179
180def test_wipe_with_uncommitted_changes_raises_error(tmp_path):
181 """Test wiping a project with uncommitted changes raises an error."""
182 p1 = _create_mock_project(str(tmp_path), "project/one", has_changes=True)
183
184 with pytest.raises(wipe.Error) as e:
185 _run_wipe([p1], ["project/one"])
186
187 assert "uncommitted changes" in str(e.value)
188 assert "project/one" in str(e.value)
189
190 assert os.path.exists(p1.worktree)
191 assert os.path.exists(p1.gitdir)
192 assert os.path.exists(p1.objdir)
193
194
195def test_wipe_with_uncommitted_changes_with_force(tmp_path):
196 """Test wiping a project with uncommitted changes with --force."""
197 p1 = _create_mock_project(str(tmp_path), "project/one", has_changes=True)
198 _run_wipe([p1], ["project/one"], options=["--force"])
199
200 assert not os.path.exists(p1.worktree)
201 assert not os.path.exists(p1.gitdir)
202 assert not os.path.exists(p1.objdir)
203
204
205def test_wipe_uncommitted_and_shared_raises_combined_error(tmp_path):
206 """Test that uncommitted and shared projects raise a combined error."""
207 shared_objdir = os.path.join(
208 str(tmp_path), ".repo/project-objects", "shared.git"
209 )
210 p1 = _create_mock_project(
211 str(tmp_path),
212 "project/one",
213 objdir_path=shared_objdir,
214 has_changes=True,
215 )
216 p2 = _create_mock_project(
217 str(tmp_path), "project/two", objdir_path=shared_objdir
218 )
219
220 with pytest.raises(wipe.Error) as e:
221 _run_wipe([p1, p2], ["project/one"])
222
223 assert "uncommitted changes" in str(e.value)
224 assert "shared object directories" in str(e.value)
225 assert "project/one" in str(e.value)
226 assert "project/two" in str(e.value)
227
228 assert os.path.exists(p1.worktree)
229 assert os.path.exists(p1.gitdir)
230 assert os.path.exists(p2.worktree)
231 assert os.path.exists(p2.gitdir)
232 assert os.path.exists(shared_objdir)
233
234
235def test_wipe_shared_project_with_force_shared(tmp_path):
236 """Test wiping a shared project with --force-shared."""
237 shared_objdir = os.path.join(
238 str(tmp_path), ".repo/project-objects", "shared.git"
239 )
240 p1 = _create_mock_project(
241 str(tmp_path), "project/one", objdir_path=shared_objdir
242 )
243 p2 = _create_mock_project(
244 str(tmp_path), "project/two", objdir_path=shared_objdir
245 )
246
247 _run_wipe([p1, p2], ["project/one"], options=["--force-shared"])
248
249 assert not os.path.exists(p1.worktree)
250 assert not os.path.exists(p1.gitdir)
251 assert os.path.exists(shared_objdir)
252 assert os.path.exists(p2.worktree)
253 assert os.path.exists(p2.gitdir)
254
255
256def test_wipe_with_uncommitted_changes_with_force_uncommitted(tmp_path):
257 """Test wiping uncommitted changes with --force-uncommitted."""
258 p1 = _create_mock_project(str(tmp_path), "project/one", has_changes=True)
259 _run_wipe([p1], ["project/one"], options=["--force-uncommitted"])
260
261 assert not os.path.exists(p1.worktree)
262 assert not os.path.exists(p1.gitdir)
263 assert not os.path.exists(p1.objdir)