1 #!@TOOLS_PYTHON@
   2 #
   3 #  This program is free software; you can redistribute it and/or modify
   4 #  it under the terms of the GNU General Public License version 2
   5 #  as published by the Free Software Foundation.
   6 #
   7 #  This program is distributed in the hope that it will be useful,
   8 #  but WITHOUT ANY WARRANTY; without even the implied warranty of
   9 #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  10 #  GNU General Public License for more details.
  11 #
  12 #  You should have received a copy of the GNU General Public License
  13 #  along with this program; if not, write to the Free Software
  14 #  Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
  15 #
  16 
  17 #
  18 # Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved.
  19 # Copyright 2008, 2012 Richard Lowe
  20 # Copyright 2014 Garrett D'Amore <garrett@damore.org>
  21 # Copyright (c) 2015, 2016 by Delphix. All rights reserved.
  22 # Copyright 2016 Nexenta Systems, Inc.
  23 # Copyright 2018 Joyent, Inc.
  24 # Copyright 2018 OmniOS Community Edition (OmniOSce) Association.
  25 #
  26 
  27 from __future__ import print_function
  28 
  29 import getopt
  30 import io
  31 import os
  32 import re
  33 import subprocess
  34 import sys
  35 import tempfile
  36 
  37 if sys.version_info[0] < 3:
  38     from cStringIO import StringIO
  39 else:
  40     from io import StringIO
  41 
  42 #
  43 # Adjust the load path based on our location and the version of python into
  44 # which it is being loaded.  This assumes the normal onbld directory
  45 # structure, where we are in bin/ and the modules are in
  46 # lib/python(version)?/onbld/Scm/.  If that changes so too must this.
  47 #
  48 sys.path.insert(1, os.path.join(os.path.dirname(__file__), "..", "lib",
  49                                 "python%d.%d" % sys.version_info[:2]))
  50 
  51 #
  52 # Add the relative path to usr/src/tools to the load path, such that when run
  53 # from the source tree we use the modules also within the source tree.
  54 #
  55 sys.path.insert(2, os.path.join(os.path.dirname(__file__), ".."))
  56 
  57 from onbld.Scm import Ignore
  58 from onbld.Checks import Comments, Copyright, CStyle, HdrChk, WsCheck
  59 from onbld.Checks import JStyle, Keywords, ManLint, Mapfile, SpellCheck
  60 
  61 class GitError(Exception):
  62     pass
  63 
  64 def git(command):
  65     """Run a command and return a stream containing its stdout (and write its
  66     stderr to its stdout)"""
  67 
  68     if type(command) != list:
  69         command = command.split()
  70 
  71     command = ["git"] + command
  72 
  73     try:
  74         tmpfile = tempfile.TemporaryFile(prefix="git-nits", mode="w+b")
  75     except EnvironmentError as e:
  76         raise GitError("Could not create temporary file: %s\n" % e)
  77 
  78     try:
  79         p = subprocess.Popen(command,
  80                              stdout=tmpfile,
  81                              stderr=subprocess.PIPE)
  82     except OSError as e:
  83         raise GitError("could not execute %s: %s\n" % (command, e))
  84 
  85     err = p.wait()
  86     if err != 0:
  87         raise GitError(p.stderr.read())
  88 
  89     tmpfile.seek(0)
  90     lines = []
  91     for l in tmpfile:
  92         lines.append(l.decode('utf-8', 'replace'))
  93     return lines
  94 
  95 def git_root():
  96     """Return the root of the current git workspace"""
  97 
  98     p = git('rev-parse --git-dir')
  99     dir = p[0]
 100 
 101     return os.path.abspath(os.path.join(dir, os.path.pardir))
 102 
 103 def git_branch():
 104     """Return the current git branch"""
 105 
 106     p = git('branch')
 107 
 108     for elt in p:
 109         if elt[0] == '*':
 110             if elt.endswith('(no branch)'):
 111                 return None
 112             return elt.split()[1]
 113 
 114 def git_parent_branch(branch):
 115     """Return the parent of the current git branch.
 116 
 117     If this branch tracks a remote branch, return the remote branch which is
 118     tracked.  If not, default to origin/master."""
 119 
 120     if not branch:
 121         return None
 122 
 123     p = git(["for-each-ref", "--format=%(refname:short) %(upstream:short)",
 124             "refs/heads/"])
 125 
 126     if not p:
 127         sys.stderr.write("Failed finding git parent branch\n")
 128         sys.exit(err)
 129 
 130     for line in p:
 131         # Git 1.7 will leave a ' ' trailing any non-tracking branch
 132         if ' ' in line and not line.endswith(' \n'):
 133             local, remote = line.split()
 134             if local == branch:
 135                 return remote
 136     return 'origin/master'
 137 
 138 def git_comments(parent):
 139     """Return a list of any checkin comments on this git branch"""
 140 
 141     p = git('log --pretty=tformat:%%B:SEP: %s..' % parent)
 142 
 143     if not p:
 144         sys.stderr.write("Failed getting git comments\n")
 145         sys.exit(err)
 146 
 147     return [x.strip() for x in p if x != ':SEP:\n']
 148 
 149 def git_file_list(parent, paths=None):
 150     """Return the set of files which have ever changed on this branch.
 151 
 152     NB: This includes files which no longer exist, or no longer actually
 153     differ."""
 154 
 155     p = git("log --name-only --pretty=format: %s.. %s" %
 156              (parent, ' '.join(paths)))
 157 
 158     if not p:
 159         sys.stderr.write("Failed building file-list from git\n")
 160         sys.exit(err)
 161 
 162     ret = set()
 163     for fname in p:
 164         if fname and not fname.isspace() and fname not in ret:
 165             ret.add(fname.strip())
 166 
 167     return ret
 168 
 169 def not_check(root, cmd):
 170     """Return a function which returns True if a file given as an argument
 171     should be excluded from the check named by 'cmd'"""
 172 
 173     ignorefiles = list(filter(os.path.exists,
 174                          [os.path.join(root, ".git", "%s.NOT" % cmd),
 175                           os.path.join(root, "exception_lists", cmd)]))
 176     return Ignore.ignore(root, ignorefiles)
 177 
 178 def gen_files(root, parent, paths, exclude):
 179     """Return a function producing file names, relative to the current
 180     directory, of any file changed on this branch (limited to 'paths' if
 181     requested), and excluding files for which exclude returns a true value """
 182 
 183     # Taken entirely from Python 2.6's os.path.relpath which we would use if we
 184     # could.
 185     def relpath(path, here):
 186         c = os.path.abspath(os.path.join(root, path)).split(os.path.sep)
 187         s = os.path.abspath(here).split(os.path.sep)
 188         l = len(os.path.commonprefix((s, c)))
 189         return os.path.join(*[os.path.pardir] * (len(s)-l) + c[l:])
 190 
 191     def ret(select=None):
 192         if not select:
 193             select = lambda x: True
 194 
 195         for abspath in git_file_list(parent, paths):
 196             path = relpath(abspath, '.')
 197             try:
 198                 res = git("diff %s HEAD %s" % (parent, path))
 199             except GitError as e:
 200                 # This ignores all the errors that can be thrown. Usually, this
 201                 # means that git returned non-zero because the file doesn't
 202                 # exist, but it could also fail if git can't create a new file
 203                 # or it can't be executed.  Such errors are 1) unlikely, and 2)
 204                 # will be caught by other invocations of git().
 205                 continue
 206             empty = not res
 207             if (os.path.isfile(path) and not empty and
 208                 select(path) and not exclude(abspath)):
 209                 yield path
 210     return ret
 211 
 212 def comchk(root, parent, flist, output):
 213     output.write("Comments:\n")
 214 
 215     return Comments.comchk(git_comments(parent), check_db=True,
 216                            output=output)
 217 
 218 
 219 def mapfilechk(root, parent, flist, output):
 220     ret = 0
 221 
 222     # We are interested in examining any file that has the following
 223     # in its final path segment:
 224     #    - Contains the word 'mapfile'
 225     #    - Begins with 'map.'
 226     #    - Ends with '.map'
 227     # We don't want to match unless these things occur in final path segment
 228     # because directory names with these strings don't indicate a mapfile.
 229     # We also ignore files with suffixes that tell us that the files
 230     # are not mapfiles.
 231     MapfileRE = re.compile(r'.*((mapfile[^/]*)|(/map\.+[^/]*)|(\.map))$',
 232         re.IGNORECASE)
 233     NotMapSuffixRE = re.compile(r'.*\.[ch]$', re.IGNORECASE)
 234 
 235     output.write("Mapfile comments:\n")
 236 
 237     for f in flist(lambda x: MapfileRE.match(x) and not
 238                    NotMapSuffixRE.match(x)):
 239         with io.open(f, encoding='utf-8', errors='replace') as fh:
 240             ret |= Mapfile.mapfilechk(fh, output=output)
 241     return ret
 242 
 243 def copyright(root, parent, flist, output):
 244     ret = 0
 245     output.write("Copyrights:\n")
 246     for f in flist():
 247         with io.open(f, encoding='utf-8', errors='replace') as fh:
 248             ret |= Copyright.copyright(fh, output=output)
 249     return ret
 250 
 251 def hdrchk(root, parent, flist, output):
 252     ret = 0
 253     output.write("Header format:\n")
 254     for f in flist(lambda x: x.endswith('.h')):
 255         with io.open(f, encoding='utf-8', errors='replace') as fh:
 256             ret |= HdrChk.hdrchk(fh, lenient=True, output=output)
 257     return ret
 258 
 259 def cstyle(root, parent, flist, output):
 260     ret = 0
 261     output.write("C style:\n")
 262     for f in flist(lambda x: x.endswith('.c') or x.endswith('.h')):
 263         with io.open(f, encoding='utf-8', errors='replace') as fh:
 264             ret |= CStyle.cstyle(fh, output=output, picky=True,
 265                              check_posix_types=True,
 266                              check_continuation=True)
 267     return ret
 268 
 269 def jstyle(root, parent, flist, output):
 270     ret = 0
 271     output.write("Java style:\n")
 272     for f in flist(lambda x: x.endswith('.java')):
 273         with io.open(f, encoding='utf-8', errors='replace') as fh:
 274             ret |= JStyle.jstyle(fh, output=output, picky=True)
 275     return ret
 276 
 277 def manlint(root, parent, flist, output):
 278     ret = 0
 279     output.write("Man page format/spelling:\n")
 280     ManfileRE = re.compile(r'.*\.[0-9][a-z]*$', re.IGNORECASE)
 281     for f in flist(lambda x: ManfileRE.match(x)):
 282         with io.open(f, encoding='utf-8', errors='replace') as fh:
 283             ret |= ManLint.manlint(fh, output=output, picky=True)
 284             ret |= SpellCheck.spellcheck(fh, output=output)
 285     return ret
 286 
 287 def keywords(root, parent, flist, output):
 288     ret = 0
 289     output.write("SCCS Keywords:\n")
 290     for f in flist():
 291         with io.open(f, encoding='utf-8', errors='replace') as fh:
 292             ret |= Keywords.keywords(fh, output=output)
 293     return ret
 294 
 295 def wscheck(root, parent, flist, output):
 296     ret = 0
 297     output.write("white space nits:\n")
 298     for f in flist():
 299         with io.open(f, encoding='utf-8', errors='replace') as fh:
 300             ret |= WsCheck.wscheck(fh, output=output)
 301     return ret
 302 
 303 def run_checks(root, parent, cmds, paths='', opts={}):
 304     """Run the checks given in 'cmds', expected to have well-known signatures,
 305     and report results for any which fail.
 306 
 307     Return failure if any of them did.
 308 
 309     NB: the function name of the commands passed in is used to name the NOT
 310     file which excepts files from them."""
 311 
 312     ret = 0
 313 
 314     for cmd in cmds:
 315         s = StringIO()
 316 
 317         exclude = not_check(root, cmd.__name__)
 318         result = cmd(root, parent, gen_files(root, parent, paths, exclude),
 319                      output=s)
 320         ret |= result
 321 
 322         if result != 0:
 323             print(s.getvalue())
 324 
 325     return ret
 326 
 327 def nits(root, parent, paths):
 328     cmds = [copyright,
 329             cstyle,
 330             hdrchk,
 331             jstyle,
 332             keywords,
 333             manlint,
 334             mapfilechk,
 335             wscheck]
 336     run_checks(root, parent, cmds, paths)
 337 
 338 def pbchk(root, parent, paths):
 339     cmds = [comchk,
 340             copyright,
 341             cstyle,
 342             hdrchk,
 343             jstyle,
 344             keywords,
 345             manlint,
 346             mapfilechk,
 347             wscheck]
 348     run_checks(root, parent, cmds)
 349 
 350 def main(cmd, args):
 351     parent_branch = None
 352     checkname = None
 353 
 354     try:
 355         opts, args = getopt.getopt(args, 'b:c:p:')
 356     except getopt.GetoptError as e:
 357         sys.stderr.write(str(e) + '\n')
 358         sys.stderr.write("Usage: %s [-c check] [-p branch] [path...]\n" % cmd)
 359         sys.exit(1)
 360 
 361     for opt, arg in opts:
 362         # We accept "-b" as an alias of "-p" for backwards compatibility.
 363         if opt == '-p' or opt == '-b':
 364             parent_branch = arg
 365         elif opt == '-c':
 366             checkname = arg
 367 
 368     if not parent_branch:
 369         parent_branch = git_parent_branch(git_branch())
 370 
 371     if checkname is None:
 372         if cmd == 'git-pbchk':
 373             checkname = 'pbchk'
 374         else:
 375             checkname = 'nits'
 376 
 377     if checkname == 'pbchk':
 378         if args:
 379             sys.stderr.write("only complete workspaces may be pbchk'd\n");
 380             sys.exit(1)
 381         pbchk(git_root(), parent_branch, None)
 382     elif checkname == 'nits':
 383         nits(git_root(), parent_branch, args)
 384     else:
 385         run_checks(git_root(), parent_branch, [eval(checkname)], args)
 386 
 387 if __name__ == '__main__':
 388     try:
 389         main(os.path.basename(sys.argv[0]), sys.argv[1:])
 390     except GitError as e:
 391         sys.stderr.write("failed to run git:\n %s\n" % str(e))
 392         sys.exit(1)