Simplify obtaining version numbers from git.
[cdist.git] / cdist
1 #!/usr/bin/python3
2
3 #    Copyright (C) 2012-2022 Carl Hetherington <cth@carlh.net>
4 #
5 #    This program is free software; you can redistribute it and/or modify
6 #    it under the terms of the GNU General Public License as published by
7 #    the Free Software Foundation; either version 2 of the License, or
8 #    (at your option) any later version.
9 #
10 #    This program is distributed in the hope that it will be useful,
11 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
12 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 #    GNU General Public License for more details.
14
15 #    You should have received a copy of the GNU General Public License
16 #    along with this program; if not, write to the Free Software
17 #    Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
18
19 from __future__ import print_function
20
21 import argparse
22 import copy
23 import datetime
24 import getpass
25 import glob
26 import inspect
27 import multiprocessing
28 import os
29 from pathlib import Path
30 import platform
31 import re
32 import signal
33 import shlex
34 import shutil
35 import subprocess
36 import sys
37 import tempfile
38 import time
39
40 class Error(Exception):
41     def __init__(self, value):
42         self.value = value
43     def __str__(self):
44         return self.value
45     def __repr__(self):
46         return str(self)
47
48 class Trees:
49     """
50     Store for Tree objects which re-uses already-created objects
51     and checks for requests for different versions of the same thing.
52     """
53
54     def __init__(self):
55         self.trees = []
56
57     def get(self, name, commit_ish, target, required_by=None):
58         for t in self.trees:
59             if t.name == name and t.commit_ish == commit_ish and t.target == target:
60                 return t
61             elif t.name == name and t.commit_ish != commit_ish:
62                 a = commit_ish if commit_ish is not None else "[Any]"
63                 if required_by is not None:
64                     a += ' by %s' % required_by
65                 b = t.commit_ish if t.commit_ish is not None else "[Any]"
66                 if t.required_by is not None:
67                     b += ' by %s' % t.required_by
68                 raise Error('conflicting versions of %s required (%s versus %s)' % (name, a, b))
69
70         nt = Tree(name, commit_ish, target, required_by)
71         self.trees.append(nt)
72         return nt
73
74     def add_built(self, name, commit_ish, target):
75         self.trees.append(Tree(name, commit_ish, target, None, built=True))
76
77
78 class Globals:
79     quiet = False
80     command = None
81     dry_run = False
82     use_git_reference = True
83     trees = Trees()
84
85 globals = Globals()
86
87
88 #
89 # Configuration
90 #
91
92 class Option:
93     def __init__(self, key, default=None):
94         self.key = key
95         self.value = default
96
97     def offer(self, key, value):
98         if key == self.key:
99             self.value = value
100
101 class BoolOption:
102     def __init__(self, key):
103         self.key = key
104         self.value = False
105
106     def offer(self, key, value):
107         if key == self.key:
108             self.value = value in ['yes', '1', 'true']
109
110 class Config:
111     def __init__(self):
112         self.options = [ Option('mxe_prefix'),
113                          Option('git_prefix'),
114                          Option('git_reference'),
115                          Option('osx_environment_prefix'),
116                          Option('osx_sdk_prefix'),
117                          Option('osx_sdk'),
118                          Option('osx_intel_deployment'),
119                          Option('osx_arm_deployment'),
120                          Option('osx_old_deployment'),
121                          Option('osx_keychain_file'),
122                          Option('osx_keychain_password'),
123                          Option('apple_id'),
124                          Option('apple_password'),
125                          Option('apple_team_id'),
126                          BoolOption('docker_sudo'),
127                          BoolOption('docker_no_user'),
128                          Option('docker_hub_repository'),
129                          Option('flatpak_state_dir'),
130                          Option('parallel', multiprocessing.cpu_count()),
131                          Option('temp', '/var/tmp'),
132                          Option('osx_notarytool', ['xcrun', 'notarytool'])]
133
134         config_dir = '%s/.config' % os.path.expanduser('~')
135         if not os.path.exists(config_dir):
136             os.mkdir(config_dir)
137         config_file = '%s/cdist' % config_dir
138         if not os.path.exists(config_file):
139             f = open(config_file, 'w')
140             for o in self.options:
141                 print('# %s ' % o.key, file=f)
142             f.close()
143             print('Template config file written to %s; please edit and try again.' % config_file, file=sys.stderr)
144             sys.exit(1)
145
146         f = open('%s/.config/cdist' % os.path.expanduser('~'), 'r')
147         while True:
148             l = f.readline()
149             if l == '':
150                 break
151
152             if len(l) > 0 and l[0] == '#':
153                 continue
154
155             s = l.strip().split()
156             if len(s) == 2:
157                 for k in self.options:
158                     k.offer(s[0], s[1])
159
160         if not isinstance(self.get('osx_notarytool'), list):
161             self.set('osx_notarytool', [self.get('osx_notarytool')])
162
163
164     def has(self, k):
165         for o in self.options:
166             if o.key == k and o.value is not None:
167                 return True
168         return False
169
170     def get(self, k):
171         for o in self.options:
172             if o.key == k:
173                 if o.value is None:
174                     raise Error('Required setting %s not found' % k)
175                 return o.value
176
177     def set(self, k, v):
178         for o in self.options:
179             o.offer(k, v)
180
181     def docker(self):
182         if self.get('docker_sudo'):
183             return 'sudo docker'
184         else:
185             return 'docker'
186
187 config = Config()
188
189 #
190 # Utility bits
191 #
192
193 def log_normal(m):
194     if not globals.quiet:
195         print('\x1b[33m* %s\x1b[0m' % m)
196
197 def log_verbose(m):
198     if globals.verbose:
199         print('\x1b[35m* %s\x1b[0m' % m)
200
201 def escape_spaces(s):
202     return s.replace(' ', '\\ ')
203
204 def scp_escape(n):
205     """Escape a host:filename string for use with an scp command"""
206     s = n.split(':')
207     assert(len(s) == 1 or len(s) == 2)
208     if len(s) == 2:
209         return '%s:"\'%s\'"' % (s[0], s[1])
210     else:
211         return '\"%s\"' % s[0]
212
213 def mv_escape(n):
214     return '\"%s\"' % n.substr(' ', '\\ ')
215
216 def copytree(a, b):
217     log_normal('copy %s -> %s' % (scp_escape(a), scp_escape(b)))
218     if b.startswith('s3://'):
219         command('s3cmd -P -r put "%s" "%s"' % (a, b))
220     else:
221         command('scp -r %s %s' % (scp_escape(a), scp_escape(b)))
222
223 def copyfile(a, b):
224     log_normal('copy %s -> %s with cwd %s' % (scp_escape(a), scp_escape(b), os.getcwd()))
225     if b.startswith('s3://'):
226         command('s3cmd -P put "%s" "%s"' % (a, b))
227     else:
228         bc = b.find(":")
229         if bc != -1:
230             host = b[:bc]
231             path = b[bc+1:]
232             temp_path = os.path.join(os.path.dirname(path), ".tmp." + os.path.basename(path))
233             command('scp %s %s' % (scp_escape(a), scp_escape(host + ":" + temp_path)))
234             command('ssh %s -- mv "%s" "%s"' % (host, escape_spaces(temp_path), escape_spaces(path)))
235         else:
236             command('scp %s %s' % (scp_escape(a), scp_escape(b)))
237
238 def makedirs(d):
239     """
240     Make directories either locally or on a remote host; remotely if
241     d includes a colon, otherwise locally.
242     """
243     if d.startswith('s3://'):
244         # No need to create folders on S3
245         return
246
247     if d.find(':') == -1:
248         try:
249             os.makedirs(d)
250         except OSError as e:
251             if e.errno != 17:
252                 raise e
253     else:
254         s = d.split(':')
255         command('ssh %s -- mkdir -p %s' % (s[0], s[1]))
256
257 def rmdir(a):
258     log_normal('remove %s' % a)
259     os.rmdir(a)
260
261 def rmtree(a):
262     log_normal('remove %s' % a)
263     shutil.rmtree(a, ignore_errors=True)
264
265 def command(c):
266     log_normal(c)
267     try:
268         r = subprocess.run(c, shell=True)
269         if r.returncode != 0:
270             raise Error('command %s failed (%d)' % (c, r.returncode))
271     except Exception as e:
272         raise Error('command %s failed (%s)' % (c, e))
273
274 def command_and_read(c):
275     log_normal(c)
276     p = subprocess.Popen(c.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
277     (out, err) = p.communicate()
278     if p.returncode != 0:
279         raise Error('command %s failed (%s)' % (c, err))
280     return str(out, 'utf-8').splitlines()
281
282 def read_wscript_variable(directory, variable):
283     f = open('%s/wscript' % directory, 'r')
284     while True:
285         l = f.readline()
286         if l == '':
287             break
288
289         s = l.split()
290         if len(s) == 3 and s[0] == variable:
291             f.close()
292             return s[2][1:-1]
293
294     f.close()
295     return None
296
297
298 def devel_to_git(git_commit, filename):
299     if git_commit is not None:
300         filename = filename.replace('devel', '-%s' % git_commit)
301     return filename
302
303
304 def get_command_line_options(args):
305     """Get the options specified by --option on the command line"""
306     options = dict()
307     if args.option is not None:
308         for o in args.option:
309             b = o.split(':')
310             if len(b) != 2:
311                 raise Error("Bad option `%s'" % o)
312             if b[1] == 'False':
313                 options[b[0]] = False
314             elif b[1] == 'True':
315                 options[b[0]] = True
316             else:
317                 options[b[0]] = b[1]
318     return options
319
320
321 class TreeDirectory:
322     def __init__(self, tree):
323         self.tree = tree
324     def __enter__(self):
325         self.cwd = os.getcwd()
326         os.chdir('%s/src/%s' % (self.tree.target.directory, self.tree.name))
327     def __exit__(self, type, value, traceback):
328         os.chdir(self.cwd)
329
330 #
331 # Version
332 #
333
334 class Version:
335     def __init__(self, s):
336         self.devel = False
337
338         if s.startswith("'"):
339             s = s[1:]
340         if s.endswith("'"):
341             s = s[0:-1]
342
343         if s.endswith('devel'):
344             s = s[0:-5]
345             self.devel = True
346
347         if s.endswith('pre'):
348             s = s[0:-3]
349
350         p = s.split('.')
351         self.major = int(p[0])
352         self.minor = int(p[1])
353         if len(p) == 3:
354             self.micro = int(p[2])
355         else:
356             self.micro = 0
357
358     @classmethod
359     def from_git_tag(cls, tag):
360         bits = tag.split('-')
361         c = cls(bits[0])
362         if len(bits) > 1 and int(bits[1]) > 0:
363             c.devel = True
364         return c
365
366     def bump_minor(self):
367         self.minor += 1
368         self.micro = 0
369
370     def bump_micro(self):
371         self.micro += 1
372
373     def to_devel(self):
374         self.devel = True
375
376     def to_release(self):
377         self.devel = False
378
379     def __str__(self):
380         s = '%d.%d.%d' % (self.major, self.minor, self.micro)
381         if self.devel:
382             s += 'devel'
383
384         return s
385
386 #
387 # Targets
388 #
389
390 class Target:
391     """
392     Class representing the target that we are building for.  This is exposed to cscripts,
393     though not all of it is guaranteed 'API'.  cscripts may expect:
394
395     platform: platform string (e.g. 'windows', 'linux', 'osx')
396     parallel: number of parallel jobs to run
397     directory: directory to work in
398     variables: dict of environment variables
399     debug: True to build a debug version, otherwise False
400     ccache: True to use ccache, False to not
401     set(a, b): set the value of variable 'a' to 'b'
402     unset(a): unset the value of variable 'a'
403     command(c): run the command 'c' in the build environment
404
405     """
406
407     def __init__(self, platform, directory=None):
408         """
409         platform -- platform string (e.g. 'windows', 'linux', 'osx')
410         directory -- directory to work in; if None we will use a temporary directory
411         Temporary directories will be removed after use; specified directories will not.
412         """
413         self.platform = platform
414         self.parallel = int(config.get('parallel'))
415
416         # Environment variables that we will use when we call cscripts
417         self.variables = {}
418         self.debug = False
419         self._ccache = False
420         # True to build our dependencies ourselves; False if this is taken care
421         # of in some other way
422         self.build_dependencies = True
423
424         if directory is None:
425             try:
426                 os.makedirs(config.get('temp'))
427             except OSError as e:
428                 if e.errno != 17:
429                     raise e
430             self.directory = tempfile.mkdtemp('', 'tmp', config.get('temp'))
431             self.rmdir = True
432             self.set('CCACHE_BASEDIR', os.path.realpath(self.directory))
433             self.set('CCACHE_NOHASHDIR', '')
434         else:
435             self.directory = os.path.realpath(directory)
436             self.rmdir = False
437
438
439     def setup(self):
440         pass
441
442     def _cscript_package(self, tree, options):
443         """
444         Call package() in the cscript and return what it returns, except that
445         anything not in a list will be put into one.
446         """
447         if len(inspect.getfullargspec(tree.cscript['package']).args) == 3:
448             packages = tree.call('package', tree.version, options)
449         else:
450             log_normal("Deprecated cscript package() method with no options parameter")
451             packages = tree.call('package', tree.version)
452
453         return packages if isinstance(packages, list) else [packages]
454
455     def _copy_packages(self, tree, packages, output_dir):
456         for p in packages:
457             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, p))))
458
459     def package(self, project, checkout, output_dir, options, notarize):
460         tree = self.build(project, checkout, options, for_package=True)
461         tree.add_defaults(options)
462         p = self._cscript_package(tree, options)
463         self._copy_packages(tree, p, output_dir)
464
465     def build(self, project, checkout, options, for_package=False):
466         tree = globals.trees.get(project, checkout, self)
467         if self.build_dependencies:
468             tree.build_dependencies(options)
469         tree.build(options, for_package=for_package)
470         return tree
471
472     def test(self, project, checkout, target, test, options):
473         """test is the test case to run, or None"""
474         tree = globals.trees.get(project, checkout, target)
475
476         tree.add_defaults(options)
477         with TreeDirectory(tree):
478             if len(inspect.getfullargspec(tree.cscript['test']).args) == 3:
479                 return tree.call('test', options, test)
480             else:
481                 log_normal('Deprecated cscript test() method with no options parameter')
482                 return tree.call('test', test)
483
484     def set(self, a, b):
485         self.variables[a] = b
486
487     def unset(self, a):
488         del(self.variables[a])
489
490     def get(self, a):
491         return self.variables[a]
492
493     def append(self, k, v, s):
494         if (not k in self.variables) or len(self.variables[k]) == 0:
495             self.variables[k] = '"%s"' % v
496         else:
497             e = self.variables[k]
498             if e[0] == '"' and e[-1] == '"':
499                 self.variables[k] = '"%s%s%s"' % (e[1:-1], s, v)
500             else:
501                 self.variables[k] = '"%s%s%s"' % (e, s, v)
502
503     def append_with_space(self, k, v):
504         return self.append(k, v, ' ')
505
506     def append_with_colon(self, k, v):
507         return self.append(k, v, ':')
508
509     def variables_string(self, escaped_quotes=False):
510         e = ''
511         for k, v in self.variables.items():
512             if escaped_quotes:
513                 v = v.replace('"', '\\"')
514             e += '%s=%s ' % (k, v)
515         return e
516
517     def cleanup(self):
518         if self.rmdir:
519             rmtree(self.directory)
520
521     def mount(self, m):
522         pass
523
524     @property
525     def ccache(self):
526         return self._ccache
527
528     @ccache.setter
529     def ccache(self, v):
530         self._ccache = v
531
532
533 class DockerTarget(Target):
534     def __init__(self, platform, directory):
535         super(DockerTarget, self).__init__(platform, directory)
536         self.mounts = []
537         self.privileged = False
538
539     def _user_tag(self):
540         if config.get('docker_no_user'):
541             return ''
542         return '-u %s' % getpass.getuser()
543
544     def _mount_option(self, d):
545         return '-v %s:%s ' % (os.path.realpath(d), os.path.realpath(d))
546
547     def setup(self):
548         opts = self._mount_option(self.directory)
549         for m in self.mounts:
550             opts += self._mount_option(m)
551         if config.has('git_reference'):
552             opts += self._mount_option(config.get('git_reference'))
553         if self.privileged:
554             opts += '--privileged=true '
555         if self.ccache:
556             opts += "-e CCACHE_DIR=/ccache/%s-%d --mount source=ccache,target=/ccache " % (self.image, os.getuid())
557         opts += "--rm "
558
559         tag = self.image
560         if config.has('docker_hub_repository'):
561             tag = '%s:%s' % (config.get('docker_hub_repository'), tag)
562
563         def signal_handler(signum, frame):
564             raise Error('Killed')
565         signal.signal(signal.SIGTERM, signal_handler)
566
567         self.container = command_and_read('%s run %s %s -itd %s /bin/bash' % (config.docker(), self._user_tag(), opts, tag))[0].strip()
568
569     def command(self, cmd):
570         dir = os.path.join(self.directory, os.path.relpath(os.getcwd(), self.directory))
571         interactive_flag = '-i ' if sys.stdin.isatty() else ''
572         command('%s exec %s %s -t %s /bin/bash -c \'export %s; cd %s; %s\'' % (config.docker(), self._user_tag(), interactive_flag, self.container, self.variables_string(), dir, cmd))
573
574     def cleanup(self):
575         super(DockerTarget, self).cleanup()
576         command('%s kill %s' % (config.docker(), self.container))
577
578     def mount(self, m):
579         self.mounts.append(m)
580
581
582 class WindowsDockerTarget(DockerTarget):
583     """
584     This target exposes the following additional API:
585
586     bits: bitness of Windows (32 or 64)
587     name: name of our target e.g. x86_64-w64-mingw32.shared
588     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
589     tool_path: path to 32- and 64-bit tools
590     """
591     def __init__(self, bits, directory, environment_version):
592         super(WindowsDockerTarget, self).__init__('windows', directory)
593         self.bits = bits
594
595         self.tool_path = '%s/usr/bin' % config.get('mxe_prefix')
596         if self.bits == 32:
597             self.name = 'i686-w64-mingw32.shared'
598         else:
599             self.name = 'x86_64-w64-mingw32.shared'
600         self.environment_prefix = '%s/usr/%s' % (config.get('mxe_prefix'), self.name)
601
602         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.environment_prefix)
603         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
604         self.set('PATH', '%s/bin:%s:%s' % (self.environment_prefix, self.tool_path, os.environ['PATH']))
605         self.set('LD', '%s-ld' % self.name)
606         self.set('RANLIB', '%s-ranlib' % self.name)
607         self.set('WINRC', '%s-windres' % self.name)
608         cxx = '-I%s/include -I%s/include' % (self.environment_prefix, self.directory)
609         link = '-L%s/lib -L%s/lib' % (self.environment_prefix, self.directory)
610         self.set('CXXFLAGS', '"%s"' % cxx)
611         self.set('CPPFLAGS', '')
612         self.set('LINKFLAGS', '"%s"' % link)
613         self.set('LDFLAGS', '"%s"' % link)
614
615         self.image = 'windows'
616         if environment_version is not None:
617             self.image += '_%s' % environment_version
618
619     def setup(self):
620         super().setup()
621         if self.ccache:
622             self.set('CC', '"ccache %s-gcc"' % self.name)
623             self.set('CXX', '"ccache %s-g++"' % self.name)
624         else:
625             self.set('CC', '%s-gcc' % self.name)
626             self.set('CXX', '%s-g++' % self.name)
627
628     @property
629     def library_prefix(self):
630         log_normal('Deprecated property library_prefix: use environment_prefix')
631         return self.environment_prefix
632
633     @property
634     def windows_prefix(self):
635         log_normal('Deprecated property windows_prefix: use environment_prefix')
636         return self.environment_prefix
637
638     @property
639     def mingw_prefixes(self):
640         log_normal('Deprecated property mingw_prefixes: use environment_prefix')
641         return [self.environment_prefix]
642
643     @property
644     def mingw_path(self):
645         log_normal('Deprecated property mingw_path: use tool_path')
646         return self.tool_path
647
648     @property
649     def mingw_name(self):
650         log_normal('Deprecated property mingw_name: use name')
651         return self.name
652
653
654 class WindowsNativeTarget(Target):
655     """
656     This target exposes the following additional API:
657
658     bits: bitness of Windows (32 or 64)
659     name: name of our target e.g. x86_64-w64-mingw32.shared
660     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
661     """
662     def __init__(self, directory):
663         super().__init__('windows', directory)
664         self.bits = 64
665
666         self.environment_prefix = config.get('windows_native_environmnet_prefix')
667
668         self.set('PATH', '%s/bin:%s' % (self.environment_prefix, os.environ['PATH']))
669
670     def command(self, cmd):
671         command(cmd)
672
673
674 class LinuxTarget(DockerTarget):
675     """
676     Build for Linux in a docker container.
677     This target exposes the following additional API:
678
679     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
680     version: distribution version (e.g. '12.04', '8', '6.5')
681     bits: bitness of the distribution (32 or 64)
682     detail: None or 'appimage' if we are building for appimage
683     """
684
685     def __init__(self, distro, version, bits, directory=None):
686         super(LinuxTarget, self).__init__('linux', directory)
687         self.distro = distro
688         self.version = version
689         self.bits = bits
690         self.detail = None
691
692         self.set('CXXFLAGS', '-I%s/include' % self.directory)
693         self.set('CPPFLAGS', '')
694         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
695         self.set('PKG_CONFIG_PATH',
696                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
697         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
698
699         if self.version is None:
700             self.image = '%s-%s' % (self.distro, self.bits)
701         else:
702             self.image = '%s-%s-%s' % (self.distro, self.version, self.bits)
703
704     def setup(self):
705         super(LinuxTarget, self).setup()
706         if self.ccache:
707             self.set('CC', '"ccache gcc"')
708             self.set('CXX', '"ccache g++"')
709
710     def test(self, project, checkout, target, test, options):
711         self.append_with_colon('PATH', '%s/bin' % self.directory)
712         self.append_with_colon('LD_LIBRARY_PATH', '%s/lib' % self.directory)
713         super(LinuxTarget, self).test(project, checkout, target, test, options)
714
715
716 class AppImageTarget(LinuxTarget):
717     def __init__(self, work):
718         super(AppImageTarget, self).__init__('ubuntu', '18.04', 64, work)
719         self.detail = 'appimage'
720         self.privileged = True
721
722
723 class FlatpakTarget(Target):
724     def __init__(self, project, checkout, work):
725         super(FlatpakTarget, self).__init__('flatpak')
726         self.build_dependencies = False
727         self.project = project
728         self.checkout = checkout
729         # If we use git references we end up with a checkout in one mount trying
730         # to link to the git reference repo in other, which doesn't work.
731         globals.use_git_reference = False
732         if config.has('flatpak_state_dir'):
733             self.mount(config.get('flatpak_state_dir'))
734
735     def command(self, c):
736         log_normal('host -> %s' % c)
737         command('%s %s' % (self.variables_string(), c))
738
739     def setup(self):
740         super().setup()
741         globals.trees.get(self.project, self.checkout, self).checkout_dependencies()
742
743     def flatpak(self):
744         return 'flatpak'
745
746     def flatpak_builder(self):
747         b = 'flatpak-builder'
748         if config.has('flatpak_state_dir'):
749             b += ' --state-dir=%s' % config.get('flatpak_state_dir')
750         return b
751
752
753 def notarize_dmg(dmg):
754     p = subprocess.run(
755             config.get('osx_notarytool') + [
756             'submit',
757             '--apple-id',
758             config.get('apple_id'),
759             '--password',
760             config.get('apple_password'),
761             '--team-id',
762             config.get('apple_team_id'),
763             '--wait',
764             dmg
765         ], capture_output=True)
766
767     last_line = [x.strip() for x in p.stdout.decode('utf-8').splitlines() if x.strip()][-1]
768     if last_line != 'status: Accepted':
769         print("Could not understand notarytool response")
770         print(p)
771         print(f"Last line: {last_line}")
772         raise Error('Notarization failed')
773
774     subprocess.run(['xcrun', 'stapler', 'staple', dmg])
775
776
777 class OSXTarget(Target):
778     def __init__(self, directory=None):
779         super(OSXTarget, self).__init__('osx', directory)
780         self.sdk_prefix = config.get('osx_sdk_prefix')
781         self.environment_prefix = config.get('osx_environment_prefix')
782         self.apple_id = config.get('apple_id')
783         self.apple_password = config.get('apple_password')
784         self.osx_keychain_file = config.get('osx_keychain_file')
785         self.osx_keychain_password = config.get('osx_keychain_password')
786
787     def command(self, c):
788         command('%s %s' % (self.variables_string(False), c))
789
790     def unlock_keychain(self):
791         self.command('security unlock-keychain -p %s %s' % (self.osx_keychain_password, self.osx_keychain_file))
792
793     def package(self, project, checkout, output_dir, options, notarize):
794         self.unlock_keychain()
795         tree = globals.trees.get(project, checkout, self)
796         with TreeDirectory(tree):
797             p = self._cscript_package_and_notarize(tree, options, self.can_notarize and notarize)
798             self._copy_packages(tree, p, output_dir)
799
800     def _copy_packages(self, tree, packages, output_dir):
801         for p in packages:
802             dest = os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, p)))
803             copyfile(p, dest)
804
805     def _cscript_package_and_notarize(self, tree, options, notarize):
806         """
807         Call package() in the cscript and notarize the .dmgs that are returned, if notarize == True
808         """
809         output = []
810         for x in self._cscript_package(tree, options):
811             # Some older cscripts give us the DMG filename and the bundle ID, even though
812             # (since using notarytool instead of altool for notarization) the bundle ID
813             # is no longer necessary.  Cope with either type of cscript.
814             dmg = x[0] if isinstance(x, tuple) else x
815             if notarize:
816                 notarize_dmg(dmg)
817             output.append(dmg)
818         return output
819
820
821 class OSXSingleTarget(OSXTarget):
822     def __init__(self, arch, sdk, deployment, directory=None, can_notarize=True):
823         super(OSXSingleTarget, self).__init__(directory)
824         self.arch = arch
825         self.sdk = sdk
826         self.deployment = deployment
827         self.can_notarize = can_notarize
828         self.sub_targets = [self]
829
830         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, sdk, arch)
831         if arch == 'x86_64':
832             host_enviro = '%s/x86_64/%s' % (config.get('osx_environment_prefix'), deployment)
833         else:
834             host_enviro = '%s/x86_64/10.10' % config.get('osx_environment_prefix')
835         target_enviro = '%s/%s/%s' % (config.get('osx_environment_prefix'), arch, deployment)
836
837         self.bin = '%s/bin' % target_enviro
838
839         # Environment variables
840         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, target_enviro, flags))
841         self.set('CPPFLAGS', '')
842         self.set('CXXFLAGS', '"-I%s/include -I%s/include -stdlib=libc++ %s"' % (self.directory, target_enviro, flags))
843         self.set('LDFLAGS', '"-L%s/lib -L%s/lib -stdlib=libc++ %s"' % (self.directory, target_enviro, flags))
844         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, target_enviro, flags))
845         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, target_enviro))
846         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % host_enviro)
847         self.set('MACOSX_DEPLOYMENT_TARGET', self.deployment)
848         self.set('CCACHE_BASEDIR', self.directory)
849
850     @Target.ccache.setter
851     def ccache(self, v):
852         Target.ccache.fset(self, v)
853         if v:
854             self.set('CC', '"ccache gcc"')
855             self.set('CXX', '"ccache g++"')
856
857     def package(self, project, checkout, output_dir, options, notarize):
858         tree = self.build(project, checkout, options, for_package=True)
859         tree.add_defaults(options)
860
861         super().package(project, checkout, output_dir, options, notarize)
862
863
864 class OSXUniversalTarget(OSXTarget):
865     def __init__(self, directory=None):
866         super(OSXUniversalTarget, self).__init__(directory)
867         self.sdk = config.get('osx_sdk')
868         self.sub_targets = []
869         for arch, deployment in (('x86_64', config.get('osx_intel_deployment')), ('arm64', config.get('osx_arm_deployment'))):
870             target = OSXSingleTarget(arch, self.sdk, deployment, os.path.join(self.directory, arch, deployment))
871             target.ccache = self.ccache
872             self.sub_targets.append(target)
873         self.can_notarize = True
874
875     def package(self, project, checkout, output_dir, options, notarize):
876         for target in self.sub_targets:
877             tree = globals.trees.get(project, checkout, target)
878             tree.build_dependencies(options)
879             tree.build(options, for_package=True)
880
881         super().package(project, checkout, output_dir, options, notarize)
882
883     @Target.ccache.setter
884     def ccache(self, v):
885         for target in self.sub_targets:
886             target.ccache = v
887
888
889 class SourceTarget(Target):
890     """Build a source .tar.bz2 and .zst"""
891     def __init__(self):
892         super(SourceTarget, self).__init__('source')
893
894     def command(self, c):
895         log_normal('host -> %s' % c)
896         command('%s %s' % (self.variables_string(), c))
897
898     def cleanup(self):
899         rmtree(self.directory)
900
901     def package(self, project, checkout, output_dir, options, notarize):
902         tree = globals.trees.get(project, checkout, self)
903         with TreeDirectory(tree):
904             name = read_wscript_variable(os.getcwd(), 'APPNAME')
905             command('./waf dist')
906             bz2 = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
907             copyfile(bz2, os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, bz2))))
908             command('tar xjf %s' % bz2)
909             command('tar --zstd -cf %s-%s.tar.zst %s-%s' % (name, tree.version, name, tree.version))
910             zstd = os.path.abspath('%s-%s.tar.zst' % (name, tree.version))
911             copyfile(zstd, os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, zstd))))
912
913
914 class LocalTarget(Target):
915     """Build on the local machine with its environment"""
916     def __init__(self, work):
917         super(LocalTarget, self).__init__('local', work)
918         # Hack around ffmpeg.git which sees that the target isn't windows/osx and then assumes
919         # distro will be there.
920         self.distro = None
921         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
922
923     def command(self, c):
924         log_normal('host -> %s' % c)
925         command('%s %s' % (self.variables_string(), c))
926
927     def cleanup(self):
928         rmtree(self.directory)
929
930 # @param s Target string:
931 #       windows-{32,64}
932 #    or ubuntu-version-{32,64}
933 #    or debian-version-{32,64}
934 #    or centos-version-{32,64}
935 #    or fedora-version-{32,64}
936 #    or mageia-version-{32,64}
937 #    or osx
938 #    or source
939 #    or flatpak
940 #    or appimage
941 def target_factory(args):
942     s = args.target
943     target = None
944     if s.startswith('windows-'):
945         x = s.split('-')
946         if platform.system() == "Windows":
947             target = WindowsNativeTarget(args.work)
948         elif len(x) == 2:
949             target = WindowsDockerTarget(int(x[1]), args.work, args.environment_version)
950         else:
951             raise Error("Bad Windows target name `%s'")
952     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
953         p = s.split('-')
954         if len(p) != 3:
955             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
956         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
957     elif s.startswith('arch-'):
958         p = s.split('-')
959         if len(p) != 2:
960             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
961         target = LinuxTarget(p[0], None, int(p[1]), args.work)
962     elif s == 'raspbian':
963         target = LinuxTarget(s, None, None, args.work)
964     elif s == 'osx':
965         target = OSXUniversalTarget(args.work)
966     elif s == 'osx-intel':
967         target = OSXSingleTarget('x86_64', config.get('osx_sdk'), config.get('osx_intel_deployment'), args.work)
968     elif s == 'osx-old':
969         target = OSXSingleTarget('x86_64', config.get('osx_sdk'), config.get('osx_old_deployment'), args.work, False)
970     elif s == 'source':
971         target = SourceTarget()
972     elif s == 'flatpak':
973         target = FlatpakTarget(args.project, args.checkout, args.work)
974     elif s == 'appimage':
975         target = AppImageTarget(args.work)
976     elif s == 'local':
977         target = LocalTarget(args.work)
978
979     if target is None:
980         raise Error("Bad target `%s'" % s)
981
982     target.debug = args.debug
983     target.ccache = args.ccache
984
985     if args.environment is not None:
986         for e in args.environment:
987             target.set(e, os.environ[e])
988
989     if args.mount is not None:
990         for m in args.mount:
991             target.mount(m)
992
993     target.setup()
994     return target
995
996
997 #
998 # Tree
999 #
1000
1001 class Tree:
1002     """Description of a tree, which is a checkout of a project,
1003        possibly built.  This class is never exposed to cscripts.
1004        Attributes:
1005            name -- name of git repository (without the .git)
1006            commit_ish -- git tag or revision to use
1007            target -- target object that we are using
1008            version -- version from the wscript (if one is present)
1009            commit -- git revision that is actually being used
1010            built -- true if the tree has been built yet in this run
1011            required_by -- name of the tree that requires this one
1012     """
1013
1014     def __init__(self, name, commit_ish, target, required_by, built=False):
1015         self.name = name
1016         self.commit_ish = commit_ish
1017         self.target = target
1018         self.version = None
1019         self.commit = None
1020         self.built = built
1021         self.required_by = required_by
1022
1023         cwd = os.getcwd()
1024         proj = '%s/src/%s' % (target.directory, self.name)
1025
1026         if not built:
1027             flags = ''
1028             redirect = ''
1029             if globals.quiet:
1030                 flags = '-q'
1031                 redirect = '>/dev/null'
1032             if config.has('git_reference') and globals.use_git_reference:
1033                 ref = '--reference-if-able %s/%s.git' % (config.get('git_reference'), self.name)
1034             else:
1035                 ref = ''
1036             command('git -c protocol.file.allow=always clone %s %s %s/%s.git %s/src/%s' % (flags, ref, config.get('git_prefix'), self.name, target.directory, self.name))
1037             os.chdir('%s/src/%s' % (target.directory, self.name))
1038
1039             if self.commit_ish is not None:
1040                 command('git checkout %s %s %s' % (flags, self.commit_ish, redirect))
1041             self.commit = command_and_read('git rev-parse --short=7 HEAD')[0].strip()
1042
1043         self.cscript = {}
1044         exec(open('%s/cscript' % proj).read(), self.cscript)
1045
1046         if not built:
1047             # cscript can include submodules = False to stop submodules being fetched
1048             if (not 'submodules' in self.cscript or self.cscript['submodules'] == True) and os.path.exists('.gitmodules'):
1049                 command('git submodule --quiet init')
1050                 paths = command_and_read('git config --file .gitmodules --get-regexp path')
1051                 urls = command_and_read('git config --file .gitmodules --get-regexp url')
1052                 for path, url in zip(paths, urls):
1053                     ref = ''
1054                     if config.has('git_reference') and globals.use_git_reference:
1055                         url = url.split(' ')[1]
1056                         ref_path = os.path.join(config.get('git_reference'), os.path.basename(url))
1057                         if os.path.exists(ref_path):
1058                             ref = '--reference %s' % ref_path
1059                     path = path.split(' ')[1]
1060                     command('git -c protocol.file.allow=always submodule --quiet update %s %s' % (ref, path))
1061
1062         if os.path.exists('%s/wscript' % proj):
1063             v = read_wscript_variable(proj, "VERSION");
1064             if v is not None:
1065                 try:
1066                     self.version = Version(v)
1067                 except:
1068                     try:
1069                         tag = command_and_read('git -C %s describe --match v* --tags' % proj)[0][1:]
1070                         self.version = Version.from_git_tag(tag)
1071                     except:
1072                         # We'll leave version as None if we can't read it; maybe this is a bad idea
1073                         # Should probably just install git on the Windows VM
1074                         pass
1075
1076         os.chdir(cwd)
1077
1078     def call(self, function, *args):
1079         with TreeDirectory(self):
1080             return self.cscript[function](self.target, *args)
1081
1082     def add_defaults(self, options):
1083         """Add the defaults from self into a dict options"""
1084         if 'option_defaults' in self.cscript:
1085             from_cscript = self.cscript['option_defaults']
1086             if isinstance(from_cscript, dict):
1087                 defaults_dict = from_cscript
1088             else:
1089                 log_normal("Deprecated cscript option_defaults method; replace with a dict")
1090                 defaults_dict = from_cscript()
1091             for k, v in defaults_dict.items():
1092                 if not k in options:
1093                     options[k] = v
1094
1095     def dependencies(self, options):
1096         """
1097         yield details of the dependencies of this tree.  Each dependency is returned
1098         as a tuple of (tree, options, parent_tree).  The 'options' parameter are the options that
1099         we want to force for 'self'.
1100         """
1101         if not 'dependencies' in self.cscript:
1102             return
1103
1104         if len(inspect.getfullargspec(self.cscript['dependencies']).args) == 2:
1105             self_options = copy.copy(options)
1106             self.add_defaults(self_options)
1107             deps = self.call('dependencies', self_options)
1108         else:
1109             log_normal("Deprecated cscript dependencies() method with no options parameter")
1110             deps = self.call('dependencies')
1111
1112         # Loop over our immediate dependencies
1113         for d in deps:
1114             dep = globals.trees.get(d[0], d[1], self.target, self.name)
1115
1116             # deps only get their options from the parent's cscript
1117             dep_options = d[2] if len(d) > 2 else {}
1118             for i in dep.dependencies(dep_options):
1119                 yield i
1120             yield (dep, dep_options, self)
1121
1122     def checkout_dependencies(self, options={}):
1123         for i in self.dependencies(options):
1124             pass
1125
1126     def build_dependencies(self, options):
1127         """
1128         Called on the 'main' project tree (-p on the command line) to build all dependencies.
1129         'options' will be the ones from the command line.
1130         """
1131         for i in self.dependencies(options):
1132             i[0].build(i[1])
1133
1134     def build(self, options, for_package=False):
1135         if self.built:
1136             return
1137
1138         log_verbose("Building %s %s %s with %s" % (self.name, self.commit_ish, self.version, options))
1139
1140         variables = copy.copy(self.target.variables)
1141
1142         options = copy.copy(options)
1143         self.add_defaults(options)
1144
1145         if not globals.dry_run:
1146             num_args = len(inspect.getfullargspec(self.cscript['build']).args)
1147             if num_args == 3:
1148                 self.call('build', options, for_package)
1149             elif num_args == 2:
1150                 self.call('build', options)
1151             else:
1152                 self.call('build')
1153
1154         self.target.variables = variables
1155         self.built = True
1156
1157
1158 #
1159 # Command-line parser
1160 #
1161
1162 def main():
1163     parser = argparse.ArgumentParser()
1164     parser.add_argument('-p', '--project', help='project name')
1165     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
1166     parser.add_argument('-o', '--output', help='output directory', default='.')
1167     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
1168     parser.add_argument('-t', '--target', help='target', action='append')
1169     parser.add_argument('--environment-version', help='version of environment to use')
1170     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
1171     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
1172     parser.add_argument('-w', '--work', help='override default work directory')
1173     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
1174     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
1175     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
1176     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
1177     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
1178     parser.add_argument('--ccache', help='use ccache', action='store_true')
1179     parser.add_argument('--verbose', help='be verbose', action='store_true')
1180
1181     subparsers = parser.add_subparsers(help='command to run', dest='command')
1182     parser_build = subparsers.add_parser("build", help="build project")
1183     parser_package = subparsers.add_parser("package", help="build and package project")
1184     parser_package.add_argument('--no-notarize', help='do not notarize .dmg packages', action='store_true')
1185     parser_release = subparsers.add_parser("release", help="release a project using its next version number (adding a tag)")
1186     parser_release.add_argument('--minor', help='minor version number bump', action='store_true')
1187     parser_release.add_argument('--micro', help='micro version number bump', action='store_true')
1188     parser_pot = subparsers.add_parser("pot", help="build the project's .pot files")
1189     parser_manual = subparsers.add_parser("manual", help="build the project's manual")
1190     parser_doxygen = subparsers.add_parser("doxygen", help="build the project's Doxygen documentation")
1191     parser_latest = subparsers.add_parser("latest", help="print out the latest version")
1192     parser_latest.add_argument('--major', help='major version to return', type=int)
1193     parser_latest.add_argument('--minor', help='minor version to return', type=int)
1194     parser_test = subparsers.add_parser("test", help="build the project and run its unit tests")
1195     parser_test.add_argument('--no-implicit-build', help='do not build first', action='store_true')
1196     parser_test.add_argument('--test', help="name of test to run, defaults to all")
1197     parser_shell = subparsers.add_parser("shell", help="start a shell in the project's work directory")
1198     parser_checkout = subparsers.add_parser("checkout", help="check out the project")
1199     parser_revision = subparsers.add_parser("revision", help="print the head git revision number")
1200     parser_dependencies = subparsers.add_parser("dependencies", help="print details of the project's dependencies as a .dot file")
1201     parser_notarize = subparsers.add_parser("notarize", help="notarize .dmgs in a directory")
1202     parser_notarize.add_argument('--dmgs', help='directory containing *.dmg')
1203
1204     global args
1205     args = parser.parse_args()
1206
1207     # Check for incorrect multiple parameters
1208     if args.target is not None:
1209         if len(args.target) > 1:
1210             parser.error('multiple -t options specified')
1211             sys.exit(1)
1212         else:
1213             args.target = args.target[0]
1214
1215     # Override configured stuff
1216     if args.git_prefix is not None:
1217         config.set('git_prefix', args.git_prefix)
1218
1219     if args.output.find(':') == -1:
1220         # This isn't of the form host:path so make it absolute
1221         args.output = os.path.abspath(args.output) + '/'
1222     else:
1223         if args.output[-1] != ':' and args.output[-1] != '/':
1224             args.output += '/'
1225
1226     # Now, args.output is 'host:', 'host:path/' or 'path/'
1227
1228     if args.work is not None:
1229         args.work = os.path.abspath(args.work)
1230         if not os.path.exists(args.work):
1231             os.makedirs(args.work)
1232
1233     if args.project is None and not args.command in ['shell', 'notarize']:
1234         raise Error('you must specify -p or --project')
1235
1236     globals.quiet = args.quiet
1237     globals.verbose = args.verbose
1238     globals.dry_run = args.dry_run
1239
1240     if args.command == 'build':
1241         if args.target is None:
1242             raise Error('you must specify -t or --target')
1243
1244         target = target_factory(args)
1245         try:
1246             target.build(args.project, args.checkout, get_command_line_options(args))
1247         finally:
1248             if not args.keep:
1249                 target.cleanup()
1250
1251     elif args.command == 'package':
1252         if args.target is None:
1253             raise Error('you must specify -t or --target')
1254
1255         target = None
1256         try:
1257             target = target_factory(args)
1258
1259             if target.platform == 'linux' and target.detail != "appimage":
1260                 if target.distro != 'arch':
1261                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1262                 else:
1263                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1264             else:
1265                 output_dir = args.output
1266
1267             makedirs(output_dir)
1268             target.package(args.project, args.checkout, output_dir, get_command_line_options(args), not args.no_notarize)
1269         finally:
1270             if target is not None and not args.keep:
1271                 target.cleanup()
1272
1273     elif args.command == 'release':
1274         if args.minor is False and args.micro is False:
1275             raise Error('you must specify --minor or --micro')
1276
1277         target = SourceTarget()
1278         tree = globals.trees.get(args.project, args.checkout, target)
1279
1280         version = tree.version
1281         version.to_release()
1282         if args.minor:
1283             version.bump_minor()
1284         else:
1285             version.bump_micro()
1286
1287         with TreeDirectory(tree):
1288             command('git tag -m "v%s" v%s' % (version, version))
1289             command('git push --tags')
1290
1291         target.cleanup()
1292
1293     elif args.command == 'pot':
1294         target = SourceTarget()
1295         tree = globals.trees.get(args.project, args.checkout, target)
1296
1297         pots = tree.call('make_pot')
1298         for p in pots:
1299             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1300
1301         target.cleanup()
1302
1303     elif args.command == 'manual':
1304         target = SourceTarget()
1305         tree = globals.trees.get(args.project, args.checkout, target)
1306         tree.checkout_dependencies()
1307
1308         outs = tree.call('make_manual')
1309         for o in outs:
1310             if os.path.isfile(o):
1311                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1312             else:
1313                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1314
1315         target.cleanup()
1316
1317     elif args.command == 'doxygen':
1318         target = SourceTarget()
1319         tree = globals.trees.get(args.project, args.checkout, target)
1320
1321         dirs = tree.call('make_doxygen')
1322         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1323             dirs = [dirs]
1324
1325         for d in dirs:
1326             copytree(d, args.output)
1327
1328         target.cleanup()
1329
1330     elif args.command == 'latest':
1331         target = SourceTarget()
1332         tree = globals.trees.get(args.project, args.checkout, target)
1333
1334         with TreeDirectory(tree):
1335             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1336             latest = None
1337             line = 0
1338             while latest is None:
1339                 t = f[line]
1340                 line += 1
1341                 m = re.compile(".*\((.*)\).*").match(t)
1342                 if m:
1343                     tags = m.group(1).split(', ')
1344                     for t in tags:
1345                         s = t.split()
1346                         if len(s) > 1:
1347                             t = s[1]
1348                         if len(t) > 0 and t[0] == 'v':
1349                             v = Version(t[1:])
1350                             if (args.major is None or v.major == args.major) and (args.minor is None or v.minor == args.minor):
1351                                 latest = v
1352
1353         print(latest)
1354         target.cleanup()
1355
1356     elif args.command == 'test':
1357         if args.target is None:
1358             raise Error('you must specify -t or --target')
1359
1360         target = None
1361         try:
1362             target = target_factory(args)
1363             options = get_command_line_options(args)
1364             if args.no_implicit_build:
1365                 globals.trees.add_built(args.project, args.checkout, target)
1366             else:
1367                 target.build(args.project, args.checkout, options)
1368             target.test(args.project, args.checkout, target, args.test, options)
1369         finally:
1370             if target is not None and not args.keep:
1371                 target.cleanup()
1372
1373     elif args.command == 'shell':
1374         if args.target is None:
1375             raise Error('you must specify -t or --target')
1376
1377         target = target_factory(args)
1378         target.command('bash')
1379
1380     elif args.command == 'revision':
1381
1382         target = SourceTarget()
1383         tree = globals.trees.get(args.project, args.checkout, target)
1384         with TreeDirectory(tree):
1385             print(command_and_read('git rev-parse HEAD')[0].strip()[:7])
1386         target.cleanup()
1387
1388     elif args.command == 'checkout':
1389
1390         if args.output is None:
1391             raise Error('you must specify -o or --output')
1392
1393         target = SourceTarget()
1394         tree = globals.trees.get(args.project, args.checkout, target)
1395         with TreeDirectory(tree):
1396             shutil.copytree('.', args.output)
1397         target.cleanup()
1398
1399     elif args.command == 'dependencies':
1400         if args.target is None:
1401             raise Error('you must specify -t or --target')
1402         if args.checkout is None:
1403             raise Error('you must specify -c or --checkout')
1404
1405         target = target_factory(args)
1406         tree = globals.trees.get(args.project, args.checkout, target)
1407         print("strict digraph {")
1408         for d in list(tree.dependencies({})):
1409             print("%s -> %s;" % (d[2].name.replace("-", "-"), d[0].name.replace("-", "_")))
1410         print("}")
1411
1412     elif args.command == 'notarize':
1413         if args.dmgs is None:
1414             raise Error('you must specify ---dmgs')
1415
1416         for dmg in Path(args.dmgs).glob('*.dmg'):
1417             notarize_dmg(dmg)
1418
1419 try:
1420     main()
1421 except Error as e:
1422     print('cdist: %s' % str(e), file=sys.stderr)
1423     sys.exit(1)