Skip to content

Commit 3d061a1

Browse files
committed
Implemented deletion of submodules including proper tests
1 parent 78d2cd6 commit 3d061a1

File tree

2 files changed

+173
-3
lines changed

2 files changed

+173
-3
lines changed

Diff for: lib/git/objects/submodule.py

+123-3
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
import os
1111
import sys
1212
import weakref
13+
import shutil
1314

1415
__all__ = ("Submodule", "RootModule")
1516

1617
#{ Utilities
1718

18-
def sm_section(path):
19+
def sm_section(name):
1920
""":return: section title used in .gitmodules configuration file"""
20-
return 'submodule "%s"' % path
21+
return 'submodule "%s"' % name
2122

2223
def sm_name(section):
2324
""":return: name of the submodule as parsed from the section name"""
@@ -223,6 +224,7 @@ def update(self, recursive=False, init=True, to_latest_revision=False):
223224
if the remote repository had a master branch, or of the 'branch' option
224225
was specified for this submodule and the branch existed remotely
225226
:note: does nothing in bare repositories
227+
:note: method is definitely not atomic if recurisve is True
226228
:return: self"""
227229
if self.repo.bare:
228230
return self
@@ -329,6 +331,111 @@ def update(self, recursive=False, init=True, to_latest_revision=False):
329331

330332
return self
331333

334+
def remove(self, module=True, force=False, configuration=True, dry_run=False):
335+
"""Remove this submodule from the repository. This will remove our entry
336+
from the .gitmodules file and the entry in the .git/config file.
337+
:param module: If True, the module we point to will be deleted
338+
as well. If the module is currently on a commit which is not part
339+
of any branch in the remote, if the currently checked out branch
340+
is ahead of its tracking branch, if you have modifications in the
341+
working tree, or untracked files,
342+
In case the removal of the repository fails for these reasons, the
343+
submodule status will not have been altered.
344+
If this submodule has child-modules on its own, these will be deleted
345+
prior to touching the own module.
346+
:param force: Enforces the deletion of the module even though it contains
347+
modifications. This basically enforces a brute-force file system based
348+
deletion.
349+
:param configuration: if True, the submodule is deleted from the configuration,
350+
otherwise it isn't. Although this should be enabled most of the times,
351+
this flag enables you to safely delete the repository of your submodule.
352+
:param dry_run: if True, we will not actually do anything, but throw the errors
353+
we would usually throw
354+
:note: doesn't work in bare repositories
355+
:raise InvalidGitRepositoryError: thrown if the repository cannot be deleted
356+
:raise OSError: if directories or files could not be removed"""
357+
if self.repo.bare:
358+
raise InvalidGitRepositoryError("Cannot delete a submodule in bare repository")
359+
# END handle bare mode
360+
361+
if not (module + configuration):
362+
raise ValueError("Need to specify to delete at least the module, or the configuration")
363+
# END handle params
364+
365+
# DELETE MODULE REPOSITORY
366+
##########################
367+
if module and self.module_exists():
368+
if force:
369+
# take the fast lane and just delete everything in our module path
370+
# TODO: If we run into permission problems, we have a highly inconsistent
371+
# state. Delete the .git folders last, start with the submodules first
372+
mp = self.module_path()
373+
method = None
374+
if os.path.islink(mp):
375+
method = os.remove
376+
elif os.path.isdir(mp):
377+
method = shutil.rmtree
378+
elif os.path.exists(mp):
379+
raise AssertionError("Cannot forcibly delete repository as it was neither a link, nor a directory")
380+
#END handle brutal deletion
381+
if not dry_run:
382+
assert method
383+
method(mp)
384+
#END apply deletion method
385+
else:
386+
# verify we may delete our module
387+
mod = self.module()
388+
if mod.is_dirty(untracked_files=True):
389+
raise InvalidGitRepositoryError("Cannot delete module at %s with any modifications, unless force is specified" % mod.working_tree_dir)
390+
# END check for dirt
391+
392+
# figure out whether we have new commits compared to the remotes
393+
# NOTE: If the user pulled all the time, the remote heads might
394+
# not have been updated, so commits coming from the remote look
395+
# as if they come from us. But we stay strictly read-only and
396+
# don't fetch beforhand.
397+
for remote in mod.remotes:
398+
num_branches_with_new_commits = 0
399+
rrefs = remote.refs
400+
for rref in rrefs:
401+
num_branches_with_new_commits = len(mod.git.cherry(rref)) != 0
402+
# END for each remote ref
403+
# not a single remote branch contained all our commits
404+
if num_branches_with_new_commits == len(rrefs):
405+
raise InvalidGitRepositoryError("Cannot delete module at %s as there are new commits" % mod.working_tree_dir)
406+
# END handle new commits
407+
# END for each remote
408+
409+
# gently remove all submodule repositories
410+
for sm in self.children():
411+
sm.remove(module=True, force=False, configuration=False, dry_run=dry_run)
412+
# END for each child-submodule
413+
414+
# finally delete our own submodule
415+
if not dry_run:
416+
shutil.rmtree(mod.working_tree_dir)
417+
# END delete tree if possible
418+
# END handle force
419+
# END handle module deletion
420+
421+
# DELETE CONFIGURATION
422+
######################
423+
if configuration and not dry_run:
424+
# first the index-entry
425+
index = self.repo.index
426+
try:
427+
del(index.entries[index.entry_key(self.path, 0)])
428+
except KeyError:
429+
pass
430+
#END delete entry
431+
index.write()
432+
433+
# now git config - need the config intact, otherwise we can't query
434+
# inforamtion anymore
435+
self.repo.config_writer().remove_section(sm_section(self.name))
436+
self.config_writer().remove_section()
437+
# END delete configuration
438+
332439
def set_parent_commit(self, commit, check=True):
333440
"""Set this instance to use the given commit whose tree is supposed to
334441
contain the .gitmodules blob.
@@ -410,10 +517,23 @@ def module_exists(self):
410517
try:
411518
self.module()
412519
return True
413-
except InvalidGitRepositoryError:
520+
except Exception:
414521
return False
415522
# END handle exception
416523

524+
def exists(self):
525+
""":return: True if the submodule exists, False otherwise. Please note that
526+
a submodule may exist (in the .gitmodules file) even though its module
527+
doesn't exist"""
528+
self._clear_cache()
529+
try:
530+
self.path
531+
return True
532+
except Exception:
533+
# we raise if the path cannot be restored from configuration
534+
return False
535+
# END handle exceptions
536+
417537
@property
418538
def branch(self):
419539
""":return: The branch name that we are to checkout"""

Diff for: test/git/test_submodule.py

+50
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def _do_base_tests(self, rwrepo):
8787
# module retrieval is not always possible
8888
if rwrepo.bare:
8989
self.failUnlessRaises(InvalidGitRepositoryError, sm.module)
90+
self.failUnlessRaises(InvalidGitRepositoryError, sm.remove)
9091
else:
9192
# its not checked out in our case
9293
self.failUnlessRaises(InvalidGitRepositoryError, sm.module)
@@ -155,6 +156,55 @@ def _do_base_tests(self, rwrepo):
155156
# undo the changes
156157
sm.module().head.ref = smref
157158
csm.module().head.ref.set_tracking_branch(csm_tracking_branch)
159+
160+
# REMOVAL OF REPOSITOTRY
161+
########################
162+
# must delete something
163+
self.failUnlessRaises(ValueError, csm.remove, module=False, configuration=False)
164+
# We have modified the configuration, hence the index is dirty, and the
165+
# deletion will fail
166+
# NOTE: As we did a few updates in the meanwhile, the indices where reset
167+
# Hence we restore some changes
168+
sm.config_writer().set_value("somekey", "somevalue")
169+
csm.config_writer().set_value("okey", "ovalue")
170+
self.failUnlessRaises(InvalidGitRepositoryError, sm.remove)
171+
# if we remove the dirty index, it would work
172+
sm.module().index.reset()
173+
# still, we have the file modified
174+
self.failUnlessRaises(InvalidGitRepositoryError, sm.remove, dry_run=True)
175+
sm.module().index.reset(working_tree=True)
176+
177+
# this would work
178+
sm.remove(dry_run=True)
179+
assert sm.module_exists()
180+
sm.remove(force=True, dry_run=True)
181+
assert sm.module_exists()
182+
183+
# but ... we have untracked files in the child submodule
184+
fn = join_path_native(csm.module().working_tree_dir, "newfile")
185+
open(fn, 'w').write("hi")
186+
self.failUnlessRaises(InvalidGitRepositoryError, sm.remove)
187+
188+
# forcibly delete the child repository
189+
csm.remove(force=True)
190+
assert not csm.exists()
191+
assert not csm.module_exists()
192+
assert len(sm.children()) == 0
193+
# now we have a changed index, as configuration was altered.
194+
# fix this
195+
sm.module().index.reset(working_tree=True)
196+
197+
# now delete only the module of the main submodule
198+
assert sm.module_exists()
199+
sm.remove(configuration=False)
200+
assert sm.exists()
201+
assert not sm.module_exists()
202+
assert sm.config_reader().get_value('url')
203+
204+
# delete the rest
205+
sm.remove()
206+
assert not sm.exists()
207+
assert not sm.module_exists()
158208
# END handle bare mode
159209

160210

0 commit comments

Comments
 (0)