xref: /openbmc/u-boot/tools/buildman/builder.py (revision 2bb1cd53)
1# Copyright (c) 2013 The Chromium OS Authors.
2#
3# Bloat-o-meter code used here Copyright 2004 Matt Mackall <mpm@selenic.com>
4#
5# SPDX-License-Identifier:	GPL-2.0+
6#
7
8import collections
9from datetime import datetime, timedelta
10import glob
11import os
12import re
13import Queue
14import shutil
15import string
16import sys
17import time
18
19import builderthread
20import command
21import gitutil
22import terminal
23from terminal import Print
24import toolchain
25
26
27"""
28Theory of Operation
29
30Please see README for user documentation, and you should be familiar with
31that before trying to make sense of this.
32
33Buildman works by keeping the machine as busy as possible, building different
34commits for different boards on multiple CPUs at once.
35
36The source repo (self.git_dir) contains all the commits to be built. Each
37thread works on a single board at a time. It checks out the first commit,
38configures it for that board, then builds it. Then it checks out the next
39commit and builds it (typically without re-configuring). When it runs out
40of commits, it gets another job from the builder and starts again with that
41board.
42
43Clearly the builder threads could work either way - they could check out a
44commit and then built it for all boards. Using separate directories for each
45commit/board pair they could leave their build product around afterwards
46also.
47
48The intent behind building a single board for multiple commits, is to make
49use of incremental builds. Since each commit is built incrementally from
50the previous one, builds are faster. Reconfiguring for a different board
51removes all intermediate object files.
52
53Many threads can be working at once, but each has its own working directory.
54When a thread finishes a build, it puts the output files into a result
55directory.
56
57The base directory used by buildman is normally '../<branch>', i.e.
58a directory higher than the source repository and named after the branch
59being built.
60
61Within the base directory, we have one subdirectory for each commit. Within
62that is one subdirectory for each board. Within that is the build output for
63that commit/board combination.
64
65Buildman also create working directories for each thread, in a .bm-work/
66subdirectory in the base dir.
67
68As an example, say we are building branch 'us-net' for boards 'sandbox' and
69'seaboard', and say that us-net has two commits. We will have directories
70like this:
71
72us-net/             base directory
73    01_of_02_g4ed4ebc_net--Add-tftp-speed-/
74        sandbox/
75            u-boot.bin
76        seaboard/
77            u-boot.bin
78    02_of_02_g4ed4ebc_net--Check-tftp-comp/
79        sandbox/
80            u-boot.bin
81        seaboard/
82            u-boot.bin
83    .bm-work/
84        00/         working directory for thread 0 (contains source checkout)
85            build/  build output
86        01/         working directory for thread 1
87            build/  build output
88        ...
89u-boot/             source directory
90    .git/           repository
91"""
92
93# Possible build outcomes
94OUTCOME_OK, OUTCOME_WARNING, OUTCOME_ERROR, OUTCOME_UNKNOWN = range(4)
95
96# Translate a commit subject into a valid filename
97trans_valid_chars = string.maketrans("/: ", "---")
98
99
100class Builder:
101    """Class for building U-Boot for a particular commit.
102
103    Public members: (many should ->private)
104        active: True if the builder is active and has not been stopped
105        already_done: Number of builds already completed
106        base_dir: Base directory to use for builder
107        checkout: True to check out source, False to skip that step.
108            This is used for testing.
109        col: terminal.Color() object
110        count: Number of commits to build
111        do_make: Method to call to invoke Make
112        fail: Number of builds that failed due to error
113        force_build: Force building even if a build already exists
114        force_config_on_failure: If a commit fails for a board, disable
115            incremental building for the next commit we build for that
116            board, so that we will see all warnings/errors again.
117        force_build_failures: If a previously-built build (i.e. built on
118            a previous run of buildman) is marked as failed, rebuild it.
119        git_dir: Git directory containing source repository
120        last_line_len: Length of the last line we printed (used for erasing
121            it with new progress information)
122        num_jobs: Number of jobs to run at once (passed to make as -j)
123        num_threads: Number of builder threads to run
124        out_queue: Queue of results to process
125        re_make_err: Compiled regular expression for ignore_lines
126        queue: Queue of jobs to run
127        threads: List of active threads
128        toolchains: Toolchains object to use for building
129        upto: Current commit number we are building (0.count-1)
130        warned: Number of builds that produced at least one warning
131        force_reconfig: Reconfigure U-Boot on each comiit. This disables
132            incremental building, where buildman reconfigures on the first
133            commit for a baord, and then just does an incremental build for
134            the following commits. In fact buildman will reconfigure and
135            retry for any failing commits, so generally the only effect of
136            this option is to slow things down.
137        in_tree: Build U-Boot in-tree instead of specifying an output
138            directory separate from the source code. This option is really
139            only useful for testing in-tree builds.
140
141    Private members:
142        _base_board_dict: Last-summarised Dict of boards
143        _base_err_lines: Last-summarised list of errors
144        _base_warn_lines: Last-summarised list of warnings
145        _build_period_us: Time taken for a single build (float object).
146        _complete_delay: Expected delay until completion (timedelta)
147        _next_delay_update: Next time we plan to display a progress update
148                (datatime)
149        _show_unknown: Show unknown boards (those not built) in summary
150        _timestamps: List of timestamps for the completion of the last
151            last _timestamp_count builds. Each is a datetime object.
152        _timestamp_count: Number of timestamps to keep in our list.
153        _working_dir: Base working directory containing all threads
154    """
155    class Outcome:
156        """Records a build outcome for a single make invocation
157
158        Public Members:
159            rc: Outcome value (OUTCOME_...)
160            err_lines: List of error lines or [] if none
161            sizes: Dictionary of image size information, keyed by filename
162                - Each value is itself a dictionary containing
163                    values for 'text', 'data' and 'bss', being the integer
164                    size in bytes of each section.
165            func_sizes: Dictionary keyed by filename - e.g. 'u-boot'. Each
166                    value is itself a dictionary:
167                        key: function name
168                        value: Size of function in bytes
169        """
170        def __init__(self, rc, err_lines, sizes, func_sizes):
171            self.rc = rc
172            self.err_lines = err_lines
173            self.sizes = sizes
174            self.func_sizes = func_sizes
175
176    def __init__(self, toolchains, base_dir, git_dir, num_threads, num_jobs,
177                 gnu_make='make', checkout=True, show_unknown=True, step=1,
178                 no_subdirs=False, full_path=False, verbose_build=False):
179        """Create a new Builder object
180
181        Args:
182            toolchains: Toolchains object to use for building
183            base_dir: Base directory to use for builder
184            git_dir: Git directory containing source repository
185            num_threads: Number of builder threads to run
186            num_jobs: Number of jobs to run at once (passed to make as -j)
187            gnu_make: the command name of GNU Make.
188            checkout: True to check out source, False to skip that step.
189                This is used for testing.
190            show_unknown: Show unknown boards (those not built) in summary
191            step: 1 to process every commit, n to process every nth commit
192            no_subdirs: Don't create subdirectories when building current
193                source for a single board
194            full_path: Return the full path in CROSS_COMPILE and don't set
195                PATH
196            verbose_build: Run build with V=1 and don't use 'make -s'
197        """
198        self.toolchains = toolchains
199        self.base_dir = base_dir
200        self._working_dir = os.path.join(base_dir, '.bm-work')
201        self.threads = []
202        self.active = True
203        self.do_make = self.Make
204        self.gnu_make = gnu_make
205        self.checkout = checkout
206        self.num_threads = num_threads
207        self.num_jobs = num_jobs
208        self.already_done = 0
209        self.force_build = False
210        self.git_dir = git_dir
211        self._show_unknown = show_unknown
212        self._timestamp_count = 10
213        self._build_period_us = None
214        self._complete_delay = None
215        self._next_delay_update = datetime.now()
216        self.force_config_on_failure = True
217        self.force_build_failures = False
218        self.force_reconfig = False
219        self._step = step
220        self.in_tree = False
221        self._error_lines = 0
222        self.no_subdirs = no_subdirs
223        self.full_path = full_path
224        self.verbose_build = verbose_build
225
226        self.col = terminal.Color()
227
228        self._re_function = re.compile('(.*): In function.*')
229        self._re_files = re.compile('In file included from.*')
230        self._re_warning = re.compile('(.*):(\d*):(\d*): warning: .*')
231        self._re_note = re.compile('(.*):(\d*):(\d*): note: this is the location of the previous.*')
232
233        self.queue = Queue.Queue()
234        self.out_queue = Queue.Queue()
235        for i in range(self.num_threads):
236            t = builderthread.BuilderThread(self, i)
237            t.setDaemon(True)
238            t.start()
239            self.threads.append(t)
240
241        self.last_line_len = 0
242        t = builderthread.ResultThread(self)
243        t.setDaemon(True)
244        t.start()
245        self.threads.append(t)
246
247        ignore_lines = ['(make.*Waiting for unfinished)', '(Segmentation fault)']
248        self.re_make_err = re.compile('|'.join(ignore_lines))
249
250    def __del__(self):
251        """Get rid of all threads created by the builder"""
252        for t in self.threads:
253            del t
254
255    def SetDisplayOptions(self, show_errors=False, show_sizes=False,
256                          show_detail=False, show_bloat=False,
257                          list_error_boards=False):
258        """Setup display options for the builder.
259
260        show_errors: True to show summarised error/warning info
261        show_sizes: Show size deltas
262        show_detail: Show detail for each board
263        show_bloat: Show detail for each function
264        list_error_boards: Show the boards which caused each error/warning
265        """
266        self._show_errors = show_errors
267        self._show_sizes = show_sizes
268        self._show_detail = show_detail
269        self._show_bloat = show_bloat
270        self._list_error_boards = list_error_boards
271
272    def _AddTimestamp(self):
273        """Add a new timestamp to the list and record the build period.
274
275        The build period is the length of time taken to perform a single
276        build (one board, one commit).
277        """
278        now = datetime.now()
279        self._timestamps.append(now)
280        count = len(self._timestamps)
281        delta = self._timestamps[-1] - self._timestamps[0]
282        seconds = delta.total_seconds()
283
284        # If we have enough data, estimate build period (time taken for a
285        # single build) and therefore completion time.
286        if count > 1 and self._next_delay_update < now:
287            self._next_delay_update = now + timedelta(seconds=2)
288            if seconds > 0:
289                self._build_period = float(seconds) / count
290                todo = self.count - self.upto
291                self._complete_delay = timedelta(microseconds=
292                        self._build_period * todo * 1000000)
293                # Round it
294                self._complete_delay -= timedelta(
295                        microseconds=self._complete_delay.microseconds)
296
297        if seconds > 60:
298            self._timestamps.popleft()
299            count -= 1
300
301    def ClearLine(self, length):
302        """Clear any characters on the current line
303
304        Make way for a new line of length 'length', by outputting enough
305        spaces to clear out the old line. Then remember the new length for
306        next time.
307
308        Args:
309            length: Length of new line, in characters
310        """
311        if length < self.last_line_len:
312            Print(' ' * (self.last_line_len - length), newline=False)
313            Print('\r', newline=False)
314        self.last_line_len = length
315        sys.stdout.flush()
316
317    def SelectCommit(self, commit, checkout=True):
318        """Checkout the selected commit for this build
319        """
320        self.commit = commit
321        if checkout and self.checkout:
322            gitutil.Checkout(commit.hash)
323
324    def Make(self, commit, brd, stage, cwd, *args, **kwargs):
325        """Run make
326
327        Args:
328            commit: Commit object that is being built
329            brd: Board object that is being built
330            stage: Stage that we are at (mrproper, config, build)
331            cwd: Directory where make should be run
332            args: Arguments to pass to make
333            kwargs: Arguments to pass to command.RunPipe()
334        """
335        cmd = [self.gnu_make] + list(args)
336        result = command.RunPipe([cmd], capture=True, capture_stderr=True,
337                cwd=cwd, raise_on_error=False, **kwargs)
338        return result
339
340    def ProcessResult(self, result):
341        """Process the result of a build, showing progress information
342
343        Args:
344            result: A CommandResult object, which indicates the result for
345                    a single build
346        """
347        col = terminal.Color()
348        if result:
349            target = result.brd.target
350
351            if result.return_code < 0:
352                self.active = False
353                command.StopAll()
354                return
355
356            self.upto += 1
357            if result.return_code != 0:
358                self.fail += 1
359            elif result.stderr:
360                self.warned += 1
361            if result.already_done:
362                self.already_done += 1
363            if self._verbose:
364                Print('\r', newline=False)
365                self.ClearLine(0)
366                boards_selected = {target : result.brd}
367                self.ResetResultSummary(boards_selected)
368                self.ProduceResultSummary(result.commit_upto, self.commits,
369                                          boards_selected)
370        else:
371            target = '(starting)'
372
373        # Display separate counts for ok, warned and fail
374        ok = self.upto - self.warned - self.fail
375        line = '\r' + self.col.Color(self.col.GREEN, '%5d' % ok)
376        line += self.col.Color(self.col.YELLOW, '%5d' % self.warned)
377        line += self.col.Color(self.col.RED, '%5d' % self.fail)
378
379        name = ' /%-5d  ' % self.count
380
381        # Add our current completion time estimate
382        self._AddTimestamp()
383        if self._complete_delay:
384            name += '%s  : ' % self._complete_delay
385        # When building all boards for a commit, we can print a commit
386        # progress message.
387        if result and result.commit_upto is None:
388            name += 'commit %2d/%-3d' % (self.commit_upto + 1,
389                    self.commit_count)
390
391        name += target
392        Print(line + name, newline=False)
393        length = 14 + len(name)
394        self.ClearLine(length)
395
396    def _GetOutputDir(self, commit_upto):
397        """Get the name of the output directory for a commit number
398
399        The output directory is typically .../<branch>/<commit>.
400
401        Args:
402            commit_upto: Commit number to use (0..self.count-1)
403        """
404        commit_dir = None
405        if self.commits:
406            commit = self.commits[commit_upto]
407            subject = commit.subject.translate(trans_valid_chars)
408            commit_dir = ('%02d_of_%02d_g%s_%s' % (commit_upto + 1,
409                    self.commit_count, commit.hash, subject[:20]))
410        elif not self.no_subdirs:
411            commit_dir = 'current'
412        if not commit_dir:
413            return self.base_dir
414        return os.path.join(self.base_dir, commit_dir)
415
416    def GetBuildDir(self, commit_upto, target):
417        """Get the name of the build directory for a commit number
418
419        The build directory is typically .../<branch>/<commit>/<target>.
420
421        Args:
422            commit_upto: Commit number to use (0..self.count-1)
423            target: Target name
424        """
425        output_dir = self._GetOutputDir(commit_upto)
426        return os.path.join(output_dir, target)
427
428    def GetDoneFile(self, commit_upto, target):
429        """Get the name of the done file for a commit number
430
431        Args:
432            commit_upto: Commit number to use (0..self.count-1)
433            target: Target name
434        """
435        return os.path.join(self.GetBuildDir(commit_upto, target), 'done')
436
437    def GetSizesFile(self, commit_upto, target):
438        """Get the name of the sizes file for a commit number
439
440        Args:
441            commit_upto: Commit number to use (0..self.count-1)
442            target: Target name
443        """
444        return os.path.join(self.GetBuildDir(commit_upto, target), 'sizes')
445
446    def GetFuncSizesFile(self, commit_upto, target, elf_fname):
447        """Get the name of the funcsizes file for a commit number and ELF file
448
449        Args:
450            commit_upto: Commit number to use (0..self.count-1)
451            target: Target name
452            elf_fname: Filename of elf image
453        """
454        return os.path.join(self.GetBuildDir(commit_upto, target),
455                            '%s.sizes' % elf_fname.replace('/', '-'))
456
457    def GetObjdumpFile(self, commit_upto, target, elf_fname):
458        """Get the name of the objdump file for a commit number and ELF file
459
460        Args:
461            commit_upto: Commit number to use (0..self.count-1)
462            target: Target name
463            elf_fname: Filename of elf image
464        """
465        return os.path.join(self.GetBuildDir(commit_upto, target),
466                            '%s.objdump' % elf_fname.replace('/', '-'))
467
468    def GetErrFile(self, commit_upto, target):
469        """Get the name of the err file for a commit number
470
471        Args:
472            commit_upto: Commit number to use (0..self.count-1)
473            target: Target name
474        """
475        output_dir = self.GetBuildDir(commit_upto, target)
476        return os.path.join(output_dir, 'err')
477
478    def FilterErrors(self, lines):
479        """Filter out errors in which we have no interest
480
481        We should probably use map().
482
483        Args:
484            lines: List of error lines, each a string
485        Returns:
486            New list with only interesting lines included
487        """
488        out_lines = []
489        for line in lines:
490            if not self.re_make_err.search(line):
491                out_lines.append(line)
492        return out_lines
493
494    def ReadFuncSizes(self, fname, fd):
495        """Read function sizes from the output of 'nm'
496
497        Args:
498            fd: File containing data to read
499            fname: Filename we are reading from (just for errors)
500
501        Returns:
502            Dictionary containing size of each function in bytes, indexed by
503            function name.
504        """
505        sym = {}
506        for line in fd.readlines():
507            try:
508                size, type, name = line[:-1].split()
509            except:
510                Print("Invalid line in file '%s': '%s'" % (fname, line[:-1]))
511                continue
512            if type in 'tTdDbB':
513                # function names begin with '.' on 64-bit powerpc
514                if '.' in name[1:]:
515                    name = 'static.' + name.split('.')[0]
516                sym[name] = sym.get(name, 0) + int(size, 16)
517        return sym
518
519    def GetBuildOutcome(self, commit_upto, target, read_func_sizes):
520        """Work out the outcome of a build.
521
522        Args:
523            commit_upto: Commit number to check (0..n-1)
524            target: Target board to check
525            read_func_sizes: True to read function size information
526
527        Returns:
528            Outcome object
529        """
530        done_file = self.GetDoneFile(commit_upto, target)
531        sizes_file = self.GetSizesFile(commit_upto, target)
532        sizes = {}
533        func_sizes = {}
534        if os.path.exists(done_file):
535            with open(done_file, 'r') as fd:
536                return_code = int(fd.readline())
537                err_lines = []
538                err_file = self.GetErrFile(commit_upto, target)
539                if os.path.exists(err_file):
540                    with open(err_file, 'r') as fd:
541                        err_lines = self.FilterErrors(fd.readlines())
542
543                # Decide whether the build was ok, failed or created warnings
544                if return_code:
545                    rc = OUTCOME_ERROR
546                elif len(err_lines):
547                    rc = OUTCOME_WARNING
548                else:
549                    rc = OUTCOME_OK
550
551                # Convert size information to our simple format
552                if os.path.exists(sizes_file):
553                    with open(sizes_file, 'r') as fd:
554                        for line in fd.readlines():
555                            values = line.split()
556                            rodata = 0
557                            if len(values) > 6:
558                                rodata = int(values[6], 16)
559                            size_dict = {
560                                'all' : int(values[0]) + int(values[1]) +
561                                        int(values[2]),
562                                'text' : int(values[0]) - rodata,
563                                'data' : int(values[1]),
564                                'bss' : int(values[2]),
565                                'rodata' : rodata,
566                            }
567                            sizes[values[5]] = size_dict
568
569            if read_func_sizes:
570                pattern = self.GetFuncSizesFile(commit_upto, target, '*')
571                for fname in glob.glob(pattern):
572                    with open(fname, 'r') as fd:
573                        dict_name = os.path.basename(fname).replace('.sizes',
574                                                                    '')
575                        func_sizes[dict_name] = self.ReadFuncSizes(fname, fd)
576
577            return Builder.Outcome(rc, err_lines, sizes, func_sizes)
578
579        return Builder.Outcome(OUTCOME_UNKNOWN, [], {}, {})
580
581    def GetResultSummary(self, boards_selected, commit_upto, read_func_sizes):
582        """Calculate a summary of the results of building a commit.
583
584        Args:
585            board_selected: Dict containing boards to summarise
586            commit_upto: Commit number to summarize (0..self.count-1)
587            read_func_sizes: True to read function size information
588
589        Returns:
590            Tuple:
591                Dict containing boards which passed building this commit.
592                    keyed by board.target
593                List containing a summary of error lines
594                Dict keyed by error line, containing a list of the Board
595                    objects with that error
596                List containing a summary of warning lines
597                Dict keyed by error line, containing a list of the Board
598                    objects with that warning
599        """
600        def AddLine(lines_summary, lines_boards, line, board):
601            line = line.rstrip()
602            if line in lines_boards:
603                lines_boards[line].append(board)
604            else:
605                lines_boards[line] = [board]
606                lines_summary.append(line)
607
608        board_dict = {}
609        err_lines_summary = []
610        err_lines_boards = {}
611        warn_lines_summary = []
612        warn_lines_boards = {}
613
614        for board in boards_selected.itervalues():
615            outcome = self.GetBuildOutcome(commit_upto, board.target,
616                                           read_func_sizes)
617            board_dict[board.target] = outcome
618            last_func = None
619            last_was_warning = False
620            for line in outcome.err_lines:
621                if line:
622                    if (self._re_function.match(line) or
623                            self._re_files.match(line)):
624                        last_func = line
625                    else:
626                        is_warning = self._re_warning.match(line)
627                        is_note = self._re_note.match(line)
628                        if is_warning or (last_was_warning and is_note):
629                            if last_func:
630                                AddLine(warn_lines_summary, warn_lines_boards,
631                                        last_func, board)
632                            AddLine(warn_lines_summary, warn_lines_boards,
633                                    line, board)
634                        else:
635                            if last_func:
636                                AddLine(err_lines_summary, err_lines_boards,
637                                        last_func, board)
638                            AddLine(err_lines_summary, err_lines_boards,
639                                    line, board)
640                        last_was_warning = is_warning
641                        last_func = None
642        return (board_dict, err_lines_summary, err_lines_boards,
643                warn_lines_summary, warn_lines_boards)
644
645    def AddOutcome(self, board_dict, arch_list, changes, char, color):
646        """Add an output to our list of outcomes for each architecture
647
648        This simple function adds failing boards (changes) to the
649        relevant architecture string, so we can print the results out
650        sorted by architecture.
651
652        Args:
653             board_dict: Dict containing all boards
654             arch_list: Dict keyed by arch name. Value is a string containing
655                    a list of board names which failed for that arch.
656             changes: List of boards to add to arch_list
657             color: terminal.Colour object
658        """
659        done_arch = {}
660        for target in changes:
661            if target in board_dict:
662                arch = board_dict[target].arch
663            else:
664                arch = 'unknown'
665            str = self.col.Color(color, ' ' + target)
666            if not arch in done_arch:
667                str = ' %s  %s' % (self.col.Color(color, char), str)
668                done_arch[arch] = True
669            if not arch in arch_list:
670                arch_list[arch] = str
671            else:
672                arch_list[arch] += str
673
674
675    def ColourNum(self, num):
676        color = self.col.RED if num > 0 else self.col.GREEN
677        if num == 0:
678            return '0'
679        return self.col.Color(color, str(num))
680
681    def ResetResultSummary(self, board_selected):
682        """Reset the results summary ready for use.
683
684        Set up the base board list to be all those selected, and set the
685        error lines to empty.
686
687        Following this, calls to PrintResultSummary() will use this
688        information to work out what has changed.
689
690        Args:
691            board_selected: Dict containing boards to summarise, keyed by
692                board.target
693        """
694        self._base_board_dict = {}
695        for board in board_selected:
696            self._base_board_dict[board] = Builder.Outcome(0, [], [], {})
697        self._base_err_lines = []
698        self._base_warn_lines = []
699        self._base_err_line_boards = {}
700        self._base_warn_line_boards = {}
701
702    def PrintFuncSizeDetail(self, fname, old, new):
703        grow, shrink, add, remove, up, down = 0, 0, 0, 0, 0, 0
704        delta, common = [], {}
705
706        for a in old:
707            if a in new:
708                common[a] = 1
709
710        for name in old:
711            if name not in common:
712                remove += 1
713                down += old[name]
714                delta.append([-old[name], name])
715
716        for name in new:
717            if name not in common:
718                add += 1
719                up += new[name]
720                delta.append([new[name], name])
721
722        for name in common:
723                diff = new.get(name, 0) - old.get(name, 0)
724                if diff > 0:
725                    grow, up = grow + 1, up + diff
726                elif diff < 0:
727                    shrink, down = shrink + 1, down - diff
728                delta.append([diff, name])
729
730        delta.sort()
731        delta.reverse()
732
733        args = [add, -remove, grow, -shrink, up, -down, up - down]
734        if max(args) == 0:
735            return
736        args = [self.ColourNum(x) for x in args]
737        indent = ' ' * 15
738        Print('%s%s: add: %s/%s, grow: %s/%s bytes: %s/%s (%s)' %
739              tuple([indent, self.col.Color(self.col.YELLOW, fname)] + args))
740        Print('%s  %-38s %7s %7s %+7s' % (indent, 'function', 'old', 'new',
741                                         'delta'))
742        for diff, name in delta:
743            if diff:
744                color = self.col.RED if diff > 0 else self.col.GREEN
745                msg = '%s  %-38s %7s %7s %+7d' % (indent, name,
746                        old.get(name, '-'), new.get(name,'-'), diff)
747                Print(msg, colour=color)
748
749
750    def PrintSizeDetail(self, target_list, show_bloat):
751        """Show details size information for each board
752
753        Args:
754            target_list: List of targets, each a dict containing:
755                    'target': Target name
756                    'total_diff': Total difference in bytes across all areas
757                    <part_name>: Difference for that part
758            show_bloat: Show detail for each function
759        """
760        targets_by_diff = sorted(target_list, reverse=True,
761        key=lambda x: x['_total_diff'])
762        for result in targets_by_diff:
763            printed_target = False
764            for name in sorted(result):
765                diff = result[name]
766                if name.startswith('_'):
767                    continue
768                if diff != 0:
769                    color = self.col.RED if diff > 0 else self.col.GREEN
770                msg = ' %s %+d' % (name, diff)
771                if not printed_target:
772                    Print('%10s  %-15s:' % ('', result['_target']),
773                          newline=False)
774                    printed_target = True
775                Print(msg, colour=color, newline=False)
776            if printed_target:
777                Print()
778                if show_bloat:
779                    target = result['_target']
780                    outcome = result['_outcome']
781                    base_outcome = self._base_board_dict[target]
782                    for fname in outcome.func_sizes:
783                        self.PrintFuncSizeDetail(fname,
784                                                 base_outcome.func_sizes[fname],
785                                                 outcome.func_sizes[fname])
786
787
788    def PrintSizeSummary(self, board_selected, board_dict, show_detail,
789                         show_bloat):
790        """Print a summary of image sizes broken down by section.
791
792        The summary takes the form of one line per architecture. The
793        line contains deltas for each of the sections (+ means the section
794        got bigger, - means smaller). The nunmbers are the average number
795        of bytes that a board in this section increased by.
796
797        For example:
798           powerpc: (622 boards)   text -0.0
799          arm: (285 boards)   text -0.0
800          nds32: (3 boards)   text -8.0
801
802        Args:
803            board_selected: Dict containing boards to summarise, keyed by
804                board.target
805            board_dict: Dict containing boards for which we built this
806                commit, keyed by board.target. The value is an Outcome object.
807            show_detail: Show detail for each board
808            show_bloat: Show detail for each function
809        """
810        arch_list = {}
811        arch_count = {}
812
813        # Calculate changes in size for different image parts
814        # The previous sizes are in Board.sizes, for each board
815        for target in board_dict:
816            if target not in board_selected:
817                continue
818            base_sizes = self._base_board_dict[target].sizes
819            outcome = board_dict[target]
820            sizes = outcome.sizes
821
822            # Loop through the list of images, creating a dict of size
823            # changes for each image/part. We end up with something like
824            # {'target' : 'snapper9g45, 'data' : 5, 'u-boot-spl:text' : -4}
825            # which means that U-Boot data increased by 5 bytes and SPL
826            # text decreased by 4.
827            err = {'_target' : target}
828            for image in sizes:
829                if image in base_sizes:
830                    base_image = base_sizes[image]
831                    # Loop through the text, data, bss parts
832                    for part in sorted(sizes[image]):
833                        diff = sizes[image][part] - base_image[part]
834                        col = None
835                        if diff:
836                            if image == 'u-boot':
837                                name = part
838                            else:
839                                name = image + ':' + part
840                            err[name] = diff
841            arch = board_selected[target].arch
842            if not arch in arch_count:
843                arch_count[arch] = 1
844            else:
845                arch_count[arch] += 1
846            if not sizes:
847                pass    # Only add to our list when we have some stats
848            elif not arch in arch_list:
849                arch_list[arch] = [err]
850            else:
851                arch_list[arch].append(err)
852
853        # We now have a list of image size changes sorted by arch
854        # Print out a summary of these
855        for arch, target_list in arch_list.iteritems():
856            # Get total difference for each type
857            totals = {}
858            for result in target_list:
859                total = 0
860                for name, diff in result.iteritems():
861                    if name.startswith('_'):
862                        continue
863                    total += diff
864                    if name in totals:
865                        totals[name] += diff
866                    else:
867                        totals[name] = diff
868                result['_total_diff'] = total
869                result['_outcome'] = board_dict[result['_target']]
870
871            count = len(target_list)
872            printed_arch = False
873            for name in sorted(totals):
874                diff = totals[name]
875                if diff:
876                    # Display the average difference in this name for this
877                    # architecture
878                    avg_diff = float(diff) / count
879                    color = self.col.RED if avg_diff > 0 else self.col.GREEN
880                    msg = ' %s %+1.1f' % (name, avg_diff)
881                    if not printed_arch:
882                        Print('%10s: (for %d/%d boards)' % (arch, count,
883                              arch_count[arch]), newline=False)
884                        printed_arch = True
885                    Print(msg, colour=color, newline=False)
886
887            if printed_arch:
888                Print()
889                if show_detail:
890                    self.PrintSizeDetail(target_list, show_bloat)
891
892
893    def PrintResultSummary(self, board_selected, board_dict, err_lines,
894                           err_line_boards, warn_lines, warn_line_boards,
895                           show_sizes, show_detail, show_bloat):
896        """Compare results with the base results and display delta.
897
898        Only boards mentioned in board_selected will be considered. This
899        function is intended to be called repeatedly with the results of
900        each commit. It therefore shows a 'diff' between what it saw in
901        the last call and what it sees now.
902
903        Args:
904            board_selected: Dict containing boards to summarise, keyed by
905                board.target
906            board_dict: Dict containing boards for which we built this
907                commit, keyed by board.target. The value is an Outcome object.
908            err_lines: A list of errors for this commit, or [] if there is
909                none, or we don't want to print errors
910            err_line_boards: Dict keyed by error line, containing a list of
911                the Board objects with that error
912            warn_lines: A list of warnings for this commit, or [] if there is
913                none, or we don't want to print errors
914            warn_line_boards: Dict keyed by warning line, containing a list of
915                the Board objects with that warning
916            show_sizes: Show image size deltas
917            show_detail: Show detail for each board
918            show_bloat: Show detail for each function
919        """
920        def _BoardList(line, line_boards):
921            """Helper function to get a line of boards containing a line
922
923            Args:
924                line: Error line to search for
925            Return:
926                String containing a list of boards with that error line, or
927                '' if the user has not requested such a list
928            """
929            if self._list_error_boards:
930                names = []
931                for board in line_boards[line]:
932                    if not board.target in names:
933                        names.append(board.target)
934                names_str = '(%s) ' % ','.join(names)
935            else:
936                names_str = ''
937            return names_str
938
939        def _CalcErrorDelta(base_lines, base_line_boards, lines, line_boards,
940                            char):
941            better_lines = []
942            worse_lines = []
943            for line in lines:
944                if line not in base_lines:
945                    worse_lines.append(char + '+' +
946                            _BoardList(line, line_boards) + line)
947            for line in base_lines:
948                if line not in lines:
949                    better_lines.append(char + '-' +
950                            _BoardList(line, base_line_boards) + line)
951            return better_lines, worse_lines
952
953        better = []     # List of boards fixed since last commit
954        worse = []      # List of new broken boards since last commit
955        new = []        # List of boards that didn't exist last time
956        unknown = []    # List of boards that were not built
957
958        for target in board_dict:
959            if target not in board_selected:
960                continue
961
962            # If the board was built last time, add its outcome to a list
963            if target in self._base_board_dict:
964                base_outcome = self._base_board_dict[target].rc
965                outcome = board_dict[target]
966                if outcome.rc == OUTCOME_UNKNOWN:
967                    unknown.append(target)
968                elif outcome.rc < base_outcome:
969                    better.append(target)
970                elif outcome.rc > base_outcome:
971                    worse.append(target)
972            else:
973                new.append(target)
974
975        # Get a list of errors that have appeared, and disappeared
976        better_err, worse_err = _CalcErrorDelta(self._base_err_lines,
977                self._base_err_line_boards, err_lines, err_line_boards, '')
978        better_warn, worse_warn = _CalcErrorDelta(self._base_warn_lines,
979                self._base_warn_line_boards, warn_lines, warn_line_boards, 'w')
980
981        # Display results by arch
982        if (better or worse or unknown or new or worse_err or better_err
983                or worse_warn or better_warn):
984            arch_list = {}
985            self.AddOutcome(board_selected, arch_list, better, '',
986                    self.col.GREEN)
987            self.AddOutcome(board_selected, arch_list, worse, '+',
988                    self.col.RED)
989            self.AddOutcome(board_selected, arch_list, new, '*', self.col.BLUE)
990            if self._show_unknown:
991                self.AddOutcome(board_selected, arch_list, unknown, '?',
992                        self.col.MAGENTA)
993            for arch, target_list in arch_list.iteritems():
994                Print('%10s: %s' % (arch, target_list))
995                self._error_lines += 1
996            if better_err:
997                Print('\n'.join(better_err), colour=self.col.GREEN)
998                self._error_lines += 1
999            if worse_err:
1000                Print('\n'.join(worse_err), colour=self.col.RED)
1001                self._error_lines += 1
1002            if better_warn:
1003                Print('\n'.join(better_warn), colour=self.col.CYAN)
1004                self._error_lines += 1
1005            if worse_warn:
1006                Print('\n'.join(worse_warn), colour=self.col.MAGENTA)
1007                self._error_lines += 1
1008
1009        if show_sizes:
1010            self.PrintSizeSummary(board_selected, board_dict, show_detail,
1011                                  show_bloat)
1012
1013        # Save our updated information for the next call to this function
1014        self._base_board_dict = board_dict
1015        self._base_err_lines = err_lines
1016        self._base_warn_lines = warn_lines
1017        self._base_err_line_boards = err_line_boards
1018        self._base_warn_line_boards = warn_line_boards
1019
1020        # Get a list of boards that did not get built, if needed
1021        not_built = []
1022        for board in board_selected:
1023            if not board in board_dict:
1024                not_built.append(board)
1025        if not_built:
1026            Print("Boards not built (%d): %s" % (len(not_built),
1027                  ', '.join(not_built)))
1028
1029    def ProduceResultSummary(self, commit_upto, commits, board_selected):
1030            (board_dict, err_lines, err_line_boards, warn_lines,
1031                    warn_line_boards) = self.GetResultSummary(
1032                    board_selected, commit_upto,
1033                    read_func_sizes=self._show_bloat)
1034            if commits:
1035                msg = '%02d: %s' % (commit_upto + 1,
1036                        commits[commit_upto].subject)
1037                Print(msg, colour=self.col.BLUE)
1038            self.PrintResultSummary(board_selected, board_dict,
1039                    err_lines if self._show_errors else [], err_line_boards,
1040                    warn_lines if self._show_errors else [], warn_line_boards,
1041                    self._show_sizes, self._show_detail, self._show_bloat)
1042
1043    def ShowSummary(self, commits, board_selected):
1044        """Show a build summary for U-Boot for a given board list.
1045
1046        Reset the result summary, then repeatedly call GetResultSummary on
1047        each commit's results, then display the differences we see.
1048
1049        Args:
1050            commit: Commit objects to summarise
1051            board_selected: Dict containing boards to summarise
1052        """
1053        self.commit_count = len(commits) if commits else 1
1054        self.commits = commits
1055        self.ResetResultSummary(board_selected)
1056        self._error_lines = 0
1057
1058        for commit_upto in range(0, self.commit_count, self._step):
1059            self.ProduceResultSummary(commit_upto, commits, board_selected)
1060        if not self._error_lines:
1061            Print('(no errors to report)', colour=self.col.GREEN)
1062
1063
1064    def SetupBuild(self, board_selected, commits):
1065        """Set up ready to start a build.
1066
1067        Args:
1068            board_selected: Selected boards to build
1069            commits: Selected commits to build
1070        """
1071        # First work out how many commits we will build
1072        count = (self.commit_count + self._step - 1) / self._step
1073        self.count = len(board_selected) * count
1074        self.upto = self.warned = self.fail = 0
1075        self._timestamps = collections.deque()
1076
1077    def GetThreadDir(self, thread_num):
1078        """Get the directory path to the working dir for a thread.
1079
1080        Args:
1081            thread_num: Number of thread to check.
1082        """
1083        return os.path.join(self._working_dir, '%02d' % thread_num)
1084
1085    def _PrepareThread(self, thread_num, setup_git):
1086        """Prepare the working directory for a thread.
1087
1088        This clones or fetches the repo into the thread's work directory.
1089
1090        Args:
1091            thread_num: Thread number (0, 1, ...)
1092            setup_git: True to set up a git repo clone
1093        """
1094        thread_dir = self.GetThreadDir(thread_num)
1095        builderthread.Mkdir(thread_dir)
1096        git_dir = os.path.join(thread_dir, '.git')
1097
1098        # Clone the repo if it doesn't already exist
1099        # TODO(sjg@chromium): Perhaps some git hackery to symlink instead, so
1100        # we have a private index but uses the origin repo's contents?
1101        if setup_git and self.git_dir:
1102            src_dir = os.path.abspath(self.git_dir)
1103            if os.path.exists(git_dir):
1104                gitutil.Fetch(git_dir, thread_dir)
1105            else:
1106                Print('Cloning repo for thread %d' % thread_num)
1107                gitutil.Clone(src_dir, thread_dir)
1108
1109    def _PrepareWorkingSpace(self, max_threads, setup_git):
1110        """Prepare the working directory for use.
1111
1112        Set up the git repo for each thread.
1113
1114        Args:
1115            max_threads: Maximum number of threads we expect to need.
1116            setup_git: True to set up a git repo clone
1117        """
1118        builderthread.Mkdir(self._working_dir)
1119        for thread in range(max_threads):
1120            self._PrepareThread(thread, setup_git)
1121
1122    def _PrepareOutputSpace(self):
1123        """Get the output directories ready to receive files.
1124
1125        We delete any output directories which look like ones we need to
1126        create. Having left over directories is confusing when the user wants
1127        to check the output manually.
1128        """
1129        if not self.commits:
1130            return
1131        dir_list = []
1132        for commit_upto in range(self.commit_count):
1133            dir_list.append(self._GetOutputDir(commit_upto))
1134
1135        for dirname in glob.glob(os.path.join(self.base_dir, '*')):
1136            if dirname not in dir_list:
1137                shutil.rmtree(dirname)
1138
1139    def BuildBoards(self, commits, board_selected, keep_outputs, verbose):
1140        """Build all commits for a list of boards
1141
1142        Args:
1143            commits: List of commits to be build, each a Commit object
1144            boards_selected: Dict of selected boards, key is target name,
1145                    value is Board object
1146            keep_outputs: True to save build output files
1147            verbose: Display build results as they are completed
1148        Returns:
1149            Tuple containing:
1150                - number of boards that failed to build
1151                - number of boards that issued warnings
1152        """
1153        self.commit_count = len(commits) if commits else 1
1154        self.commits = commits
1155        self._verbose = verbose
1156
1157        self.ResetResultSummary(board_selected)
1158        builderthread.Mkdir(self.base_dir, parents = True)
1159        self._PrepareWorkingSpace(min(self.num_threads, len(board_selected)),
1160                commits is not None)
1161        self._PrepareOutputSpace()
1162        self.SetupBuild(board_selected, commits)
1163        self.ProcessResult(None)
1164
1165        # Create jobs to build all commits for each board
1166        for brd in board_selected.itervalues():
1167            job = builderthread.BuilderJob()
1168            job.board = brd
1169            job.commits = commits
1170            job.keep_outputs = keep_outputs
1171            job.step = self._step
1172            self.queue.put(job)
1173
1174        # Wait until all jobs are started
1175        self.queue.join()
1176
1177        # Wait until we have processed all output
1178        self.out_queue.join()
1179        Print()
1180        self.ClearLine(0)
1181        return (self.fail, self.warned)
1182