Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Git rebase support #558

Merged
merged 3 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/manual/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,7 @@ git | ``url``: URL of remote repository
| ``branch`` (\*): Branch to check out (optional, default: master)
| ``tag``: Checkout this tag (optional, overrides branch attribute)
| ``commit``: SHA1 commit Id to check out (optional, overrides branch or tag attribute)
| ``rebase`` (\*): Rebase local branch instead of fast-forward merge update (optional, defaults to false)
| ``rev``: Canonical git-rev-parse revision specification (optional, see below)
| ``remote-*``: additional remote repositories (optional, see below)
| ``sslVerify`` (\*): Whether to verify the SSL certificate when fetching (optional)
Expand Down Expand Up @@ -978,6 +979,15 @@ git
.. note:: The default branch of the remote repository is not used. Bob will
always checkout "master" unless ``branch``, ``tag`` or ``commit`` is given.

If neiter a commit, nor a tag is specified, Bob will try to track the
upstream branch with fast forward merges. This implies that updates will
fail if the upstream repository has been rebased or there are local
conflicting changes or commits. Set the ``rebase`` property to ``True`` to
handle upstream rebases or local commits.

.. attention:: Rebasing is a potentially dangerous operation. Make sure you
read and understood the git rebase manpage before using this option.

The ``rev`` property of the ``git`` SCM unifies the specification of the
desired branch/tag/commit into one single property. If present it will be
evaluated first. Any other ``branch``, ``tag`` or ``commit`` property is
Expand Down
39 changes: 38 additions & 1 deletion pym/bob/scm/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class GitScm(Scm):
})]),
schema.Optional('dissociate') : bool,
schema.Optional('retries') : schema.And(int, lambda n: n >= 0, error="Invalid retries attribute"),
schema.Optional('rebase') : bool,
}

__SCHEMA = {
Expand Down Expand Up @@ -114,6 +115,7 @@ def __init__(self, spec, overrides=[], stripUser=None, useBranchAndCommit=False)
self.__resolvedReferences = []
self.__useBranchAndCommit = spec.get('useBranchAndCommit', useBranchAndCommit)
self.__retries = spec.get('retries', 0)
self.__rebase = spec.get('rebase', False)

def __resolveReferences(self, alt):
# if the reference is a string it's used as optional reference
Expand Down Expand Up @@ -155,6 +157,7 @@ def getProperties(self, isJenkins, pretty=False):
'references' : self.__references,
'dissociate' : self.__dissociate,
'useBranchAndCommit' : self.__useBranchAndCommit,
'rebase' : self.__rebase,
})
for key, val in self.__remotes.items():
properties.update({GitScm.REMOTE_PREFIX+key : val})
Expand Down Expand Up @@ -348,6 +351,16 @@ async def __checkoutTag(self, invoker, fetchCmd, switch):
await self.__checkoutSubmodules(invoker)

async def __checkoutBranch(self, invoker, fetchCmd, switch):
oldUpstreamCommit = None
if self.__rebase:
# In case of rebasing, we have to remember the original state of
# the remote tracking branch. This is the rebase base. Otherwise
# non-local commits might be accidentally rebased or resurrected.
remote = await invoker.runCommand(
["git", "rev-parse", "--verify", "-q", "refs/remotes/origin/"+self.__branch],
stdout=True, cwd=self.__dir)
if remote.returncode == 0:
oldUpstreamCommit = remote.stdout.rstrip()
await invoker.checkCommand(fetchCmd, retries=self.__retries, cwd=self.__dir)
if await invoker.callCommand(["git", "rev-parse", "--verify", "-q", "HEAD"],
stdout=False, cwd=self.__dir):
Expand Down Expand Up @@ -375,11 +388,35 @@ async def __checkoutBranch(self, invoker, fetchCmd, switch):
elif (await invoker.checkOutputCommand(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=self.__dir)) == self.__branch:
# pull only if on original branch
preUpdate = await self.__updateSubmodulesPre(invoker)
await invoker.checkCommand(["git", "-c", "submodule.recurse=0", "merge", "--ff-only", "refs/remotes/origin/"+self.__branch], cwd=self.__dir)
await self.__forwardBranch(invoker, oldUpstreamCommit)
await self.__updateSubmodulesPost(invoker, preUpdate)
else:
invoker.warn("Not updating", self.__dir, "because branch was changed manually...")

async def __forwardBranch(self, invoker, oldUpstreamCommit):
if self.__rebase:
# Ok, the upstream branch may be rebased. Try to rebase local
# commits on the newly fetched upstream.
if oldUpstreamCommit is not None:
await invoker.checkCommand(
["git", "-c", "submodule.recurse=0", "rebase", "--onto",
"refs/remotes/origin/"+self.__branch, oldUpstreamCommit],
cwd=self.__dir)
else:
# That's bad. We don't know how upstream moved. Try to rebase
# anyway.
invoker.warn("Rebasing", self.__dir, "but old upstream commit not known! Please check result.")
await invoker.checkCommand(
["git", "-c", "submodule.recurse=0", "rebase",
"refs/remotes/origin/"+self.__branch],
cwd=self.__dir)
else:
# Just do a fast-forward only merge.
await invoker.checkCommand(
["git", "-c", "submodule.recurse=0", "merge", "--ff-only",
"refs/remotes/origin/"+self.__branch],
cwd=self.__dir)

async def __checkoutSubmodules(self, invoker):
if not self.__submodules: return

Expand Down
137 changes: 137 additions & 0 deletions test/unit/test_input_gitscm.py
Original file line number Diff line number Diff line change
Expand Up @@ -872,3 +872,140 @@ def testSubmoduleCloneSpecificMissing(self):
with tempfile.TemporaryDirectory() as workspace:
with self.assertRaises(CmdFailedError):
self.invokeGit(workspace, scm)


class TestRebase(TestCase):

def setUp(self):
self.__repodir = tempfile.TemporaryDirectory()
self.repodir = self.__repodir.name

cmds = """\
git init .
git config user.email "[email protected]"
git config user.name test
echo -n "hello world" > test.txt
git add test.txt
git commit -m "first commit"
"""
subprocess.check_call([getBashPath(), "-c", cmds], cwd=self.repodir)

def tearDown(self):
self.__repodir.cleanup()

def createGitScm(self, spec = {}):
s = {
'scm' : "git",
'url' : "file://" + os.path.abspath(self.repodir),
'recipe' : "foo.yaml#0",
'__source' : "Recipe foo",
"rebase" : True,
}
s.update(spec)
return GitScm(s)

def invokeGit(self, workspace, scm):
spec = MagicMock(workspaceWorkspacePath=workspace, envWhiteList=set())
invoker = Invoker(spec, True, True, True, True, True, False)
runInEventLoop(scm.invoke(invoker))

def verify(self, workspace, content, file="test.txt"):
with open(os.path.join(workspace, file)) as f:
self.assertEqual(f.read(), content)

def testNoChange(self):
"""Test rebase without upstream changes"""
scm = self.createGitScm()
with tempfile.TemporaryDirectory() as workspace:
self.invokeGit(workspace, scm)
self.verify(workspace, "hello world")
self.invokeGit(workspace, scm)
self.verify(workspace, "hello world")

def testFastForwardRebase(self):
"""Test fast forward upstream movement"""
scm = self.createGitScm()
with tempfile.TemporaryDirectory() as workspace:
self.invokeGit(workspace, scm)
self.verify(workspace, "hello world")

# update upstream repository
cmds = """\
echo -n changed > test.txt
git commit -a -m "commit 2"
"""
subprocess.check_call([getBashPath(), "-c", cmds], cwd=self.repodir)

self.invokeGit(workspace, scm)
self.verify(workspace, "changed")

def testRebaseNoLocalChange(self):
"""Test update if upstream rebased without local commits"""

scm = self.createGitScm()
with tempfile.TemporaryDirectory() as workspace:
self.invokeGit(workspace, scm)
self.verify(workspace, "hello world")

# update upstream repository
cmds = """\
echo -n changed > test.txt
git commit -a --amend --no-edit
"""
subprocess.check_call([getBashPath(), "-c", cmds], cwd=self.repodir)

self.invokeGit(workspace, scm)
self.verify(workspace, "changed")

def testRebaseWithLocalChange(self):
"""Test update if upstream rebased with additional local commits"""

scm = self.createGitScm()
with tempfile.TemporaryDirectory() as workspace:
self.invokeGit(workspace, scm)
self.verify(workspace, "hello world")

# make some local commit
cmds = """\
git config user.email "[email protected]"
git config user.name test
echo -n foo > additional.txt
git add additional.txt
git commit -m 'local commit'
"""
subprocess.check_call([getBashPath(), "-c", cmds], cwd=workspace)

# update upstream repository
cmds = """\
echo -n changed > test.txt
git commit -a --amend --no-edit
"""
subprocess.check_call([getBashPath(), "-c", cmds], cwd=self.repodir)

self.invokeGit(workspace, scm)
self.verify(workspace, "changed")
self.verify(workspace, "foo", "additional.txt")

def testFastForwardUnknownTrackingOldState(self):
"""Test update if upstream ff'ed *and* old upstream commit is unknown"""

scm = self.createGitScm()
with tempfile.TemporaryDirectory() as workspace:
self.invokeGit(workspace, scm)
self.verify(workspace, "hello world")

# delete local remote tracking branch
cmds = """\
git branch -d -r origin/master
"""
subprocess.check_call([getBashPath(), "-c", cmds], cwd=workspace)

# update upstream repository
cmds = """\
echo -n changed > test.txt
git commit -a -m 'new commit'
"""
subprocess.check_call([getBashPath(), "-c", cmds], cwd=self.repodir)

self.invokeGit(workspace, scm)
self.verify(workspace, "changed")
Loading