From 24df2f38af6e8a0d6f1e43788f318b02f51c9225 Mon Sep 17 00:00:00 2001 From: Davide Guerri Date: Thu, 6 Feb 2014 22:26:45 +0000 Subject: [PATCH] Add drop and supersede commands * supersede command allows the marking of commits as superceded by a set of upstream changes * drop command allows the marking of commits that should be dropped Both commands add git notes to a given sha1 in the upstream-merge namespace (refs/notes/upstream-merge). The notes are read by the import-upstream command during import and the appropriate actions are then taken. Add functional and unit tests for the two newly created commands. JIRA: CICD-248 Change-Id: I6f69dd890af18e77a9affdb958afde1ec8b1cab8 --- functional-tests/020-test_help.sh | 2 +- functional-tests/050-test_supersede.sh | 169 ++++++++++++++++++++++ functional-tests/060-test_drop.sh | 104 +++++++++++++ ghp/commands/drop.py | 134 +++++++++++++++++ ghp/commands/import_upstream.py | 7 +- ghp/commands/supersede.py | 193 +++++++++++++++++++++++++ ghp/lib/__init__.py | 2 + ghp/lib/note.py | 52 +++++++ ghp/lib/searchers.py | 148 ++++++++++++++++++- tests/test_commands.py | 2 +- tests/test_drop.py | 67 +++++++++ tests/test_supersede.py | 112 ++++++++++++++ 12 files changed, 987 insertions(+), 5 deletions(-) create mode 100755 functional-tests/050-test_supersede.sh create mode 100755 functional-tests/060-test_drop.sh create mode 100644 ghp/commands/drop.py create mode 100644 ghp/commands/supersede.py create mode 100644 ghp/lib/note.py create mode 100644 tests/test_drop.py create mode 100644 tests/test_supersede.py diff --git a/functional-tests/020-test_help.sh b/functional-tests/020-test_help.sh index dfa95cc..f243287 100755 --- a/functional-tests/020-test_help.sh +++ b/functional-tests/020-test_help.sh @@ -19,7 +19,7 @@ function test_help_output() { "$help1" != "$help2" -o "$help2" != "$help3" ] && return 1 || return 0 } -for com in "" "import-upstream" ; do +for com in "" "import-upstream" "drop" "supersede" ; do test_help_output $com && log INFO "test_help_output::${com:-null} passed." || \ log ERROR "test_help_output::${com:-null} failed!" done diff --git a/functional-tests/050-test_supersede.sh b/functional-tests/050-test_supersede.sh new file mode 100755 index 0000000..701e611 --- /dev/null +++ b/functional-tests/050-test_supersede.sh @@ -0,0 +1,169 @@ +#!/bin/bash + +BASE_DIR=$(cd $(dirname $0); pwd -P) + +# Include and run common test functions and initializations +source $BASE_DIR/libs/logging.lib +source $BASE_DIR/libs/utils.lib + +REPO_NAME="empty-repo" +UPSTREAM_REPO=$(git rev-parse --show-toplevel) +TEST_BASE_REF="2c4bf67b5c416adfb162d9ca1fb4b0bf353fbb2a" +TEST_REBASE_REF="fd3524e1b7353cda228b6fb73c3a2d34a4fee4de" +VALID_CHID="I82ef79c3621dacf619a02404f16464877a06f158" +VALID_CHID2="I2492200f8e6fb0cc470cc376eb17a39b4b3033ff" +INVALID_CHID="I0123456789abcdef0123456789abcdef01234567" + +SUCCESS_SHA1="b8c16b3dd8883d02b4b65882ad5467c9f5e7beb9 ?-" + +function _common() { + prepare_for_hpgit $TEST_DIR $REPO_NAME $UPSTREAM_REPO $TEST_BASE_REF \ + $TEST_NAME + + pushd $TEST_DIR/$REPO_NAME >/dev/null + + log DEBUG "Creating a local patches" + cat </dev/null || return 1 + + git checkout master --quiet || return 1 + + log DEBUG "Rebasing local patches onto upstream version $TEST_REBASE_REF" + git branch import/$TEST_NAME-new $TEST_REBASE_REF --quiet || return 1 +} + +function test_existing_changeid() { + log DEBUG "Starting $TEST_NAME::$FUNCNAME" + + _common || return 1 + + local commit_sha1=$(git log -1 --format='%H') + + git-hp supersede $commit_sha1 $VALID_CHID -u import/$TEST_NAME-new \ + >/dev/null || return 1 + + git-hp import-upstream import/$TEST_NAME-new >/dev/null || return 1 + + git show --numstat | grep '0\s\s*1\s\s*nothing' >/dev/null + if [ "$?" -ne 0 ]; then + popd >/dev/null + return 1 + fi + + popd >/dev/null +} + +function test_non_existing_changeid() { + log DEBUG "Starting $TEST_NAME::$FUNCNAME" + + _common || return 1 + + local commit_sha1=$(git log -1 --format='%H') + + git-hp supersede $commit_sha1 $INVALID_CHID -u import/$TEST_NAME-new 2>&1 | \ + grep "CRITICAL: Change-Id '$INVALID_CHID' not found in branch \ +'import/$TEST_NAME-new'" >/dev/null + if [ "$?" -ne 0 ]; then + popd >/dev/null + return 1 + fi + + popd >/dev/null +} + +function test_non_existing_changeid_force() { + log DEBUG "Starting $TEST_NAME::$FUNCNAME" + + _common || return 1 + + local commit_sha1=$(git log -1 --format='%H') + + git-hp supersede $commit_sha1 $INVALID_CHID -u import/$TEST_NAME-new -f \ + >/dev/null || return 1 + + git-hp -vv import-upstream import/$TEST_NAME-new | \ + grep -e "Including commit '[0-9a-f][0-9a-f]* Add nothing'" \ + >/dev/null + if [ "$?" -ne 0 ]; then + popd >/dev/null + return 1 + fi + + popd >/dev/null +} + +function test_multiple_changeids() { + log DEBUG "Starting $TEST_NAME::$FUNCNAME" + + _common || return 1 + + local commit_sha1=$(git log -1 --format='%H') + + git-hp supersede $commit_sha1 $VALID_CHID $VALID_CHID2 \ + -u import/$TEST_NAME-new >/dev/null || return 1 + + git-hp -vv import-upstream import/$TEST_NAME-new >/dev/null || return 1 + + git show --numstat | grep '0\s\s*1\s\s*nothing' >/dev/null + if [ "$?" -ne 0 ]; then + popd >/dev/null + return 1 + fi + + popd >/dev/null +} + +function test_one_non_exsisting_changeid() { + log DEBUG "Starting $TEST_NAME::$FUNCNAME" + + _common || return 1 + + local commit_sha1=$(git log -1 --format='%H') + + git-hp supersede $commit_sha1 $VALID_CHID $INVALID_CHID \ + -u import/$TEST_NAME-new 2>&1 | \ + grep "CRITICAL: Change-Id '$INVALID_CHID' not found in branch \ +'import/$TEST_NAME-new'" >/dev/null + if [ "$?" -ne 0 ]; then + popd >/dev/null + return 1 + fi + + popd >/dev/null +} + +TESTS="test_existing_changeid test_non_existing_changeid \ + test_non_existing_changeid_force test_multiple_changeids \ + test_one_non_exsisting_changeid" + +for test in $TESTS; do + $test && log INFO "$TEST_NAME::$test() passed." || \ + log ERROR "$TEST_NAME::$test() failed!" +done diff --git a/functional-tests/060-test_drop.sh b/functional-tests/060-test_drop.sh new file mode 100755 index 0000000..437d99f --- /dev/null +++ b/functional-tests/060-test_drop.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +BASE_DIR=$(cd $(dirname $0); pwd -P) + +# Include and run common test functions and initializations +source $BASE_DIR/libs/logging.lib +source $BASE_DIR/libs/utils.lib + +REPO_NAME="empty-repo" +UPSTREAM_REPO=$(git rev-parse --show-toplevel) +TEST_BASE_REF="2c4bf67b5c416adfb162d9ca1fb4b0bf353fbb2a" +TEST_REBASE_REF="fd3524e1b7353cda228b6fb73c3a2d34a4fee4de" + +SUCCESS_SHA1="b8c16b3dd8883d02b4b65882ad5467c9f5e7beb9 ?-" + +function _common() { + prepare_for_hpgit $TEST_DIR $REPO_NAME $UPSTREAM_REPO $TEST_BASE_REF \ + $TEST_NAME + + pushd $TEST_DIR/$REPO_NAME >/dev/null + + log DEBUG "Creating a local patches" + cat </dev/null || return 1 + + git checkout master --quiet || return 1 + + log DEBUG "Rebasing local patches onto upstream version $TEST_REBASE_REF" + git branch import/$TEST_NAME-new $TEST_REBASE_REF --quiet || return 1 +} + +function test_new() { + log DEBUG "Starting $TEST_NAME::$FUNCNAME" + + _common || return 1 + + local commit_sha1=$(git log -1 --format='%H') + + git-hp drop $commit_sha1 + + git-hp import-upstream import/$TEST_NAME-new >/dev/null || return 1 + + git show --numstat | grep '0\s\s*1\s\s*nothing' >/dev/null + if [ "$?" -ne 0 ]; then + popd >/dev/null + return 1 + fi + + popd >/dev/null +} + +function test_already_present() { + log DEBUG "Starting $TEST_NAME::$FUNCNAME" + + _common || return 1 + + local commit_sha1=$(git log -1 --format='%H') + + git-hp drop $commit_sha1 + + git-hp drop $commit_sha1 2>&1 | \ + grep "Drop note has not been added as '$commit_sha1' already has one" \ + >/dev/null + if [ "$?" -ne 0 ]; then + popd >/dev/null + return 1 + fi + + popd >/dev/null +} + +TESTS="test_new test_already_present" + +for test in $TESTS; do + $test && log INFO "$TEST_NAME::$test() passed." || \ + log ERROR "$TEST_NAME::$test() failed!" +done diff --git a/ghp/commands/drop.py b/ghp/commands/drop.py new file mode 100644 index 0000000..f593643 --- /dev/null +++ b/ghp/commands/drop.py @@ -0,0 +1,134 @@ +# +# Copyright (c) 2013, 2014 Hewlett-Packard Development Company, L.P. +# +# Confidential computer software. Valid license from HP required for +# possession, use or copying. Consistent with FAR 12.211 and 12.212, +# Commercial Computer Software, Computer Software Documentation, and +# Technical Data for Commercial Items are licensed to the U.S. Government +# under vendor's standard commercial license. +# + +from ghp.errors import HpgitError +from ghp.log import LogDedentMixin +from ghp.lib.utils import GitMixin +from ghp import subcommand, log + +from git import BadObject, util + +import inspect +import re + + +class DropError(HpgitError): + """Exception thrown by L{Drop}""" + pass + + +class Drop(LogDedentMixin, GitMixin): + """Mark a commit to be dropped on next import. + + Mark a commit as to be dropped. + + The mark is applied by means of a note in upstream-merge namespace + (refs/notes/upstream-merge). + + The note will contain the following header: + + Dropped: Walter White + + """ + DROP_HEADER = 'Dropped:' + NOTE_REF = 'refs/notes/upstream-merge' + + def __init__(self, git_object=None, author=None, *args, **kwargs): + + # make sure to correctly initialize inherited objects before performing + # any computation + super(Drop, self).__init__(*args, **kwargs) + + # test parameters + if not git_object: + raise DropError("Commit should be provided") + + try: + # test commit "id" presence + self._commit = self.repo.commit(git_object) + except BadObject: + raise DropError("Commit '%s' not found (or ambiguous)" % git_object) + + if not author: + self._author = '%s <%s>' % (self.repo.git.config('user.name'), + self.repo.git.config('user.email')) + else: + self._author = author + + # test that we can use this git repo + if not self.is_detached(): + raise DropError("In 'detached HEAD' state") + + # To Do: check if it possible and useful. + if self.repo.bare: + raise DropError("Cannot add notes in bare repositories") + + @property + def commit(self): + """Commit to be marked as dropped.""" + return self._commit + + @property + def author(self): + """Commit to be marked as dropped.""" + return self._author + + def check_duplicates(self): + """Check if a dropped header is already present""" + note = self.commit.note(note_ref=Drop.NOTE_REF) + if note: + pattern = '^%s\s*(.+)' % Drop.DROP_HEADER + m = re.search(pattern, note, re.MULTILINE | re.IGNORECASE) + if m: + self.log.warning( + """Drop header already present in the note for commit '%s': + %s""" % (self.commit, m.group(1))) + return False + return True + + def mark(self): + """ + Create the note for the given commit with the given change-ids. + """ + self.log.debug("Creating a note for commit '%s'", self.commit) + + if self.check_duplicates(): + git_note = '%s %s\n' % (Drop.DROP_HEADER, self.author) + self.log.debug('With the following content:') + self.log.debug(git_note) + self.commit.append_note(git_note, note_ref=Drop.NOTE_REF) + else: + self.log.warning( + "Drop note has not been added as '%s' already has one" % + self.commit) + + +@subcommand.arg('commit', metavar='', nargs=None, + help='Commit to be marked as dropped') +@subcommand.arg('-a', '--author', metavar='', + dest='author', + default=None, + help='Git author for the mark') +def do_drop(args): + """ + Mark a commit as dropped. + Marked commits will be skipped during the upstream rebasing process. + See also the "git hp import-upstream" command. + """ + + logger = log.getLogger('%s.%s' % (__name__, + inspect.stack()[0][0].f_code.co_name)) + + drop = Drop(git_object=args.commit, author=args.author) + + if drop.mark(): + logger.notice("Drop mark created successfully") + +# vim:sw=4:sts=4:ts=4:et: diff --git a/ghp/commands/import_upstream.py b/ghp/commands/import_upstream.py index 16f2212..514885e 100644 --- a/ghp/commands/import_upstream.py +++ b/ghp/commands/import_upstream.py @@ -428,7 +428,8 @@ class ImportStrategiesFactory(object): from ghp.lib.searchers import (NoMergeCommitFilter, ReverseCommitFilter, - DiscardDuplicateGerritChangeId) + DiscardDuplicateGerritChangeId, + SupersededCommitFilter, DroppedCommitFilter) class LocateChangesStrategy(GitMixin, Sequence): @@ -501,6 +502,9 @@ class LocateChangesWalk(LocateChangesStrategy): limit=self.searcher.commit)) self.filters.append(NoMergeCommitFilter()) self.filters.append(ReverseCommitFilter()) + self.filters.append(DroppedCommitFilter()) + self.filters.append( + SupersededCommitFilter(self.search_ref, limit=self.searcher.commit)) return super(LocateChangesWalk, self).filtered_iter() @@ -590,7 +594,6 @@ def do_import_upstream(args): """, "\n ".join(commit_list)) return True - logger.notice("Starting import of upstream") importupstream.create_import(force=args.force) logger.notice("Successfully created import branch") diff --git a/ghp/commands/supersede.py b/ghp/commands/supersede.py new file mode 100644 index 0000000..eae57b3 --- /dev/null +++ b/ghp/commands/supersede.py @@ -0,0 +1,193 @@ +# +# Copyright (c) 2013, 2014 Hewlett-Packard Development Company, L.P. +# +# Confidential computer software. Valid license from HP required for +# possession, use or copying. Consistent with FAR 12.211 and 12.212, +# Commercial Computer Software, Computer Software Documentation, and +# Technical Data for Commercial Items are licensed to the U.S. Government +# under vendor's standard commercial license. +# + +from ghp.errors import HpgitError +from ghp.log import LogDedentMixin +from ghp.lib.utils import GitMixin +from ghp.lib.searchers import CommitMessageSearcher +from ghp import subcommand, log + +from git import BadObject, Head + +import inspect +import re + + +class SupersedeError(HpgitError): + """Exception thrown by L{Supersede}""" + pass + + +class Supersede(LogDedentMixin, GitMixin): + """ + Mark a commit as superseded. + + The mark is applied by means of a note in upstream-merge namespace + (refs/notes/upstream-merge). + + The note will contain one or more of the following headers: + + Superseded-by: I82ef79c3621dacf619a02404f16464877a06f158 + + """ + + SUPERSEDE_HEADER = 'Superseded-by:' + NOTE_REF = 'refs/notes/upstream-merge' + + CHANGE_ID_REGEX = '^I[0-9a-f]{6,40}$' + CHANGE_ID_HEADER_REGEX_FMT = '^Change-Id:\s*%s' + CHANGE_ID_HEADER_REGEX = '^Change-Id:\s*(I[0-9a-f]{6,40})$' + + def __init__(self, git_object=None, change_ids=list(), + upstream_branch=None, force=False, *args, **kwargs): + + # make sure to correctly initialize inherited objects before performing + # any computation + super(Supersede, self).__init__(*args, **kwargs) + + # test commit parameter + if not git_object: + raise SupersedeError("Commit should be provided") + + # test that we can use this git repo + if not self.is_detached(): + raise SupersedeError("In 'detached HEAD' state") + + # To Do: check if it possible and useful. + if self.repo.bare: + raise SupersedeError("Cannot add notes in bare repositories") + + if not upstream_branch: + raise SupersedeError("Missing upstream_branch parameter") + + try: + # test commit "id" presence + self._commit = self.repo.commit(git_object) + except BadObject: + raise SupersedeError("Commit '%s' not found (or ambiguous)" % + git_object) + + # test change_ids parameter + if len(change_ids) == 0: + raise SupersedeError("At least one change id should be provided") + + self._upstream_branch = upstream_branch + self._change_ids = change_ids + git_branch = Head(self.repo, 'refs/heads/%s' % upstream_branch) + for change_id in change_ids: + # Check change id format + if not re.match(Supersede.CHANGE_ID_REGEX, change_id, + re.IGNORECASE): + raise SupersedeError("Invalid Change Id '%s'" % change_id) + + # Check if change id is actually present in some commit + # reachable from + try: + change_commit = CommitMessageSearcher( + repo=self.repo, branch=git_branch, + pattern=Supersede.CHANGE_ID_HEADER_REGEX_FMT % + change_id).find() + + self.log.debug("Change-id '%s' found in commit '%s'" % + (change_id, change_commit)) + + except RuntimeError as e: + if force: + self.log.warn("Warning: change-id '%s' not found in '%s'" % + (change_id, upstream_branch)) + else: + raise SupersedeError( + "Change-Id '%s' not found in branch '%s'" % + (change_id, upstream_branch)) + + @property + def commit(self): + """Commit to be marked as superseded.""" + return self._commit + + @property + def change_ids(self): + """Change ids that make a commit obsolete.""" + return self._change_ids + + @property + def change_ids_branch(self): + """Branch to search for change ids""" + return self._upstream_branch + + def check_duplicates(self): + """ + Check if a supersede header is already present in the note containing + one of change ids passed on the command line + """ + note = self.commit.note(note_ref=Supersede.NOTE_REF) + if note: + pattern = '^%s\s?(%s)$' % (Supersede.SUPERSEDE_HEADER, + '|'.join(self.change_ids)) + m = re.search(pattern, note, re.MULTILINE | re.IGNORECASE) + if m: + self.log.warning( + ("Change-Id '%s' already present in the note for commit" + + " '%s'") % (m.group(1), self.commit)) + return False + return True + + def mark(self): + """Create the note for the given commit with the given change-ids.""" + self.log.debug("Creating a note for commit '%s'", self.commit) + + if self.check_duplicates(): + git_note = '' + for change_id in self.change_ids: + git_note += '%s %s\n' % (Supersede.SUPERSEDE_HEADER, change_id) + self.log.debug('With the following content:') + self.log.debug(git_note) + self.commit.append_note(git_note, note_ref=Supersede.NOTE_REF) + else: + self.log.warning('Note has not been added') + + +@subcommand.arg('commit', metavar='', nargs=None, + help='Commit to be marked as superseded') +@subcommand.arg('change_ids', metavar='', nargs='+', + help='Change id which makes obsolete. The change id ' + 'must be present in to drop . ' + 'If more than one change id is specified, all must be ' + 'present in to drop ') +@subcommand.arg('-f', '--force', dest='force', required=False, + action='store_true', default=False, + help='Apply the commit mark even if one or more change ids ' + 'could not be found. Use this flag carefully as commits ' + 'will not be dropped during import-upstream command ' + 'execution as long as all associated change ids are ' + 'present in the local copy of the upstream branch') +@subcommand.arg('-u', '--upstream-branch', metavar='', + dest='upstream_branch', required=False, + default='upstream/master', + help='Search change ids values in branch ' + '(default: %(default)s)') +def do_supersede(args): + """ + Mark a commit as superseded by a set of change-ids. + Marked commits will be skipped during the upstream rebasing process. + See also the "git hp import-upstream" command. + """ + + logger = log.getLogger('%s.%s' % (__name__, + inspect.stack()[0][0].f_code.co_name)) + + supersede = Supersede(git_object=args.commit, change_ids=args.change_ids, + upstream_branch=args.upstream_branch, + force=args.force) + + if supersede.mark(): + logger.notice("Supersede mark created successfully") + +# vim:sw=4:sts=4:ts=4:et: diff --git a/ghp/lib/__init__.py b/ghp/lib/__init__.py index 4439c26..7c5e1a0 100644 --- a/ghp/lib/__init__.py +++ b/ghp/lib/__init__.py @@ -7,3 +7,5 @@ # Technical Data for Commercial Items are licensed to the U.S. Government # under vendor's standard commercial license. # + +from ghp.lib import note diff --git a/ghp/lib/note.py b/ghp/lib/note.py new file mode 100644 index 0000000..185b0a5 --- /dev/null +++ b/ghp/lib/note.py @@ -0,0 +1,52 @@ +from ghp.errors import HpgitError +from git import base, GitCommandError + +class NoteAlreadyExistsError(HpgitError): + """Exception thrown by note related commands""" + pass + + +def add_note(self, message, force=False, note_ref='refs/notes/commits'): + """ + Add a note to an object, tossing a NoteError exception if the object is + already annotated. + :param message: note message + :param force: if true, any existing note will be overwritten + :param note_ref: ref to use for notes. Defaults to refs/notes/commits + """ + if force: + self.repo.git.notes('--ref', note_ref, 'add', '-f', '-m', message, + str(self)) + else: + try: + self.repo.git.notes('--ref', note_ref, 'add', '-m', message, + str(self)) + except GitCommandError as e: + if e.status == 1: + raise NoteAlreadyExistsError(e.message) + else: + raise e + +def append_note(self, message, note_ref='refs/notes/commits'): + """Add a note to an object + :param message: note message + :param note_ref: ref to use for notes. Defaults to refs/notes/commits + """ + self.repo.git.notes('--ref', note_ref, 'append', '-m', message, str(self)) + +def note_message(self, note_ref='refs/notes/commits'): + """ + Return note message + :param note_ref: ref to use for notes. Defaults to refs/notes/commits + """ + try: + return self.repo.git.notes('--ref', note_ref, 'show', str(self)) + except GitCommandError as e: + if e.status == 1: + return None + else: + raise e + +base.Object.add_note = add_note +base.Object.append_note = append_note +base.Object.note = note_message diff --git a/ghp/lib/searchers.py b/ghp/lib/searchers.py index b027f89..2e85821 100644 --- a/ghp/lib/searchers.py +++ b/ghp/lib/searchers.py @@ -16,6 +16,8 @@ try: except ImportError: from ghp.lib.pygitcompat import HpgitCompatCommit as Commit +from git import Head + from abc import ABCMeta, abstractmethod import re @@ -348,7 +350,7 @@ class CommitMessageSearcher(LogDedentMixin, Searcher): if not self.commit: raise RuntimeError("Failed to locate a pattern match") - self.log.notice("Commit matching search pattern is: '%s'", self.commit.hexsha) + self.log.debug("Commit matching search pattern is: '%s'", self.commit.hexsha) return self.commit.hexsha @@ -382,6 +384,150 @@ class CommitFilter(object): pass +class SupersededCommitFilter(LogDedentMixin, GitMixin, CommitFilter): + """ + Prunes all commits that have a note with the "Superseded-by:" header + containing a Change-Id present in upstream tracking branch + + :param string search_ref: git reference to search for ChangeIds (required). + :param Commit limit: commit object to ignore searching history after + (optional). + """ + + SUPERSEDE_HEADER = 'Superseded-by:' + NOTE_REF = 'refs/notes/upstream-merge' + + def __init__(self, search_ref, limit=None, *args, **kwargs): + + super(SupersededCommitFilter, self).__init__(*args, **kwargs) + + if not self.is_valid_commit(search_ref): + raise ValueError("Invalid value for 'search_ref': %s" % search_ref) + self.search_ref = search_ref + + if limit: + if not hasattr(limit, 'hexsha'): + raise ValueError( + "Invalid object: no hexsha attribute for 'limit'") + if not self.is_valid_commit(limit.hexsha): + raise ValueError("'limit' object does not contain a valid SHA1") + self.limit = limit + + self._regex = None + + def _get_rev_range(self): + + if self.limit: + return "%s..%s" % (self.limit.hexsha, self.search_ref) + else: + return self.search_ref + + def _get_change_id(self, commit): + """ + Returns the Change-Id string from the footer of the given commit. + + Will ignore any instances outside of the footer section + """ + # read the commit message in reverse to access the + # footer first but ignore subject and first blank line + for line in reversed(commit.message.splitlines()[1:]): + line = line.strip() + # exit on the first blank line found since that indicates + # we're reached the top of the footer section + if not line: + break + + cid = re.search('^Change-Id:\s*(.+)$', line, re.IGNORECASE) + if cid: + return cid.group(1) + return + + def filter(self, commit_iter): + + self.log.info( + """\ + Filtering out all commits marked with a Superseded-by Change-Id + which is present in '%s' + """, self.search_ref) + + supersede_re = re.compile('^%s\s*(.+)\s*$' % + SupersededCommitFilter.SUPERSEDE_HEADER, + re.IGNORECASE | re.MULTILINE) + + for commit in commit_iter: + commit_note = commit.note(note_ref=SupersededCommitFilter.NOTE_REF) + # include non-annotated commits + if not commit_note: + yield commit + continue + + # include annotated commits which don't have a SUPERSEDE_HEADER + superseding_change_ids = supersede_re.findall(commit_note) + if not superseding_change_ids: + yield commit + continue + + # search for all the change-ids in matches (egrep regex) + commits_grep_re = '^Change-Id:\\s*\(%s\)\\s*$' % \ + '|'.join(superseding_change_ids) + + # retrieve all matching commits because we need to check + # each match for whether the changeId is actually in + # the footer or just included as a reference. + matching_commits = Commit.iter_items(self.repo, + self._get_rev_range(), + regexp_ignore_case=True, + grep=commits_grep_re) + + for possible in matching_commits: + change_id = self._get_change_id(possible) + if change_id: + superseding_change_ids.remove(change_id) + + # include commits which have some superseding change-ids not + # present in upstream + if superseding_change_ids: + self.log.debug( + """\ + Including commit '%s %s' + because the following superseding change-ids have not been + found: + %s + """, commit.hexsha[:7], commit.message.splitlines()[0], + '\n'.join(superseding_change_ids)) + yield commit + continue + + self.log.debug( + """\ + Filtering out commit '%s %s' + because it has been marked as superseded by the following + note: + %s + """, commit.hexsha[:7], commit.message.splitlines()[0], + commit_note) + +class DroppedCommitFilter(LogDedentMixin, CommitFilter): + """ + Prunes all commits that have a note with the Dropped: header + """ + + DROPPED_HEADER = 'Dropped:' + NOTE_REF = 'refs/notes/upstream-merge' + + def filter(self, commit_iter): + for commit in commit_iter: + commit_note = commit.note(note_ref=DroppedCommitFilter.NOTE_REF) + if not commit_note: + yield commit + elif not re.match('^%s.+' % DroppedCommitFilter.DROPPED_HEADER, + commit_note, re.IGNORECASE | re.MULTILINE): + yield commit + else: + self.log.debug("Dropping commit '%s' as requested:", commit) + self.log.debug(commit_note) + + class MergeCommitFilter(CommitFilter): """ Includes only those commits that have more than one parent listed (merges) diff --git a/tests/test_commands.py b/tests/test_commands.py index 3748964..9425a22 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -18,7 +18,7 @@ from ghp import commands as c class TestGetSubcommands(testtools.TestCase): """Test case for get_subcommands function""" - _available_subcommands = ('import-upstream',) + _available_subcommands = ('import-upstream', 'supersede' ,'drop') def test_available_subcommands(self): """Test available subcommands""" diff --git a/tests/test_drop.py b/tests/test_drop.py new file mode 100644 index 0000000..41385c9 --- /dev/null +++ b/tests/test_drop.py @@ -0,0 +1,67 @@ +# +# Copyright (c) 2012, 2013 Hewlett-Packard Development Company, L.P. +# +# Confidential computer software. Valid license from HP required for +# possession, use or copying. Consistent with FAR 12.211 and 12.212, +# Commercial Computer Software, Computer Software Documentation, and +# Technical Data for Commercial Items are licensed to the U.S. Government +# under vendor's standard commercial license. +# + +"""Tests the drop module""" +import testtools +from ghp.commands import drop as d +from git import repo as r +from git import GitCommandError + +class TestDrop(testtools.TestCase): + """Test case for Drop class""" + + first_commit = "bd6b9eefe961abe8c15cb5dc6905b92e14714a4e" + second_commit = "05fac847a5629e36050dcd69b9a782b2645d3cc7" + invalid_commit = "this_is_an_invalid_commit" + author="Walter White " + note_ref = 'refs/notes/upstream-merge' + + def test_valid_parameters(self): + """Test drop initialization and read properties""" + + repo=r.Repo('.') + automatic_author='%s <%s>' % (repo.git.config('user.name'), + repo.git.config('user.email')) + t = d.Drop(git_object=TestDrop.first_commit) + self.assertEquals(t.author, automatic_author) + + t = d.Drop(git_object=TestDrop.first_commit, author=TestDrop.author) + self.assertEquals(t.author, TestDrop.author) + + def test_invalid_commit(self): + """Test drop initialization with invalid commit""" + + self.assertRaises(d.DropError, d.Drop, + git_object=TestDrop.invalid_commit) + + def test_mark(self): + """Test drop mark""" + + t = d.Drop(git_object=TestDrop.first_commit, author=TestDrop.author) + + repo = r.Repo('.') + try: + # Older git versions don't support --ignore-missing so we need to + # catch GitCommandError exception + repo.git.notes('--ref', TestDrop.note_ref, 'remove', + TestDrop.first_commit) + except GitCommandError: + pass + + t.mark() + + self.assertRegexpMatches( + '^Dropped: %s' % TestDrop.author, + repo.git.notes('--ref', TestDrop.note_ref, 'show', + TestDrop.first_commit) + ) + + repo.git.notes('--ref', TestDrop.note_ref, 'remove', + TestDrop.first_commit) diff --git a/tests/test_supersede.py b/tests/test_supersede.py new file mode 100644 index 0000000..e16bad1 --- /dev/null +++ b/tests/test_supersede.py @@ -0,0 +1,112 @@ +# +# Copyright (c) 2012, 2013 Hewlett-Packard Development Company, L.P. +# +# Confidential computer software. Valid license from HP required for +# possession, use or copying. Consistent with FAR 12.211 and 12.212, +# Commercial Computer Software, Computer Software Documentation, and +# Technical Data for Commercial Items are licensed to the U.S. Government +# under vendor's standard commercial license. +# + +"""Tests the supersede module""" + +import testtools +from ghp.commands import supersede as s +from git import repo as r +from git import GitCommandError + +class TestSupersede(testtools.TestCase): + """Test case for Supersede class""" + + first_commit = "bd6b9eefe961abe8c15cb5dc6905b92e14714a4e" + second_commit = "05fac847a5629e36050dcd69b9a782b2645d3cc7" + invalid_commit = "this_is_an_invalid_commit" + first_change_ids = ("Ia028d7afc9df2a599a52b1b17858037fab4e3f44",) + second_change_ids = ("Iebd1f5aa789dcd9574a00bb8837e4596bf55fa88", + "I4ab003213c40b0375283a15e2967d11e0351feb1") + invalid_change_ids = ("this_is_an_invalid_change_id",) + change_ids_branch = "master" + invalid_change_ids_branch = "this_is_an_invalid_change_ids_branch" + note_ref = 'refs/notes/upstream-merge' + + def test_valid_parameters(self): + """Test supersede initialization and read properties""" + + t = s.Supersede(git_object=TestSupersede.first_commit, + change_ids=TestSupersede.first_change_ids, + upstream_branch=TestSupersede.change_ids_branch) + + self.assertEquals(str(t.commit), TestSupersede.first_commit) + self.assertNotEqual(str(t.commit), TestSupersede.second_commit) + self.assertEqual(str(t.change_ids_branch), + TestSupersede.change_ids_branch) + self.assertNotEqual(str(t.change_ids_branch), + TestSupersede.invalid_change_ids_branch) + + def test_invalid_commit(self): + """Test supersede initialization with invalid commit""" + + self.assertRaises(s.SupersedeError, s.Supersede, + git_object=TestSupersede.invalid_commit, + change_ids=TestSupersede.first_change_ids, + upstream_branch=TestSupersede.change_ids_branch) + + def test_multiple_change_id(self): + """Test supersede initialization with multiple change ids""" + + t = s.Supersede(git_object=TestSupersede.first_commit, + change_ids=TestSupersede.second_change_ids, + upstream_branch=TestSupersede.change_ids_branch) + + self.assertEquals(str(t.commit), TestSupersede.first_commit) + self.assertNotEqual(str(t.commit), TestSupersede.second_commit) + + def test_invalid_cids(self): + """Test supersede initialization with invalid cids""" + + self.assertRaises(s.SupersedeError, s.Supersede, + git_object=TestSupersede.first_commit, + change_ids=TestSupersede.invalid_change_ids, + upstream_branch=TestSupersede.change_ids_branch) + + def test_default_upstream_branch(self): + """Test supersede initialization with no branch name""" + + self.assertRaises(s.SupersedeError, s.Supersede, + git_object=TestSupersede.first_commit, + change_ids=TestSupersede.invalid_change_ids, + upstream_branch= + TestSupersede.invalid_change_ids_branch) + + def test_no_upstream_branch(self): + """Test supersede initialization with invalid branch name""" + + self.assertRaises(s.SupersedeError, s.Supersede, + git_object=TestSupersede.first_commit, + change_ids=TestSupersede.invalid_change_ids) + + def test_mark(self): + """Test Supersede mark""" + + t = s.Supersede(git_object=TestSupersede.first_commit, + change_ids=TestSupersede.first_change_ids, + upstream_branch=TestSupersede.change_ids_branch) + + repo = r.Repo('.') + try: + # Older git versions don't support --ignore-missing + repo.git.notes('--ref', TestSupersede.note_ref, 'remove', + TestSupersede.first_commit) + except GitCommandError: + pass + + t.mark() + + self.assertRegexpMatches( + '^Superseded-by: %s' % TestSupersede.first_change_ids, + repo.git.notes('--ref', TestSupersede.note_ref, 'show', + TestSupersede.first_commit) + ) + + repo.git.notes('--ref', TestSupersede.note_ref, 'remove', + TestSupersede.first_commit)