Merge lp:~sergiusens/snapcraft/validation into lp:~snappy-dev/snapcraft/core

Proposed by Sergio Schvezov on 2015-08-25
Status: Merged
Approved by: Leo Arias on 2015-08-27
Approved revision: 151
Merged at revision: 141
Proposed branch: lp:~sergiusens/snapcraft/validation
Merge into: lp:~snappy-dev/snapcraft/core
Prerequisite: lp:~sergiusens/snapcraft/meta-all-yaml
Diff against target: 551 lines (+394/-9)
9 files modified
debian/control (+3/-1)
integration-tests/data/local-plugin/snapcraft.yaml (+1/-0)
schema/snapcraft.yaml (+89/-0)
setup.py (+5/-2)
snapcraft/common.py (+11/-0)
snapcraft/dirs.py (+1/-0)
snapcraft/tests/__init__.py (+1/-0)
snapcraft/tests/test_yaml.py (+251/-6)
snapcraft/yaml.py (+32/-0)
To merge this branch: bzr merge lp:~sergiusens/snapcraft/validation
Reviewer Review Type Date Requested Status
John Lenton Approve on 2015-08-27
Leo Arias (community) 2015-08-25 Approve on 2015-08-27
Review via email: mp+269120@code.launchpad.net

Commit message

Initial json schema support

Description of the change

I'm just not sure where to add the tests here, so please advise during the review :-)

To post a comment you must log in.
Leo Arias (elopio) wrote :

To test, I think you should encapsulate your additions to the Config.__init__ in a validate method. Then we can pass valid and invalid yamls to it.

Some comments in the diff.

Zygmunt Krynicki (zyga) wrote :

One more comment below

Leo Arias (elopio) wrote :

This is great. I left some IMO comments. The only one I feel strong about is splitting the tests in scenarios or subtests.

review: Needs Fixing
Sergio Schvezov (sergiusens) wrote :

Hey you asked me for subtests a lot and I had them added explained as in the documentation you linked to ;-)

Leo Arias (elopio) wrote :

how did I not see the subtests??? Sorry about that.
So the only thing that's missing is bumping the dependency to python3 (>= 3.4),

Leo Arias (elopio) :
review: Approve
John Lenton (chipaca) wrote :

LGTM. zOMGwtfbbqschemajsonyaml, but LGTM.

review: Approve
Leo Arias (elopio) wrote :

Once this lands, the guys from erle and spreed have to be notified about the new format, right?

Snappy Tarmac (snappydevtarmac) wrote :

The attempt to merge lp:~sergiusens/snapcraft/validation into lp:snapcraft failed. Below is the output from the failed tests.

wget -c http://0.0.0.0:39937/test.tar
cp --preserve=all -R zzz /tmp/tmpo5btu74v/parts/copy/install/zzz
cp --preserve=all -R src /tmp/tmpyoazun5w/parts/copy/install/dst
cp --preserve=all -R src /tmp/tmprwukoxqe/parts/copy/install/dir/dst

...........--2015-08-27 19:50:49-- http://0.0.0.0:39937/test.tar
Connecting to 0.0.0.0:39937... connected.
HTTP request sent, awaiting response... 200 OK
Length: 22 [text/html]
Saving to: ‘test.tar’

     0K 100% 4.74M=0s

2015-08-27 19:50:49 (4.74 MB/s) - ‘test.tar’ saved [22/22]

.E.................Warning: unable to find "test_relexepath" in the path
.............E
======================================================================
ERROR: snapcraft.tests.test_cmds (unittest.loader.ModuleImportFailure)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/usr/lib/python3.4/unittest/case.py", line 57, in testPartExecutor
    yield
  File "/usr/lib/python3.4/unittest/case.py", line 574, in run
    testMethod()
  File "/usr/lib/python3.4/unittest/loader.py", line 32, in testFailure
    raise exception
ImportError: Failed to import test module: snapcraft.tests.test_cmds
Traceback (most recent call last):
  File "/usr/lib/python3.4/unittest/loader.py", line 312, in _find_tests
    module = self._get_module_from_name(name)
  File "/usr/lib/python3.4/unittest/loader.py", line 290, in _get_module_from_name
    __import__(name)
  File "/tmp/tarmac/branch.04EPnA/snapcraft/tests/test_cmds.py", line 24, in <module>
    from snapcraft import (
  File "/tmp/tarmac/branch.04EPnA/snapcraft/cmds.py", line 27, in <module>
    import snapcraft.yaml
  File "/tmp/tarmac/branch.04EPnA/snapcraft/yaml.py", line 21, in <module>
    import jsonschema
ImportError: No module named 'jsonschema'

======================================================================
ERROR: snapcraft.tests.test_yaml (unittest.loader.ModuleImportFailure)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/usr/lib/python3.4/unittest/case.py", line 57, in testPartExecutor
    yield
  File "/usr/lib/python3.4/unittest/case.py", line 574, in run
    testMethod()
  File "/usr/lib/python3.4/unittest/loader.py", line 32, in testFailure
    raise exception
ImportError: Failed to import test module: snapcraft.tests.test_yaml
Traceback (most recent call last):
  File "/usr/lib/python3.4/unittest/loader.py", line 312, in _find_tests
    module = self._get_module_from_name(name)
  File "/usr/lib/python3.4/unittest/loader.py", line 290, in _get_module_from_name
    __import__(name)
  File "/tmp/tarmac/branch.04EPnA/snapcraft/tests/test_yaml.py", line 17, in <module>
    import jsonschema
ImportError: No module named 'jsonschema'

----------------------------------------------------------------------
Ran 44 tests in 1.155s

FAILED (errors=2)

Sergio Schvezov (sergiusens) wrote :

On Thu, Aug 27, 2015 at 4:47 PM, Leo Arias <email address hidden> wrote:

> Once this lands, the guys from erle and spreed have to be notified about
> the new format, right?

I've disabled daily builds for now

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'debian/control'
2--- debian/control 2015-08-07 19:49:21 +0000
3+++ debian/control 2015-08-27 16:38:10 +0000
4@@ -11,9 +11,10 @@
5 pep8,
6 plainbox,
7 pyflakes,
8- python3 (>= 3.2),
9+ python3 (>= 3.4),
10 python3-apt,
11 python3-fixtures,
12+ python3-jsonschema,
13 python3-setuptools,
14 python3-testscenarios,
15 python3-yaml,
16@@ -33,6 +34,7 @@
17 dpkg-dev,
18 git,
19 python3-apt,
20+ python3-jsonschema,
21 python3-yaml,
22 mercurial,
23 sudo,
24
25=== modified file 'integration-tests/data/local-plugin/snapcraft.yaml'
26--- integration-tests/data/local-plugin/snapcraft.yaml 2015-08-27 16:38:09 +0000
27+++ integration-tests/data/local-plugin/snapcraft.yaml 2015-08-27 16:38:10 +0000
28@@ -6,3 +6,4 @@
29
30 parts:
31 x-local-plugin:
32+ source: .
33
34=== added directory 'schema'
35=== added file 'schema/snapcraft.yaml'
36--- schema/snapcraft.yaml 1970-01-01 00:00:00 +0000
37+++ schema/snapcraft.yaml 2015-08-27 16:38:10 +0000
38@@ -0,0 +1,89 @@
39+$schema: http://json-schema.org/draft-04/schema#
40+
41+title: snapcraft schema
42+type: object
43+properties:
44+ name:
45+ type: string
46+ description: name of the snap package
47+ pattern: "^[a-z0-9][a-z0-9+-]*$"
48+ version:
49+ # python's defaul yaml loading code loads 1.0 as an int
50+ # type: string
51+ description: package version
52+ pattern: "^[a-zA-Z0-9.+~-]*$"
53+ vendor:
54+ type: string
55+ format: email
56+ summary:
57+ type: string
58+ description: one line summary for the package
59+ maxLength: 78
60+ description:
61+ type: string
62+ description: long description of the package
63+ type:
64+ type: string
65+ description: the snap type, the implicit type is 'app'
66+ enum:
67+ - app
68+ - framework
69+ frameworks:
70+ type: array
71+ minItems: 1
72+ uniqueItems: true
73+ items:
74+ - type: string
75+ services:
76+ type: array
77+ items:
78+ - type: object
79+ properties:
80+ name:
81+ type: string
82+ start:
83+ type: string
84+ description: command executed to start the service
85+ stop:
86+ type: string
87+ description: command executed to stop the service
88+ required:
89+ - name
90+ - start
91+ binaries:
92+ type: array
93+ items:
94+ - type: object
95+ properties:
96+ name:
97+ type: string
98+ exec:
99+ type: string
100+ description: command executed to start the service
101+ required:
102+ - name
103+ parts:
104+ type: object
105+ minProperties: 1
106+ patternProperties:
107+ ^[a-z0-9][a-z0-9+-]*$:
108+ type: object
109+ properties:
110+ plugin:
111+ type: string
112+ description: plugin name
113+ source:
114+ type: string
115+ description: path to the sources
116+ # TODO To be enabled once the Ubuntu plugin is removed
117+ # required:
118+ # - plugin
119+ # - source
120+ addtionalProperties: false
121+required:
122+ - name
123+ - version
124+ - vendor
125+ - summary
126+ - description
127+ - parts
128
129=== modified file 'setup.py'
130--- setup.py 2015-07-06 16:48:22 +0000
131+++ setup.py 2015-08-27 16:38:10 +0000
132@@ -36,6 +36,9 @@
133 'snapcraft.plugins'],
134 package_data={'snapcraft.plugins': ['manifest.txt']},
135 scripts=['bin/snapcraft'],
136- data_files=[('share/snapcraft/plugins', ['plugins/' + x for x in os.listdir('plugins')])],
137+ data_files=[
138+ ('share/snapcraft/plugins', ['plugins/' + x for x in os.listdir('plugins')]),
139+ ('share/snapcraft/schema', ['schema/' + x for x in os.listdir('schema')]),
140+ ],
141 cmdclass={'test': TestCommand},
142-)
143+ )
144
145=== modified file 'snapcraft/common.py'
146--- snapcraft/common.py 2015-08-05 15:39:18 +0000
147+++ snapcraft/common.py 2015-08-27 16:38:10 +0000
148@@ -25,6 +25,8 @@
149 COMMAND_ORDER = ["pull", "build", "stage", "snap"]
150 _DEFAULT_PLUGINDIR = '/usr/share/snapcraft/plugins'
151 _plugindir = _DEFAULT_PLUGINDIR
152+_DEFAULT_SCHEMADIR = '/usr/share/snapcraft/schema'
153+_schemadir = _DEFAULT_SCHEMADIR
154 _arch = None
155 _arch_triplet = None
156
157@@ -79,3 +81,12 @@
158
159 def get_plugindir():
160 return _plugindir
161+
162+
163+def set_schemadir(schemadir):
164+ global _schemadir
165+ _schemadir = schemadir
166+
167+
168+def get_schemadir():
169+ return _schemadir
170
171=== modified file 'snapcraft/dirs.py'
172--- snapcraft/dirs.py 2015-07-23 17:19:25 +0000
173+++ snapcraft/dirs.py 2015-08-27 16:38:10 +0000
174@@ -27,3 +27,4 @@
175 # only change the default if we are running from a checkout
176 if os.path.exists(os.path.join(topdir, "setup.py")):
177 common.set_plugindir(os.path.join(topdir, 'plugins'))
178+ common.set_schemadir(os.path.join(topdir, 'schema'))
179
180=== modified file 'snapcraft/tests/__init__.py'
181--- snapcraft/tests/__init__.py 2015-08-04 15:06:59 +0000
182+++ snapcraft/tests/__init__.py 2015-08-27 16:38:10 +0000
183@@ -32,3 +32,4 @@
184 # is a module variable. Make sure that it is returned to the original
185 # value when a test ends.
186 self.addCleanup(common.set_plugindir, common.get_plugindir())
187+ self.addCleanup(common.set_schemadir, common.get_schemadir())
188
189=== modified file 'snapcraft/tests/test_yaml.py'
190--- snapcraft/tests/test_yaml.py 2015-08-05 18:40:06 +0000
191+++ snapcraft/tests/test_yaml.py 2015-08-27 16:38:10 +0000
192@@ -14,15 +14,18 @@
193 # You should have received a copy of the GNU General Public License
194 # along with this program. If not, see <http://www.gnu.org/licenses/>.
195
196+import jsonschema
197 import logging
198 import os
199 import tempfile
200 import unittest
201+import unittest.mock
202
203 import fixtures
204
205+import snapcraft.common
206+import snapcraft.yaml
207 from snapcraft import dirs
208-from snapcraft.yaml import Config
209 from snapcraft.tests import TestCase
210
211
212@@ -37,11 +40,19 @@
213
214 @unittest.mock.patch('snapcraft.yaml.Config.load_plugin')
215 def test_config_loads_plugins(self, mock_loadPlugin):
216- self.make_snapcraft_yaml("""parts:
217+ dirs.setup_dirs()
218+
219+ self.make_snapcraft_yaml("""name: test
220+version: "1"
221+vendor: me <me@me.com>
222+summary: test
223+description: test
224+
225+parts:
226 ubuntu:
227 packages: [fswebcam]
228 """)
229- Config()
230+ snapcraft.yaml.Config()
231 mock_loadPlugin.assert_called_with("ubuntu", "ubuntu", {
232 "packages": ["fswebcam"],
233 })
234@@ -52,7 +63,7 @@
235
236 # no snapcraft.yaml
237 with self.assertRaises(SystemExit) as raised:
238- Config()
239+ snapcraft.yaml.Config()
240
241 self.assertEqual(raised.exception.code, 1, 'Wrong exit code returned.')
242 self.assertEqual(
243@@ -66,7 +77,13 @@
244 fake_logger = fixtures.FakeLogger(level=logging.ERROR)
245 self.useFixture(fake_logger)
246
247- self.make_snapcraft_yaml("""parts:
248+ self.make_snapcraft_yaml("""name: test
249+version: "1"
250+vendor: me <me@me.com>
251+summary: test
252+description: test
253+
254+parts:
255 p1:
256 plugin: ubuntu
257 after: [p2]
258@@ -75,7 +92,235 @@
259 after: [p1]
260 """)
261 with self.assertRaises(SystemExit) as raised:
262- Config()
263+ snapcraft.yaml.Config()
264
265 self.assertEqual(raised.exception.code, 1, 'Wrong exit code returned.')
266 self.assertEqual('Circular dependency chain!\n', fake_logger.output)
267+
268+ @unittest.mock.patch('snapcraft.yaml.Config.load_plugin')
269+ def test_invalid_yaml_missing_name(self, mock_loadPlugin):
270+ dirs.setup_dirs()
271+
272+ fake_logger = fixtures.FakeLogger(level=logging.ERROR)
273+ self.useFixture(fake_logger)
274+
275+ self.make_snapcraft_yaml("""
276+version: "1"
277+vendor: me <me@me.com>
278+summary: test
279+description: nothing
280+
281+parts:
282+ ubuntu:
283+ packages: [fswebcam]
284+""")
285+ with self.assertRaises(SystemExit) as raised:
286+ snapcraft.yaml.Config()
287+
288+ self.assertEqual(raised.exception.code, 1, 'Wrong exit code returned.')
289+ self.assertEqual(
290+ 'Issues while validating snapcraft.yaml: \'name\' is a required property\n',
291+ fake_logger.output)
292+
293+ @unittest.mock.patch('snapcraft.yaml.Config.load_plugin')
294+ def test_invalid_yaml_invalid_name_as_number(self, mock_loadPlugin):
295+ dirs.setup_dirs()
296+
297+ fake_logger = fixtures.FakeLogger(level=logging.ERROR)
298+ self.useFixture(fake_logger)
299+
300+ self.make_snapcraft_yaml("""name: 1
301+version: "1"
302+vendor: me <me@me.com>
303+summary: test
304+description: nothing
305+
306+parts:
307+ ubuntu:
308+ packages: [fswebcam]
309+""")
310+ with self.assertRaises(SystemExit) as raised:
311+ snapcraft.yaml.Config()
312+
313+ self.assertEqual(raised.exception.code, 1, 'Wrong exit code returned.')
314+ self.assertEqual(
315+ 'Issues while validating snapcraft.yaml: 1 is not of type \'string\'\n',
316+ fake_logger.output)
317+
318+ @unittest.mock.patch('snapcraft.yaml.Config.load_plugin')
319+ def test_invalid_yaml_invalid_name_chars(self, mock_loadPlugin):
320+ dirs.setup_dirs()
321+
322+ fake_logger = fixtures.FakeLogger(level=logging.ERROR)
323+ self.useFixture(fake_logger)
324+
325+ self.make_snapcraft_yaml("""name: myapp@me_1.0
326+version: "1"
327+vendor: me <me@me.com>
328+summary: test
329+description: nothing
330+
331+parts:
332+ ubuntu:
333+ packages: [fswebcam]
334+""")
335+ with self.assertRaises(SystemExit) as raised:
336+ snapcraft.yaml.Config()
337+
338+ self.assertEqual(raised.exception.code, 1, 'Wrong exit code returned.')
339+ self.assertEqual(
340+ 'Issues while validating snapcraft.yaml: \'myapp@me_1.0\' does not match \'^[a-z0-9][a-z0-9+-]*$\'\n',
341+ fake_logger.output)
342+
343+ @unittest.mock.patch('snapcraft.yaml.Config.load_plugin')
344+ def test_invalid_yaml_missing_description(self, mock_loadPlugin):
345+ dirs.setup_dirs()
346+
347+ fake_logger = fixtures.FakeLogger(level=logging.ERROR)
348+ self.useFixture(fake_logger)
349+
350+ self.make_snapcraft_yaml("""name: test
351+version: "1"
352+vendor: me <me@me.com>
353+summary: test
354+
355+parts:
356+ ubuntu:
357+ packages: [fswebcam]
358+""")
359+ with self.assertRaises(SystemExit) as raised:
360+ snapcraft.yaml.Config()
361+
362+ self.assertEqual(raised.exception.code, 1, 'Wrong exit code returned.')
363+ self.assertEqual(
364+ 'Issues while validating snapcraft.yaml: \'description\' is a required property\n',
365+ fake_logger.output)
366+
367+
368+class TestValidation(TestCase):
369+
370+ def setUp(self):
371+ super().setUp()
372+ dirs.setup_dirs()
373+
374+ self.data = {
375+ 'name': 'my-package-1',
376+ 'version': '1.0-snapcraft1~ppa1',
377+ 'vendor': 'Me <me@me.com>',
378+ 'summary': 'my summary less that 79 chars',
379+ 'description': 'description which can be pretty long',
380+ 'parts': {
381+ 'part1': {
382+ 'type': 'project',
383+ },
384+ },
385+ }
386+
387+ def test_required_properties(self):
388+ for key in self.data:
389+ data = self.data.copy()
390+ with self.subTest(key=key):
391+ del data[key]
392+
393+ with self.assertRaises(jsonschema.ValidationError) as raised:
394+ snapcraft.yaml._validate_snapcraft_yaml(data)
395+
396+ expected_message = '\'{}\' is a required property'.format(key)
397+ self.assertEqual(raised.exception.message, expected_message, msg=data)
398+
399+ def test_invalid_names(self):
400+ invalid_names = [
401+ 'package@awesome',
402+ 'something.another',
403+ '_hideme',
404+ ]
405+
406+ for name in invalid_names:
407+ data = self.data.copy()
408+ with self.subTest(key=name):
409+ data['name'] = name
410+
411+ with self.assertRaises(jsonschema.ValidationError) as raised:
412+ snapcraft.yaml._validate_snapcraft_yaml(data)
413+
414+ expected_message = '\'{}\' does not match \'^[a-z0-9][a-z0-9+-]*$\''.format(name)
415+ self.assertEqual(raised.exception.message, expected_message, msg=data)
416+
417+ def test_summary_too_long(self):
418+ self.data['summary'] = 'a' * 80
419+ with self.assertRaises(jsonschema.ValidationError) as raised:
420+ snapcraft.yaml._validate_snapcraft_yaml(self.data)
421+
422+ expected_message = '\'{}\' is too long'.format(self.data['summary'])
423+ self.assertEqual(raised.exception.message, expected_message, msg=self.data)
424+
425+ def test_valid_types(self):
426+ self.data['type'] = 'app'
427+ snapcraft.yaml._validate_snapcraft_yaml(self.data)
428+
429+ self.data['type'] = 'framework'
430+ snapcraft.yaml._validate_snapcraft_yaml(self.data)
431+
432+ def test_invalid_types(self):
433+ invalid_types = [
434+ 'apps',
435+ 'kernel',
436+ 'platform',
437+ 'oem',
438+ 'os',
439+ ]
440+
441+ for t in invalid_types:
442+ data = self.data.copy()
443+ with self.subTest(key=t):
444+ data['type'] = t
445+
446+ with self.assertRaises(jsonschema.ValidationError) as raised:
447+ snapcraft.yaml._validate_snapcraft_yaml(data)
448+
449+ expected_message = '\'{}\' is not one of [\'app\', \'framework\']'.format(t)
450+ self.assertEqual(raised.exception.message, expected_message, msg=data)
451+
452+ def test_valid_services(self):
453+ self.data['services'] = [
454+ {
455+ 'name': 'service1',
456+ 'start': 'binary1 start',
457+ },
458+ {
459+ 'name': 'service2',
460+ 'start': 'binary2',
461+ 'stop': 'binary2 --stop',
462+ },
463+ {
464+ 'name': 'service3',
465+ }
466+ ]
467+
468+ snapcraft.yaml._validate_snapcraft_yaml(self.data)
469+
470+ def test_services_required_properties(self):
471+ self.data['services'] = [
472+ {
473+ 'start': 'binary1 start',
474+ }
475+ ]
476+
477+ with self.assertRaises(jsonschema.ValidationError) as raised:
478+ snapcraft.yaml._validate_snapcraft_yaml(self.data)
479+
480+ expected_message = '\'name\' is a required property'
481+ self.assertEqual(raised.exception.message, expected_message, msg=self.data)
482+
483+ def test_schema_file_not_found(self):
484+ mock_the_open = unittest.mock.mock_open()
485+ mock_the_open.side_effect = FileNotFoundError()
486+
487+ with unittest.mock.patch('snapcraft.yaml.open', mock_the_open, create=True):
488+ with self.assertRaises(snapcraft.yaml.SchemaNotFoundError) as raised:
489+ snapcraft.yaml._validate_snapcraft_yaml(self.data)
490+
491+ expected_path = os.path.join(snapcraft.common.get_schemadir(), 'snapcraft.yaml')
492+ mock_the_open.assert_called_once_with(expected_path)
493+ expected_message = 'Schema is missing, could not validate snapcraft.yaml, check installation'
494+ self.assertEqual(raised.exception.message, expected_message)
495
496=== modified file 'snapcraft/yaml.py'
497--- snapcraft/yaml.py 2015-08-26 08:52:09 +0000
498+++ snapcraft/yaml.py 2015-08-27 16:38:10 +0000
499@@ -18,6 +18,8 @@
500 import sys
501
502 import yaml
503+import jsonschema
504+import os
505
506 import snapcraft.plugin
507 from snapcraft import common
508@@ -26,6 +28,24 @@
509 logger = logging.getLogger(__name__)
510
511
512+class SchemaNotFoundError(Exception):
513+
514+ def __init__(self, message):
515+ self.message = message
516+
517+
518+def _validate_snapcraft_yaml(snapcraft_yaml):
519+ schema_file = os.path.abspath(os.path.join(common.get_schemadir(), 'snapcraft.yaml'))
520+
521+ try:
522+ with open(schema_file) as fp:
523+ schema = yaml.load(fp)
524+ except FileNotFoundError:
525+ raise SchemaNotFoundError('Schema is missing, could not validate snapcraft.yaml, check installation')
526+
527+ jsonschema.validate(snapcraft_yaml, schema)
528+
529+
530 class Config:
531
532 def __init__(self):
533@@ -39,6 +59,18 @@
534 except FileNotFoundError:
535 logger.error("Could not find snapcraft.yaml. Are you sure you're in the right directory?\nTo start a new project, use 'snapcraft init'")
536 sys.exit(1)
537+
538+ # Make sure the loaded snapcraft yaml follows the schema
539+ try:
540+ _validate_snapcraft_yaml(self.data)
541+ except SchemaNotFoundError as e:
542+ logger.error(e.message)
543+ sys.exit(1)
544+ except jsonschema.ValidationError as e:
545+ msg = "Issues while validating snapcraft.yaml: {}".format(e.message)
546+ logger.error(msg)
547+ sys.exit(1)
548+
549 self.build_tools = self.data.get('build-tools', [])
550
551 for part_name in self.data.get("parts", []):

Subscribers

People subscribed via source and target branches