From 0b4e147009bffa7b720ee767633e3acf3bb68c3a Mon Sep 17 00:00:00 2001 From: Richard Maw Date: Mon, 11 Nov 2013 17:38:16 +0000 Subject: GitDir: Add GitIndex class This represents the state of the index of a GitDirectory. Methods that use the index are now used via the GitIndex class, rather than using the default index, as previously used when the methods were in GitDirectory. GitIndex may be constructed with an alternative path, which can be used to manipulate a git checkout without altering a developer's view of the repository i.e. The working tree and default index. This is needed for `morph build` and `morph deploy` to handle the build without commit logic. --- morphlib/__init__.py | 1 + morphlib/gitdir.py | 34 +-------- morphlib/gitdir_tests.py | 11 +-- morphlib/gitindex.py | 99 +++++++++++++++++++++++++ morphlib/gitindex_tests.py | 56 ++++++++++++++ morphlib/plugins/branch_and_merge_new_plugin.py | 2 +- 6 files changed, 165 insertions(+), 38 deletions(-) create mode 100644 morphlib/gitindex.py create mode 100644 morphlib/gitindex_tests.py diff --git a/morphlib/__init__.py b/morphlib/__init__.py index 4954f812..76b7a989 100644 --- a/morphlib/__init__.py +++ b/morphlib/__init__.py @@ -60,6 +60,7 @@ import extractedtarball import fsutils import git import gitdir +import gitindex import localartifactcache import localrepocache import mountableimage diff --git a/morphlib/gitdir.py b/morphlib/gitdir.py index 4ba4cd9c..9020506a 100644 --- a/morphlib/gitdir.py +++ b/morphlib/gitdir.py @@ -16,9 +16,7 @@ # =*= License: GPL-2 =*= -import collections import cliapp -import glob import os import morphlib @@ -226,36 +224,8 @@ class GitDirectory(object): output = self._runcmd(['git', 'rev-parse', '--abbrev-ref', 'HEAD']) return output.strip() - def _get_status(self): - '''Runs git status and formats its output into something more useful. - - This runs git status such that unusual filenames are preserved - and returns its output in a sequence of (status_code, to_path, - from_path). - - from_path is None unless the status_code says there was a rename, - in which case it is the path it was renamed from. - - Untracked and ignored changes are also included in the output, - their status codes are '??' and '!!' respectively. - - ''' - status = self._runcmd(['git', 'status', '-z', '--ignored']) - tokens = collections.deque(status.split('\0')) - while True: - tok = tokens.popleft() - # Terminates with an empty token, since status ends with a \0 - if not tok: - return - - code = tok[:2] - to_path = tok[3:] - yield code, to_path, tokens.popleft() if code[0] == 'R' else None - - def get_uncommitted_changes(self): - for code, to_path, from_path in self._get_status(): - if code not in ('??', '!!'): - yield code, to_path, from_path + def get_index(self, index_file=None): + return morphlib.gitindex.GitIndex(self, index_file) def store_blob(self, blob_contents): '''Hash `blob_contents`, store it in git and return the sha1. diff --git a/morphlib/gitdir_tests.py b/morphlib/gitdir_tests.py index 395ee2e5..a56df7c8 100644 --- a/morphlib/gitdir_tests.py +++ b/morphlib/gitdir_tests.py @@ -64,6 +64,12 @@ class GitDirectoryTests(unittest.TestCase): gitdir.set_remote_fetch_url('origin', url) self.assertEqual(gitdir.get_remote_fetch_url('origin'), url) + def test_gets_index(self): + os.mkdir(self.dirname) + gitdir = morphlib.gitdir.init(self.dirname) + self.assertIsInstance(gitdir.get_index(), morphlib.gitindex.GitIndex) + + class GitDirectoryContentsTests(unittest.TestCase): def setUp(self): @@ -172,8 +178,3 @@ class GitDirectoryContentsTests(unittest.TestCase): with open(os.path.join(self.tempdir, 'blob'), 'r') as f: sha1 = gd.store_blob(f) self.assertEqual('test string', gd.get_blob_contents(sha1)) - - def test_uncommitted_changes(self): - gd = morphlib.gitdir.GitDirectory(self.dirname) - self.assertEqual(sorted(gd.get_uncommitted_changes()), - [(' D', 'foo', None)]) diff --git a/morphlib/gitindex.py b/morphlib/gitindex.py new file mode 100644 index 00000000..d8de8824 --- /dev/null +++ b/morphlib/gitindex.py @@ -0,0 +1,99 @@ +# Copyright (C) 2013 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# =*= License: GPL-2 =*= + + +import collections + + +STATUS_UNTRACKED = '??' +STATUS_IGNORED = '!!' + + +class GitIndex(object): + '''An object that represents operations on the working tree. + + Index objects can be constructed with a different path to the + index file, which can be used to construct commits without + altering the working tree, index or HEAD. + + The file must either be a previously initialised index, or a + non-existant file. + + Git creates a lock file and atomically alters the index by + renaming a temporary file into place, so `index_file` must be + in a writable directory. + + ''' + + def __init__(self, gd, index_file): + self._gd = gd + self._index_file = index_file + + def _run_git(self, *args, **kwargs): + if self._index_file is not None: + kwargs['env'] = kwargs.get('env', {}) + kwargs['env']['GIT_INDEX_FILE'] = self._index_file + return self._gd._runcmd(['git'] + list(args), **kwargs) + + def _get_status(self): + '''Return git status output in a Python useful format + + This runs git status such that unusual filenames are preserved + and returns its output in a sequence of (status_code, to_path, + from_path). + + from_path is None unless the status_code says there was a + rename, in which case it is the path it was renamed from. + + Untracked and ignored changes are also included in the output, + their status codes are '??' and '!!' respectively. + + ''' + + # git status -z will NUL terminate paths, so we don't have to + # unescape the paths it outputs. Unfortunately each status entry + # can have 1 or 2 paths, so extra parsing is required. + # To handle this, we split it into NUL delimited tokens. + # The first token of an entry is the 2 character status code, + # a space, then the path. + # If our status code starts with R then it's a rename, hence + # has a second path, requiring us to pop an extra token. + status = self._run_git('status', '-z', '--ignored') + tokens = collections.deque(status.split('\0')) + while True: + tok = tokens.popleft() + # Status output is NUL terminated rather than delimited, + # and split is for delimited output. A side effect of this is + # that we get an empty token as the last output. This suits + # us fine, as it gives us a sentinel value to terminate with. + if not tok: + return + + # The first token of an entry is 2 character status, a space, + # then the path + code = tok[:2] + to_path = tok[3:] + + # If the code starts with R then it's a rename, and + # the next token says where the file was renamed from + from_path = tokens.popleft() if code[0] == 'R' else None + yield code, to_path, from_path + + def get_uncommitted_changes(self): + for code, to_path, from_path in self._get_status(): + if code not in (STATUS_UNTRACKED, STATUS_IGNORED): + yield code, to_path, from_path diff --git a/morphlib/gitindex_tests.py b/morphlib/gitindex_tests.py new file mode 100644 index 00000000..db1e0e9b --- /dev/null +++ b/morphlib/gitindex_tests.py @@ -0,0 +1,56 @@ +# Copyright (C) 2013 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# =*= License: GPL-2 =*= + + +import os +import shutil +import tempfile +import unittest + +import morphlib + + +class GitIndexTests(unittest.TestCase): + + def setUp(self): + self.tempdir = tempfile.mkdtemp() + self.dirname = os.path.join(self.tempdir, 'foo') + os.mkdir(self.dirname) + gd = morphlib.gitdir.init(self.dirname) + with open(os.path.join(self.dirname, 'foo'), 'w') as f: + f.write('dummy text\n') + gd._runcmd(['git', 'add', '.']) + gd._runcmd(['git', 'commit', '-m', 'Initial commit']) + self.mirror = os.path.join(self.tempdir, 'mirror') + gd._runcmd(['git', 'clone', '--mirror', self.dirname, self.mirror]) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_uncommitted_changes(self): + idx = morphlib.gitdir.GitDirectory(self.dirname).get_index() + self.assertEqual(list(idx.get_uncommitted_changes()), []) + os.unlink(os.path.join(self.dirname, 'foo')) + self.assertEqual(sorted(idx.get_uncommitted_changes()), + [(' D', 'foo', None)]) + + def test_uncommitted_alt_index(self): + gd = morphlib.gitdir.GitDirectory(self.dirname) + idx = gd.get_index(os.path.join(self.tempdir, 'index')) + self.assertEqual(sorted(idx.get_uncommitted_changes()), + [('D ', 'foo', None)]) + # 'D ' means not in the index, but in the working tree diff --git a/morphlib/plugins/branch_and_merge_new_plugin.py b/morphlib/plugins/branch_and_merge_new_plugin.py index e7b5abc1..8ad9effd 100644 --- a/morphlib/plugins/branch_and_merge_new_plugin.py +++ b/morphlib/plugins/branch_and_merge_new_plugin.py @@ -781,7 +781,7 @@ class SimpleBranchAndMergePlugin(cliapp.Plugin): if head != branch: self.app.output.write( ' %s: unexpected ref checked out %r\n' % (repo, head)) - if any(gd.get_uncommitted_changes()): + if any(gd.get_index().get_uncommitted_changes()): has_uncommitted_changes = True self.app.output.write(' %s: uncommitted changes\n' % repo) -- cgit v1.2.1