Skip to content

Commit 9fbae71

Browse files
committed
Greatly improved possible safety of Submodule.update(), which is used by default.
Previously, the implementation would gladly reset new commits in submodules, and/or reset a dirty working tree. Now the new force_reset/force flag has to be specified explicitly to get back to the old behaviour. All submodule tests except for one are working.
1 parent ea29541 commit 9fbae71

File tree

7 files changed

+170
-58
lines changed

7 files changed

+170
-58
lines changed

‎git/config.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -546,10 +546,12 @@ def set_value(self, section, option, value):
546546
:param option: Name of the options whose value to set
547547
548548
:param value: Value to set the option to. It must be a string or convertible
549-
to a string"""
549+
to a string
550+
:return: this instance"""
550551
if not self.has_section(section):
551552
self.add_section(section)
552553
self.set(section, option, self._value_to_string(value))
554+
return self
553555

554556
def rename_section(self, section, new_name):
555557
"""rename the given section to new_name

‎git/exc.py

+11
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,14 @@ def __init__(self, command, status, stdout, stderr):
8484
def __str__(self):
8585
return ("'%s' hook returned with exit code %i\nstdout: '%s'\nstderr: '%s'"
8686
% (self.command, self.status, self.stdout, self.stderr))
87+
88+
89+
class RepositoryDirtyError(Exception):
90+
"""Thrown whenever an operation on a repository fails as it has uncommited changes that would be overwritten"""
91+
92+
def __init__(self, repo, message):
93+
self.repo = repo
94+
self.message = message
95+
96+
def __str__(self):
97+
return "Operation cannot be performed on %r: %s" % (self.repo, self.message)

‎git/objects/submodule/base.py

+54-17
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
)
2525
from git.exc import (
2626
InvalidGitRepositoryError,
27-
NoSuchPathError
27+
NoSuchPathError,
28+
RepositoryDirtyError
2829
)
2930
from git.compat import (
3031
string_types,
@@ -36,6 +37,7 @@
3637

3738
import os
3839
import logging
40+
import tempfile
3941

4042
__all__ = ["Submodule", "UpdateProgress"]
4143

@@ -424,8 +426,8 @@ def add(cls, repo, name, path, url=None, branch=None, no_checkout=False):
424426

425427
return sm
426428

427-
def update(self, recursive=False, init=True, to_latest_revision=False, progress=None,
428-
dry_run=False):
429+
def update(self, recursive=False, init=True, to_latest_revision=False, progress=None, dry_run=False,
430+
force=False):
429431
"""Update the repository of this submodule to point to the checkout
430432
we point at with the binsha of this instance.
431433
@@ -440,6 +442,12 @@ def update(self, recursive=False, init=True, to_latest_revision=False, progress=
440442
:param progress: UpdateProgress instance or None of no progress should be shown
441443
:param dry_run: if True, the operation will only be simulated, but not performed.
442444
All performed operations are read-only
445+
:param force:
446+
If True, we may reset heads even if the repository in question is dirty. Additinoally we will be allowed
447+
to set a tracking branch which is ahead of its remote branch back into the past or the location of the
448+
remote branch. This will essentially 'forget' commits.
449+
If False, local tracking branches that are in the future of their respective remote branches will simply
450+
not be moved.
443451
:note: does nothing in bare repositories
444452
:note: method is definitely not atomic if recurisve is True
445453
:return: self"""
@@ -565,23 +573,45 @@ def update(self, recursive=False, init=True, to_latest_revision=False, progress=
565573
# update the working tree
566574
# handles dry_run
567575
if mrepo is not None and mrepo.head.commit.binsha != binsha:
576+
# We must assure that our destination sha (the one to point to) is in the future of our current head.
577+
# Otherwise, we will reset changes that might have been done on the submodule, but were not yet pushed
578+
# We also handle the case that history has been rewritten, leaving no merge-base. In that case
579+
# we behave conservatively, protecting possible changes the user had done
580+
may_reset = True
581+
if mrepo.head.commit.binsha != self.NULL_BIN_SHA:
582+
base_commit = mrepo.merge_base(mrepo.head.commit, hexsha)
583+
if len(base_commit) == 0 or base_commit[0].hexsha == hexsha:
584+
if force:
585+
log.debug("Will force checkout or reset on local branch that is possibly in the future of" +
586+
"the commit it will be checked out to, effectively 'forgetting' new commits")
587+
else:
588+
log.info("Skipping %s on branch '%s' of submodule repo '%s' as it contains un-pushed commits",
589+
is_detached and "checkout" or "reset", mrepo.head, mrepo)
590+
may_reset = False
591+
# end handle force
592+
# end handle if we are in the future
593+
594+
if may_reset and not force and mrepo.is_dirty(index=True, working_tree=True, untracked_files=True):
595+
raise RepositoryDirtyError(mrepo, "Cannot reset a dirty repository")
596+
# end handle force and dirty state
597+
# end handle empty repo
598+
599+
# end verify future/past
568600
progress.update(BEGIN | UPDWKTREE, 0, 1, prefix +
569601
"Updating working tree at %s for submodule %r to revision %s"
570602
% (self.path, self.name, hexsha))
571-
if not dry_run:
603+
604+
if not dry_run and may_reset:
572605
if is_detached:
573606
# NOTE: for now we force, the user is no supposed to change detached
574607
# submodules anyway. Maybe at some point this becomes an option, to
575608
# properly handle user modifications - see below for future options
576609
# regarding rebase and merge.
577-
mrepo.git.checkout(hexsha, force=True)
610+
mrepo.git.checkout(hexsha, force=force)
578611
else:
579-
# TODO: allow to specify a rebase, merge, or reset
580-
# TODO: Warn if the hexsha forces the tracking branch off the remote
581-
# branch - this should be prevented when setting the branch option
582612
mrepo.head.reset(hexsha, index=True, working_tree=True)
583613
# END handle checkout
584-
# END handle dry_run
614+
# if we may reset/checkout
585615
progress.update(END | UPDWKTREE, 0, 1, prefix + "Done updating working tree for submodule %r" % self.name)
586616
# END update to new commit only if needed
587617

@@ -591,7 +621,8 @@ def update(self, recursive=False, init=True, to_latest_revision=False, progress=
591621
# in dry_run mode, the module might not exist
592622
if mrepo is not None:
593623
for submodule in self.iter_items(self.module()):
594-
submodule.update(recursive, init, to_latest_revision, progress=progress, dry_run=dry_run)
624+
submodule.update(recursive, init, to_latest_revision, progress=progress, dry_run=dry_run,
625+
force=force)
595626
# END handle recursive update
596627
# END handle dry run
597628
# END for each submodule
@@ -748,7 +779,7 @@ def remove(self, module=True, force=False, configuration=True, dry_run=False):
748779
csm.remove(module, force, configuration, dry_run)
749780
del(csm)
750781
# end
751-
if nc > 0:
782+
if not dry_run and nc > 0:
752783
# Assure we don't leave the parent repository in a dirty state, and commit our changes
753784
# It's important for recursive, unforced, deletions to work as expected
754785
self.module().index.commit("Removed submodule '%s'" % self.name)
@@ -854,8 +885,7 @@ def remove(self, module=True, force=False, configuration=True, dry_run=False):
854885
def set_parent_commit(self, commit, check=True):
855886
"""Set this instance to use the given commit whose tree is supposed to
856887
contain the .gitmodules blob.
857-
858-
:param commit: Commit'ish reference pointing at the root_tree
888+
:param commit: Commit'ish reference pointing at the root_tree, or None always point to the most recent commit
859889
:param check: if True, relatively expensive checks will be performed to verify
860890
validity of the submodule.
861891
:raise ValueError: if the commit's tree didn't contain the .gitmodules blob.
@@ -939,7 +969,7 @@ def rename(self, new_name):
939969
pw.release()
940970

941971
# .gitmodules
942-
cw = self.config_writer().config
972+
cw = self.config_writer(write=False).config
943973
cw.rename_section(sm_section(self.name), sm_section(new_name))
944974
cw.release()
945975

@@ -948,9 +978,16 @@ def rename(self, new_name):
948978
# .git/modules
949979
mod = self.module()
950980
if mod.has_separate_working_tree():
951-
module_abspath = self._module_abspath(self.repo, self.path, new_name)
952-
os.renames(mod.git_dir, module_abspath)
953-
self._write_git_file_and_module_config(mod.working_tree_dir, module_abspath)
981+
destination_module_abspath = self._module_abspath(self.repo, self.path, new_name)
982+
source_dir = mod.git_dir
983+
# Let's be sure the submodule name is not so obviously tied to a directory
984+
if destination_module_abspath.startswith(mod.git_dir):
985+
tmp_dir = self._module_abspath(self.repo, self.path, os.path.basename(tempfile.mkdtemp()))
986+
os.renames(source_dir, tmp_dir)
987+
source_dir = tmp_dir
988+
# end handle self-containment
989+
os.renames(source_dir, destination_module_abspath)
990+
self._write_git_file_and_module_config(mod.working_tree_dir, destination_module_abspath)
954991
# end move separate git repository
955992

956993
return self

‎git/objects/submodule/root.py

+14-8
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def _clear_cache(self):
6161
#{ Interface
6262

6363
def update(self, previous_commit=None, recursive=True, force_remove=False, init=True,
64-
to_latest_revision=False, progress=None, dry_run=False):
64+
to_latest_revision=False, progress=None, dry_run=False, force_reset=False):
6565
"""Update the submodules of this repository to the current HEAD commit.
6666
This method behaves smartly by determining changes of the path of a submodules
6767
repository, next to changes to the to-be-checked-out commit or the branch to be
@@ -80,10 +80,16 @@ def update(self, previous_commit=None, recursive=True, force_remove=False, init=
8080
:param init: If we encounter a new module which would need to be initialized, then do it.
8181
:param to_latest_revision: If True, instead of checking out the revision pointed to
8282
by this submodule's sha, the checked out tracking branch will be merged with the
83-
newest remote branch fetched from the repository's origin
83+
latest remote branch fetched from the repository's origin.
84+
Unless force_reset is specified, a local tracking branch will never be reset into its past, therefore
85+
the remote branch must be in the future for this to have an effect.
86+
:param force_reset: if True, submodules may checkout or reset their branch even if the repository has
87+
pending changes that would be overwritten, or if the local tracking branch is in the future of the
88+
remote tracking branch and would be reset into its past.
8489
:param progress: RootUpdateProgress instance or None if no progress should be sent
8590
:param dry_run: if True, operations will not actually be performed. Progress messages
86-
will change accordingly to indicate the WOULD DO state of the operation."""
91+
will change accordingly to indicate the WOULD DO state of the operation.
92+
:return: self"""
8793
if self.repo.bare:
8894
raise InvalidGitRepositoryError("Cannot update submodules in bare repositories")
8995
# END handle bare
@@ -134,9 +140,7 @@ def update(self, previous_commit=None, recursive=True, force_remove=False, init=
134140
# of previous module. Trigger the cache to be updated before that
135141
progress.update(op, i, len_rrsm, prefix + "Removing submodule %r at %s" % (rsm.name, rsm.abspath))
136142
rsm._parent_commit = repo.head.commit
137-
if not dry_run:
138-
rsm.remove(configuration=False, module=True, force=force_remove)
139-
# END handle dry-run
143+
rsm.remove(configuration=False, module=True, force=force_remove, dry_run=dry_run)
140144

141145
if i == len_rrsm - 1:
142146
op |= END
@@ -311,7 +315,7 @@ def update(self, previous_commit=None, recursive=True, force_remove=False, init=
311315
for sm in sms:
312316
# update the submodule using the default method
313317
sm.update(recursive=False, init=init, to_latest_revision=to_latest_revision,
314-
progress=progress, dry_run=dry_run)
318+
progress=progress, dry_run=dry_run, force=force_reset)
315319

316320
# update recursively depth first - question is which inconsitent
317321
# state will be better in case it fails somewhere. Defective branch
@@ -322,11 +326,13 @@ def update(self, previous_commit=None, recursive=True, force_remove=False, init=
322326
if sm.module_exists():
323327
type(self)(sm.module()).update(recursive=True, force_remove=force_remove,
324328
init=init, to_latest_revision=to_latest_revision,
325-
progress=progress, dry_run=dry_run)
329+
progress=progress, dry_run=dry_run, force_reset=force_reset)
326330
# END handle dry_run
327331
# END handle recursive
328332
# END for each submodule to update
329333

334+
return self
335+
330336
def module(self):
331337
""":return: the actual repository containing the submodules"""
332338
return self.repo

‎git/repo/fun.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ def rev_parse(repo, rev):
296296
raise ValueError("Invalid token: %r" % token)
297297
# END end handle tag
298298
except (IndexError, AttributeError):
299-
raise BadObject("Invalid Revision in %s" % rev)
299+
raise BadName("Invalid revision spec '%s' - not enough parent commits to reach '%s%i'" % (rev, token, num))
300300
# END exception handling
301301
# END parse loop
302302

0 commit comments

Comments
 (0)