summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJannis Pohlmann <jannis.pohlmann@codethink.co.uk>2012-12-05 17:51:47 +0000
committerJannis Pohlmann <jannis.pohlmann@codethink.co.uk>2012-12-13 14:30:35 +0000
commit51e4bbb4dffde9574404df9c5e947f518dc49a41 (patch)
tree208d2826e88932f2442c44b4268bdda76346206f
parentd63c97a0bef1cd2f03ca266acda67cad065632df (diff)
downloadmorph-51e4bbb4dffde9574404df9c5e947f518dc49a41.tar.gz
Add an initial implementation of "morph tag"
In order to make releases and freeze system branches entirely, we need to be able to 100% petrify a system branch (that is, resolve ALL refs into SHA1s) and tag this state to be able to check it out again later. This is essentially what "morph tag" does. It takes a tag name and an arbitrary amount of arguments to "git tag", petrifies all morphologies of the current system branch behind the scenes, creates a dangling commit and attaches an annotated tag to it. Petrifying in this case means that all refs used for chunks are resolved into commit SHA1s. For stratum and system morphologies, the refs are replaced by the name of the tag that's being created. The "tag" command also supports tagging when stratum morphologies are spread across multiple repositories. In this case, it will include all statum morphologies from other repos in the tag commi in the branch root repo. The references to these morphologies are updated so that they point to the branch root repo and the tag being created. This commit also adds a few tests for "morph tag" to verify that all this works.
-rw-r--r--morphlib/git.py13
-rw-r--r--morphlib/plugins/branch_and_merge_plugin.py203
-rwxr-xr-xtests.branching/morph-tag-creates-commit-and-tag.script39
-rw-r--r--tests.branching/morph-tag-creates-commit-and-tag.stdout55
-rw-r--r--tests.branching/morph-tag-fails-if-tag-exists.exit1
-rwxr-xr-xtests.branching/morph-tag-fails-if-tag-exists.script33
-rw-r--r--tests.branching/morph-tag-fails-if-tag-exists.stderr3
-rwxr-xr-xtests.branching/morph-tag-tag-works-as-expected.script46
-rw-r--r--tests.branching/morph-tag-tag-works-as-expected.stdout58
-rwxr-xr-xtests.branching/morph-tag-works-with-multiple-morphs-repos.script123
-rw-r--r--tests.branching/morph-tag-works-with-multiple-morphs-repos.stdout172
11 files changed, 746 insertions, 0 deletions
diff --git a/morphlib/git.py b/morphlib/git.py
index 7985b815..a37b6675 100644
--- a/morphlib/git.py
+++ b/morphlib/git.py
@@ -170,6 +170,19 @@ def get_user_name(runcmd):
' git config --global user.email "me@example.com"\n')
+def get_user_email(runcmd):
+ '''Get user.email configuration setting. Complain if none was found.'''
+ if 'GIT_AUTHOR_EMAIL' in os.environ:
+ return os.environ['GIT_AUTHOR_EMAIL'].strip()
+ try:
+ return runcmd(['git', 'config', 'user.email']).strip()
+ except cliapp.AppException:
+ raise cliapp.AppException(
+ 'No git user info found. Please set your identity, using: \n'
+ ' git config --global user.email "My Name"\n'
+ ' git config --global user.email "me@example.com"\n')
+
+
def set_remote(runcmd, gitdir, name, url):
'''Set remote with name 'name' use a given url at gitdir'''
return runcmd(['git', 'remote', 'set-url', name, url], cwd=gitdir)
diff --git a/morphlib/plugins/branch_and_merge_plugin.py b/morphlib/plugins/branch_and_merge_plugin.py
index efaa8dfe..ae2eb82d 100644
--- a/morphlib/plugins/branch_and_merge_plugin.py
+++ b/morphlib/plugins/branch_and_merge_plugin.py
@@ -48,6 +48,7 @@ class BranchAndMergePlugin(cliapp.Plugin):
arg_synopsis='SYSTEM STRATUM [CHUNK]')
self.app.add_subcommand('petrify', self.petrify)
self.app.add_subcommand('unpetrify', self.unpetrify)
+ self.app.add_subcommand('tag', self.tag)
self.app.add_subcommand('build', self.build,
arg_synopsis='SYSTEM')
self.app.add_subcommand('status', self.status)
@@ -825,6 +826,208 @@ class BranchAndMergePlugin(cliapp.Plugin):
self.print_changelog('The following changes were made but have not '
'been committed')
+ def tag(self, args):
+ if len(args) < 1:
+ raise cliapp.AppException('morph tag expects a tag name')
+
+ tagname = args[0]
+
+ # Deduce workspace, system branch and branch root repository.
+ workspace = self.deduce_workspace()
+ branch, branch_dir = self.deduce_system_branch()
+ branch_root = self.get_branch_config(branch_dir, 'branch.root')
+ branch_root_dir = self.find_repository(branch_dir, branch_root)
+
+ # Define the committer.
+ committer_name = morphlib.git.get_user_name(self.app.runcmd)
+ committer_email = morphlib.git.get_user_email(self.app.runcmd)
+
+ # Prepare an environment for our internal index file.
+ # This index file allows us to commit changes to a tree without
+ # git noticing any change in the working tree or its own index.
+ env = dict(os.environ)
+ env['GIT_INDEX_FILE'] = os.path.join(
+ branch_root_dir, '.git', 'morph-tag-index')
+ env['GIT_COMMITTER_NAME'] = committer_name
+ env['GIT_COMMITTER_EMAIL'] = committer_email
+
+ # Extract git arguments that deal with the commit message.
+ # This is so that we can use them for creating the tag commit.
+ msg_args = []
+ for i in xrange(0, len(args)):
+ if args[i] == '-m' or args[i] == '-F':
+ if i < len(args)-1:
+ msg_args.append(args[i])
+ msg_args.append(args[i+1])
+ elif args[i].startswith('--message='):
+ msg_args.append(args[i])
+
+ # Fail if no commit mesage was provided.
+ if not msg_args:
+ raise cliapp.AppException(
+ 'Commit message expected. Please run one of '
+ 'the following commands to provide one:\n'
+ ' morph tag NAME -- -m "Message"\n'
+ ' morph tag NAME -- --message="Message"\n'
+ ' morph tag NAME -- -F <message file>')
+
+ # Abort if the tag already exists.
+ try:
+ morphlib.git.rev_parse(self.app.runcmd, branch_root_dir,
+ 'refs/tags/%s' % tagname)
+ raise cliapp.AppException('%s: Tag \"%s\" already exists' %
+ (branch_root, tagname))
+ except:
+ pass
+
+ self.app.status(msg='%(repo)s: Preparing tag commit',
+ repo=branch_root)
+
+ # Read current tree into the internal index.
+ parent_sha1 = self.resolve_ref(branch_root_dir, branch)
+ self.app.runcmd(['git', 'read-tree', parent_sha1],
+ cwd=branch_root_dir, env=env)
+
+ self.app.status(msg='%(repo)s: Petrifying everything',
+ repo=branch_root)
+
+ # Petrify everything.
+ self.lrc, self.rrc = morphlib.util.new_repo_caches(self.app)
+ self.petrify_everything(branch, branch_dir,
+ branch_root, branch_root_dir,
+ tagname, env)
+
+ self.app.status(msg='%(repo)s: Creating tag commit',
+ repo=branch_root)
+
+ # Create a dangling commit.
+ commit = self.create_tag_commit(
+ branch_root_dir, tagname, msg_args, env)
+
+ self.app.status(msg='%(repo)s: Creating annotated tag "%(tag)s"',
+ repo=branch_root, tag=tagname)
+
+ # Create an annotated tag for this commit.
+ self.create_annotated_tag(branch_root_dir, commit, env, args)
+
+ def petrify_everything(self, branch, branch_dir,
+ branch_root, branch_root_dir, tagref, env):
+ petrified_morphologies = set()
+ resolved_refs = {}
+ for f in sorted(glob.iglob(os.path.join(branch_root_dir, '*.morph'))):
+ name = os.path.basename(f)[:-len('.morph')]
+ morphology = self.load_morphology(branch_root_dir, name)
+ self.petrify_morphology(branch, branch_dir,
+ branch_root, branch_root_dir,
+ branch_root, branch_root_dir,
+ tagref, name, morphology,
+ petrified_morphologies, resolved_refs, env)
+
+ def petrify_morphology(self, branch, branch_dir,
+ branch_root, branch_root_dir, repo, repo_dir,
+ tagref, name, morphology,
+ petrified_morphologies, resolved_refs, env):
+ self.app.status(msg='%(repo)s: Petrifying morphology \"%(morph)s\"',
+ repo=repo, morph=name)
+
+ # Mark morphology as petrified_morphologies so we don't petrify it twice.
+ petrified_morphologies.add(morphology)
+
+ # Resolve the refs of all build dependencies (strata) and strata
+ # in the morphology into commit SHA1s.
+ strata = []
+ if 'build-depends' in morphology and morphology['build-depends']:
+ strata += morphology['build-depends']
+ if 'strata' in morphology and morphology['strata']:
+ strata += morphology['strata']
+ for info in strata:
+ # Obtain the commit SHA1 this stratum would be built from.
+ commit, tree = self.resolve_info(info, resolved_refs)
+ stratum_repo_dir = self.edit_stratum(
+ branch, branch_dir, repo, repo_dir, info)
+
+ # Load the stratum morphology and petrify it recursively if
+ # that hasn't happened yet.
+ stratum = self.load_morphology(stratum_repo_dir, info['morph'])
+ if not stratum in petrified_morphologies:
+ self.petrify_morphology(branch, branch_dir,
+ branch_root, branch_root_dir,
+ info['repo'], stratum_repo_dir,
+ tagref, info['morph'], stratum,
+ petrified_morphologies,
+ resolved_refs, env)
+
+ # Change the ref for this morphology to the tag we're creating.
+ if info['ref'] != tagref:
+ info['unpetrify-ref'] = info['ref']
+ info['ref'] = tagref
+
+ # We'll be copying all systems/strata into the tag commit
+ # in the branch root repo, so make sure to note what repos
+ # they all came from
+ if info['repo'] != branch_root:
+ info['unpetrify-repo'] = info['repo']
+ info['repo'] = branch_root
+
+ # If this morphology is a stratum, resolve the refs of all its
+ # chunks into SHA1s.
+ if morphology['kind'] == 'stratum':
+ for info in morphology['chunks']:
+ commit, tree = self.resolve_info(info, resolved_refs)
+ if info['ref'] != commit:
+ info['unpetrify-ref'] = info['ref']
+ info['ref'] = commit
+
+ # Write the petrified morphology to a temporary file in the
+ # branch root repository for inclusion in the tag commit.
+ handle, tmpfile = tempfile.mkstemp(suffix='.morph')
+ self.save_morphology(branch_root_dir, tmpfile, morphology)
+
+ # Hash the petrified morphology and add it to the index
+ # for the tag commit.
+ sha1 = self.app.runcmd(
+ ['git', 'hash-object', '-t', 'blob', '-w', tmpfile],
+ cwd=branch_root_dir, env=env)
+ self.app.runcmd(
+ ['git', 'update-index', '--add', '--cacheinfo',
+ '100644', sha1, '%s.morph' % name],
+ cwd=branch_root_dir, env=env)
+
+ # Delete the temporary file again.
+ os.remove(tmpfile)
+
+ def resolve_info(self, info, resolved_refs):
+ '''Takes a morphology info and resolves its ref with cache support.'''
+
+ key = (info['repo'], info['ref'])
+ if not key in resolved_refs:
+ commit_sha1, tree_sha1 = self.app.resolve_ref(
+ self.lrc, self.rrc, info['repo'], info['ref'],
+ update=not self.app.settings['no-git-update'])
+ resolved_refs[key] = (commit_sha1, tree_sha1)
+ return resolved_refs[key]
+
+ def create_tag_commit(self, repo_dir, tagname, args, env):
+ self.app.status(msg='%(repo)s: Creating commit for the tag',
+ repo=repo_dir)
+
+ # Write and commit the tree.
+ tree = self.app.runcmd(
+ ['git', 'write-tree'], cwd=repo_dir, env=env).strip()
+ commit = self.app.runcmd(
+ ['git', 'commit-tree', tree, '-p', 'HEAD'] + args,
+ cwd=repo_dir, env=env).strip()
+ return commit
+
+ def create_annotated_tag(self, repo_dir, commit, env, args=[]):
+ self.app.status(msg='%(repo)s: Creating annotated tag for '
+ 'commit %(commit)s',
+ repo=repo_dir, commit=commit)
+
+ # Create an annotated tag for the commit
+ self.app.runcmd(['git', 'tag', '-a'] + args + [commit],
+ cwd=repo_dir, env=env)
+
# When 'merge' is unset, git doesn't try to resolve conflicts itself in
# those files.
MERGE_ATTRIBUTE = '*.morph\t-merge\n'
diff --git a/tests.branching/morph-tag-creates-commit-and-tag.script b/tests.branching/morph-tag-creates-commit-and-tag.script
new file mode 100755
index 00000000..4de3e4b9
--- /dev/null
+++ b/tests.branching/morph-tag-creates-commit-and-tag.script
@@ -0,0 +1,39 @@
+#!/bin/sh
+#
+# Copyright (C) 2012 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.
+
+
+## Check that 'morph tag example-tag' successfully creates a dangling
+## commit and an annotated tag pointing to this commit.
+
+set -eu
+
+# Make sure the commits always have the same SHA1s.
+source "$SRCDIR/scripts/fix-committer-info"
+
+# Create a workspace and branch.
+cd "$DATADIR/workspace"
+"$SRCDIR/scripts/test-morph" init
+"$SRCDIR/scripts/test-morph" checkout test:morphs master
+
+# Tag the system branch.
+"$SRCDIR/scripts/test-morph" tag example-tag -- -m Message
+
+# Show the tag itself and its log. This allows to verify a couple of things,
+# including that the commit and tag are created, that the commit message is
+# set correctly and that all references are petrified.
+"$SRCDIR/scripts/test-morph" foreach -- git show example-tag
+"$SRCDIR/scripts/test-morph" foreach -- git log example-tag
diff --git a/tests.branching/morph-tag-creates-commit-and-tag.stdout b/tests.branching/morph-tag-creates-commit-and-tag.stdout
new file mode 100644
index 00000000..38bca41b
--- /dev/null
+++ b/tests.branching/morph-tag-creates-commit-and-tag.stdout
@@ -0,0 +1,55 @@
+test:morphs
+tag example-tag
+Tagger: developer <developer@example.com>
+Date: Tue Jul 31 16:51:54 2012 +0000
+
+Message
+
+commit 7cab6b5f121518b3ecd585c4af3c5bc267947bae
+Author: developer <developer@example.com>
+Date: Tue Jul 31 16:51:54 2012 +0000
+
+ Message
+
+diff --git a/hello-stratum.morph b/hello-stratum.morph
+index 3b7be17..87561c1 100644
+--- a/hello-stratum.morph
++++ b/hello-stratum.morph
+@@ -5,8 +5,9 @@
+ {
+ "name": "hello",
+ "repo": "test:hello",
+- "ref": "master",
+- "build-depends": []
++ "ref": "f4d032b42c0134e67bdf19a43fa99072493667d7",
++ "build-depends": [],
++ "unpetrify-ref": "master"
+ }
+ ]
+ }
+diff --git a/hello-system.morph b/hello-system.morph
+index f3f64b4..d26675d 100644
+--- a/hello-system.morph
++++ b/hello-system.morph
+@@ -8,7 +8,8 @@
+ {
+ "morph": "hello-stratum",
+ "repo": "test:morphs",
+- "ref": "master"
++ "ref": "example-tag",
++ "unpetrify-ref": "master"
+ }
+ ]
+ }
+test:morphs
+commit 7cab6b5f121518b3ecd585c4af3c5bc267947bae
+Author: developer <developer@example.com>
+Date: Tue Jul 31 16:51:54 2012 +0000
+
+ Message
+
+commit 48d38ef3f39857d7dba4ed1ffc51653c6bed4906
+Author: developer <developer@example.com>
+Date: Tue Jul 31 16:51:54 2012 +0000
+
+ initial
diff --git a/tests.branching/morph-tag-fails-if-tag-exists.exit b/tests.branching/morph-tag-fails-if-tag-exists.exit
new file mode 100644
index 00000000..d00491fd
--- /dev/null
+++ b/tests.branching/morph-tag-fails-if-tag-exists.exit
@@ -0,0 +1 @@
+1
diff --git a/tests.branching/morph-tag-fails-if-tag-exists.script b/tests.branching/morph-tag-fails-if-tag-exists.script
new file mode 100755
index 00000000..a30bb774
--- /dev/null
+++ b/tests.branching/morph-tag-fails-if-tag-exists.script
@@ -0,0 +1,33 @@
+#!/bin/sh
+#
+# Copyright (C) 2012 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.
+
+
+## Check that 'morph tag example-tag' fails if 'example-tag' already exists.
+
+set -eu
+
+# Make sure the commits always have the same SHA1s.
+source "$SRCDIR/scripts/fix-committer-info"
+
+# Create a workspace and branch.
+cd "$DATADIR/workspace"
+"$SRCDIR/scripts/test-morph" init
+"$SRCDIR/scripts/test-morph" checkout test:morphs master
+
+# Tag the system branch twice.
+"$SRCDIR/scripts/test-morph" tag example-tag -- -m First
+"$SRCDIR/scripts/test-morph" tag example-tag -- -m Second
diff --git a/tests.branching/morph-tag-fails-if-tag-exists.stderr b/tests.branching/morph-tag-fails-if-tag-exists.stderr
new file mode 100644
index 00000000..66c71252
--- /dev/null
+++ b/tests.branching/morph-tag-fails-if-tag-exists.stderr
@@ -0,0 +1,3 @@
+ERROR: Command failed: git tag -a example-tag -m Second 70d7adfe89033309e0494efbe6df3e4a5604e558
+fatal: tag 'example-tag' already exists
+
diff --git a/tests.branching/morph-tag-tag-works-as-expected.script b/tests.branching/morph-tag-tag-works-as-expected.script
new file mode 100755
index 00000000..25ba4a51
--- /dev/null
+++ b/tests.branching/morph-tag-tag-works-as-expected.script
@@ -0,0 +1,46 @@
+#!/bin/sh
+#
+# Copyright (C) 2012 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.
+
+
+## Check that tagging an existing tag commit with 'morph tag' preserves
+## the unpetrify-ref and does not "double-petrify" apart from updating
+## references to "example-tag" to "tagged-tag".
+
+set -eu
+
+# Make sure the commits always have the same SHA1s.
+source "$SRCDIR/scripts/fix-committer-info"
+
+# Create a workspace and branch.
+cd "$DATADIR/workspace"
+"$SRCDIR/scripts/test-morph" init
+"$SRCDIR/scripts/test-morph" checkout test:morphs master
+
+# Tag the system branch.
+"$SRCDIR/scripts/test-morph" tag example-tag -- -m First
+
+# Check out the tag.
+"$SRCDIR/scripts/run-git-in" master/test:morphs checkout -b example-tag \
+ 2>/dev/null
+
+# Tag the tag.
+"$SRCDIR/scripts/test-morph" tag tagged-tag -- -m Second
+
+# List all tags and show the second one.
+"$SRCDIR/scripts/test-morph" foreach -- git tag -l
+"$SRCDIR/scripts/test-morph" foreach -- git show tagged-tag
+"$SRCDIR/scripts/test-morph" foreach -- git log tagged-tag
diff --git a/tests.branching/morph-tag-tag-works-as-expected.stdout b/tests.branching/morph-tag-tag-works-as-expected.stdout
new file mode 100644
index 00000000..58d77714
--- /dev/null
+++ b/tests.branching/morph-tag-tag-works-as-expected.stdout
@@ -0,0 +1,58 @@
+test:morphs
+example-tag
+tagged-tag
+test:morphs
+tag tagged-tag
+Tagger: developer <developer@example.com>
+Date: Tue Jul 31 16:51:54 2012 +0000
+
+Second
+
+commit f7f04d63ec5e494cf5b4fd2e94ad5f2f26265c3f
+Author: developer <developer@example.com>
+Date: Tue Jul 31 16:51:54 2012 +0000
+
+ Second
+
+diff --git a/hello-stratum.morph b/hello-stratum.morph
+index 3b7be17..87561c1 100644
+--- a/hello-stratum.morph
++++ b/hello-stratum.morph
+@@ -5,8 +5,9 @@
+ {
+ "name": "hello",
+ "repo": "test:hello",
+- "ref": "master",
+- "build-depends": []
++ "ref": "f4d032b42c0134e67bdf19a43fa99072493667d7",
++ "build-depends": [],
++ "unpetrify-ref": "master"
+ }
+ ]
+ }
+diff --git a/hello-system.morph b/hello-system.morph
+index f3f64b4..2981e9b 100644
+--- a/hello-system.morph
++++ b/hello-system.morph
+@@ -8,7 +8,8 @@
+ {
+ "morph": "hello-stratum",
+ "repo": "test:morphs",
+- "ref": "master"
++ "ref": "tagged-tag",
++ "unpetrify-ref": "master"
+ }
+ ]
+ }
+test:morphs
+commit f7f04d63ec5e494cf5b4fd2e94ad5f2f26265c3f
+Author: developer <developer@example.com>
+Date: Tue Jul 31 16:51:54 2012 +0000
+
+ Second
+
+commit 48d38ef3f39857d7dba4ed1ffc51653c6bed4906
+Author: developer <developer@example.com>
+Date: Tue Jul 31 16:51:54 2012 +0000
+
+ initial
diff --git a/tests.branching/morph-tag-works-with-multiple-morphs-repos.script b/tests.branching/morph-tag-works-with-multiple-morphs-repos.script
new file mode 100755
index 00000000..374c9ff5
--- /dev/null
+++ b/tests.branching/morph-tag-works-with-multiple-morphs-repos.script
@@ -0,0 +1,123 @@
+#!/bin/sh
+#
+# Copyright (C) 2012 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.
+
+
+## Check that "morph tag" works if morphologies are spread across multiple
+## repositories. In this case, it should copy all petrified morphologies
+## into the branch root repository and only create the tag there.
+
+set -eu
+
+# Make sure the commits always have the same SHA1s.
+source "$SRCDIR/scripts/fix-committer-info"
+
+# Create new directory for repos used in this test.
+mkdir -p "$DATADIR/repos"
+
+# Create the first morphs repository.
+mkdir "$DATADIR/repos/morphs1"
+
+# Create system morphology in first morphs repository.
+cat <<EOF > "$DATADIR/repos/morphs1/test-system.morph"
+{
+ "name": "test-system",
+ "kind": "system",
+ "system-kind": "syslinux-disk",
+ "arch": "$(uname -m)",
+ "disk-size": "1G",
+ "strata": [
+ {
+ "morph": "stratum1",
+ "repo": "repos:morphs1",
+ "ref": "master"
+ },
+ {
+ "morph": "stratum2",
+ "repo": "repos:morphs2",
+ "ref": "master"
+ }
+ ]
+}
+EOF
+
+# Create stratum that depends on another stratum.
+cat <<EOF > "$DATADIR/repos/morphs1/stratum1.morph"
+{
+ "name": "stratum1",
+ "kind": "stratum",
+ "build-depends": [
+ {
+ "morph": "stratum3",
+ "repo": "repos:morphs2",
+ "ref": "master"
+ }
+ ]
+}
+EOF
+
+# Commit all files to the first repository.
+scripts/run-git-in "$DATADIR/repos/morphs1" init
+scripts/run-git-in "$DATADIR/repos/morphs1" add .
+scripts/run-git-in "$DATADIR/repos/morphs1" commit -m initial
+
+# Create second morphs repository.
+mkdir "$DATADIR/repos/morphs2"
+
+# Create two strata in the second repository.
+cat <<EOF > "$DATADIR/repos/morphs2/stratum2.morph"
+{
+ "name": "stratum2",
+ "kind": "stratum",
+ "build-depends": [
+ {
+ "morph": "stratum3",
+ "repo": "repos:morphs2",
+ "ref": "master"
+ }
+ ]
+}
+EOF
+cat <<EOF > "$DATADIR/repos/morphs2/stratum3.morph"
+{
+ "name": "stratum3",
+ "kind": "stratum"
+}
+EOF
+
+# Commit all files to the second repository.
+"$SRCDIR/scripts/run-git-in" "$DATADIR/repos/morphs2" init
+"$SRCDIR/scripts/run-git-in" "$DATADIR/repos/morphs2" add .
+"$SRCDIR/scripts/run-git-in" "$DATADIR/repos/morphs2" commit -m initial
+
+cd "$DATADIR/workspace"
+"$SRCDIR/scripts/test-morph" init
+
+# Check out the master system branch.
+"$SRCDIR/scripts/test-morph" \
+ --repo-alias=repos="file://$DATADIR/repos/%s#file://$DATADIR/%s" \
+ checkout repos:morphs1 master
+
+# Tag the master system branch.
+"$SRCDIR/scripts/test-morph" \
+ --repo-alias=repos="file://$DATADIR/repos/%s#file://$DATADIR/%s" \
+ tag tag-across-multiple-repos -- -m "create tag"
+
+# Show the tag.
+GIT_DIR="$DATADIR/workspace/master/repos:morphs1/.git" \
+ git show tag-across-multiple-repos
+GIT_DIR="$DATADIR/workspace/master/repos:morphs1/.git" \
+ git log -n1 -p --stat tag-across-multiple-repos
diff --git a/tests.branching/morph-tag-works-with-multiple-morphs-repos.stdout b/tests.branching/morph-tag-works-with-multiple-morphs-repos.stdout
new file mode 100644
index 00000000..91b36f99
--- /dev/null
+++ b/tests.branching/morph-tag-works-with-multiple-morphs-repos.stdout
@@ -0,0 +1,172 @@
+Initialized empty Git repository in TMP/repos/morphs1/.git/
+[master (root-commit) 0bfadac] initial
+ 2 files changed, 30 insertions(+), 0 deletions(-)
+ create mode 100644 stratum1.morph
+ create mode 100644 test-system.morph
+Initialized empty Git repository in TMP/repos/morphs2/.git/
+[master (root-commit) 06b1b9c] initial
+ 2 files changed, 15 insertions(+), 0 deletions(-)
+ create mode 100644 stratum2.morph
+ create mode 100644 stratum3.morph
+tag tag-across-multiple-repos
+Tagger: developer <developer@example.com>
+Date: Tue Jul 31 16:51:54 2012 +0000
+
+create tag
+
+commit 8fe8196c4eb87e5967d7b08eb8da9d7d0764c25d
+Author: developer <developer@example.com>
+Date: Tue Jul 31 16:51:54 2012 +0000
+
+ create tag
+
+diff --git a/stratum1.morph b/stratum1.morph
+index 93a2d04..bf622db 100644
+--- a/stratum1.morph
++++ b/stratum1.morph
+@@ -4,8 +4,10 @@
+ "build-depends": [
+ {
+ "morph": "stratum3",
+- "repo": "repos:morphs2",
+- "ref": "master"
++ "repo": "repos:morphs1",
++ "ref": "tag-across-multiple-repos",
++ "unpetrify-ref": "master",
++ "unpetrify-repo": "repos:morphs2"
+ }
+ ]
+ }
+diff --git a/stratum2.morph b/stratum2.morph
+new file mode 100644
+index 0000000..d27599c
+--- /dev/null
++++ b/stratum2.morph
+@@ -0,0 +1,13 @@
++{
++ "name": "stratum2",
++ "kind": "stratum",
++ "build-depends": [
++ {
++ "morph": "stratum3",
++ "repo": "repos:morphs1",
++ "ref": "tag-across-multiple-repos",
++ "unpetrify-ref": "master",
++ "unpetrify-repo": "repos:morphs2"
++ }
++ ]
++}
+diff --git a/stratum3.morph b/stratum3.morph
+new file mode 100644
+index 0000000..a735127
+--- /dev/null
++++ b/stratum3.morph
+@@ -0,0 +1,4 @@
++{
++ "name": "stratum3",
++ "kind": "stratum"
++}
+diff --git a/test-system.morph b/test-system.morph
+index 27806c1..c5e4d98 100644
+--- a/test-system.morph
++++ b/test-system.morph
+@@ -8,12 +8,15 @@
+ {
+ "morph": "stratum1",
+ "repo": "repos:morphs1",
+- "ref": "master"
++ "ref": "tag-across-multiple-repos",
++ "unpetrify-ref": "master"
+ },
+ {
+ "morph": "stratum2",
+- "repo": "repos:morphs2",
+- "ref": "master"
++ "repo": "repos:morphs1",
++ "ref": "tag-across-multiple-repos",
++ "unpetrify-ref": "master",
++ "unpetrify-repo": "repos:morphs2"
+ }
+ ]
+ }
+commit 8fe8196c4eb87e5967d7b08eb8da9d7d0764c25d
+Author: developer <developer@example.com>
+Date: Tue Jul 31 16:51:54 2012 +0000
+
+ create tag
+---
+ stratum1.morph | 6 ++++--
+ stratum2.morph | 13 +++++++++++++
+ stratum3.morph | 4 ++++
+ test-system.morph | 9 ++++++---
+ 4 files changed, 27 insertions(+), 5 deletions(-)
+
+diff --git a/stratum1.morph b/stratum1.morph
+index 93a2d04..bf622db 100644
+--- a/stratum1.morph
++++ b/stratum1.morph
+@@ -4,8 +4,10 @@
+ "build-depends": [
+ {
+ "morph": "stratum3",
+- "repo": "repos:morphs2",
+- "ref": "master"
++ "repo": "repos:morphs1",
++ "ref": "tag-across-multiple-repos",
++ "unpetrify-ref": "master",
++ "unpetrify-repo": "repos:morphs2"
+ }
+ ]
+ }
+diff --git a/stratum2.morph b/stratum2.morph
+new file mode 100644
+index 0000000..d27599c
+--- /dev/null
++++ b/stratum2.morph
+@@ -0,0 +1,13 @@
++{
++ "name": "stratum2",
++ "kind": "stratum",
++ "build-depends": [
++ {
++ "morph": "stratum3",
++ "repo": "repos:morphs1",
++ "ref": "tag-across-multiple-repos",
++ "unpetrify-ref": "master",
++ "unpetrify-repo": "repos:morphs2"
++ }
++ ]
++}
+diff --git a/stratum3.morph b/stratum3.morph
+new file mode 100644
+index 0000000..a735127
+--- /dev/null
++++ b/stratum3.morph
+@@ -0,0 +1,4 @@
++{
++ "name": "stratum3",
++ "kind": "stratum"
++}
+diff --git a/test-system.morph b/test-system.morph
+index 27806c1..c5e4d98 100644
+--- a/test-system.morph
++++ b/test-system.morph
+@@ -8,12 +8,15 @@
+ {
+ "morph": "stratum1",
+ "repo": "repos:morphs1",
+- "ref": "master"
++ "ref": "tag-across-multiple-repos",
++ "unpetrify-ref": "master"
+ },
+ {
+ "morph": "stratum2",
+- "repo": "repos:morphs2",
+- "ref": "master"
++ "repo": "repos:morphs1",
++ "ref": "tag-across-multiple-repos",
++ "unpetrify-ref": "master",
++ "unpetrify-repo": "repos:morphs2"
+ }
+ ]
+ }