xref: /illumos-gate/usr/src/tools/scripts/git-pbchk.py (revision 8a2b682e57a046b828f37bcde1776f131ef4629f)
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 2019 Garrett D'Amore <garrett@damore.org>
21# Copyright (c) 2015, 2016 by Delphix. All rights reserved.
22# Copyright 2016 Nexenta Systems, Inc.
23# Copyright (c) 2019, Joyent, Inc.
24# Copyright 2020 OmniOS Community Edition (OmniOSce) Association.
25#
26
27from __future__ import print_function
28
29import getopt
30import io
31import os
32import re
33import subprocess
34import sys
35import tempfile
36
37if sys.version_info[0] < 3:
38    from cStringIO import StringIO
39else:
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#
48sys.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#
55sys.path.insert(2, os.path.join(os.path.dirname(__file__), ".."))
56
57from onbld.Scm import Ignore
58from onbld.Checks import Comments, Copyright, CStyle, HdrChk, WsCheck
59from onbld.Checks import JStyle, Keywords, ManLint, Mapfile, SpellCheck
60
61class GitError(Exception):
62    pass
63
64def 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
95def 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
103def 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
114def 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(1)
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
138def 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("No outgoing changesets found - missing -p option?\n");
145        sys.exit(1)
146
147    return [x.strip() for x in p if x != ':SEP:\n']
148
149def 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(1)
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
169def 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
178def gen_files(root, parent, paths, exclude, filter=None):
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    if filter is None:
184        filter = lambda x: os.path.isfile(x)
185
186    # Taken entirely from Python 2.6's os.path.relpath which we would use if we
187    # could.
188    def relpath(path, here):
189        c = os.path.abspath(os.path.join(root, path)).split(os.path.sep)
190        s = os.path.abspath(here).split(os.path.sep)
191        l = len(os.path.commonprefix((s, c)))
192        return os.path.join(*[os.path.pardir] * (len(s)-l) + c[l:])
193
194    def ret(select=None):
195        if not select:
196            select = lambda x: True
197
198        for abspath in git_file_list(parent, paths):
199            path = relpath(abspath, '.')
200            try:
201                res = git("diff %s HEAD %s" % (parent, path))
202            except GitError as e:
203                # This ignores all the errors that can be thrown. Usually, this
204                # means that git returned non-zero because the file doesn't
205                # exist, but it could also fail if git can't create a new file
206                # or it can't be executed.  Such errors are 1) unlikely, and 2)
207                # will be caught by other invocations of git().
208                continue
209            empty = not res
210            if (filter(path) and not empty and
211                select(path) and not exclude(abspath)):
212                yield path
213    return ret
214
215def gen_links(root, parent, paths, exclude):
216    """Return a function producing symbolic link names, relative to the current
217    directory, of any file changed on this branch (limited to 'paths' if
218    requested), and excluding files for which exclude returns a true value """
219
220    return gen_files(root, parent, paths, exclude, lambda x: os.path.islink(x))
221
222def comchk(root, parent, flist, output):
223    output.write("Comments:\n")
224
225    return Comments.comchk(git_comments(parent), check_db=True,
226                           output=output)
227
228
229def mapfilechk(root, parent, flist, output):
230    ret = 0
231
232    # We are interested in examining any file that has the following
233    # in its final path segment:
234    #    - Contains the word 'mapfile'
235    #    - Begins with 'map.'
236    #    - Ends with '.map'
237    # We don't want to match unless these things occur in final path segment
238    # because directory names with these strings don't indicate a mapfile.
239    # We also ignore files with suffixes that tell us that the files
240    # are not mapfiles.
241    MapfileRE = re.compile(r'.*((mapfile[^/]*)|(/map\.+[^/]*)|(\.map))$',
242        re.IGNORECASE)
243    NotMapSuffixRE = re.compile(r'.*\.[ch]$', re.IGNORECASE)
244
245    output.write("Mapfile comments:\n")
246
247    for f in flist(lambda x: MapfileRE.match(x) and not
248                   NotMapSuffixRE.match(x)):
249        with io.open(f, encoding='utf-8', errors='replace') as fh:
250            ret |= Mapfile.mapfilechk(fh, output=output)
251    return ret
252
253def copyright(root, parent, flist, output):
254    ret = 0
255    output.write("Copyrights:\n")
256    for f in flist():
257        with io.open(f, encoding='utf-8', errors='replace') as fh:
258            ret |= Copyright.copyright(fh, output=output)
259    return ret
260
261def hdrchk(root, parent, flist, output):
262    ret = 0
263    output.write("Header format:\n")
264    for f in flist(lambda x: x.endswith('.h')):
265        with io.open(f, encoding='utf-8', errors='replace') as fh:
266            ret |= HdrChk.hdrchk(fh, lenient=True, output=output)
267    return ret
268
269def cstyle(root, parent, flist, output):
270    ret = 0
271    output.write("C style:\n")
272    for f in flist(lambda x: x.endswith('.c') or x.endswith('.h')):
273        with io.open(f, mode='rb') as fh:
274            ret |= CStyle.cstyle(fh, output=output, picky=True,
275                             check_posix_types=True,
276                             check_continuation=True)
277    return ret
278
279def jstyle(root, parent, flist, output):
280    ret = 0
281    output.write("Java style:\n")
282    for f in flist(lambda x: x.endswith('.java')):
283        with io.open(f, mode='rb') as fh:
284            ret |= JStyle.jstyle(fh, output=output, picky=True)
285    return ret
286
287def manlint(root, parent, flist, output):
288    ret = 0
289    output.write("Man page format/spelling:\n")
290    ManfileRE = re.compile(r'.*\.[0-9][a-z]*$', re.IGNORECASE)
291    for f in flist(lambda x: ManfileRE.match(x)):
292        with io.open(f, mode='rb') as fh:
293            ret |= ManLint.manlint(fh, output=output, picky=True)
294            ret |= SpellCheck.spellcheck(fh, output=output)
295    return ret
296
297def keywords(root, parent, flist, output):
298    ret = 0
299    output.write("SCCS Keywords:\n")
300    for f in flist():
301        with io.open(f, encoding='utf-8', errors='replace') as fh:
302            ret |= Keywords.keywords(fh, output=output)
303    return ret
304
305def wscheck(root, parent, flist, output):
306    ret = 0
307    output.write("white space nits:\n")
308    for f in flist():
309        with io.open(f, encoding='utf-8', errors='replace') as fh:
310            ret |= WsCheck.wscheck(fh, output=output)
311    return ret
312
313def symlinks(root, parent, flist, output):
314    ret = 0
315    output.write("Symbolic links:\n")
316    for f in flist():
317        output.write("  "+f+"\n")
318        ret |= 1
319    return ret
320
321def iswinreserved(name):
322    reserved = [
323        'con', 'prn', 'aux', 'nul',
324        'com1', 'com2', 'com3', 'com4', 'com5',
325        'com6', 'com7', 'com8', 'com9', 'com0',
326        'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5',
327        'lpt6', 'lpt7', 'lpt8', 'lpt9', 'lpt0' ]
328    l = name.lower()
329    for r in reserved:
330        if l == r or l.startswith(r+"."):
331            return True
332    return False
333
334def haswinspecial(name):
335    specials = '<>:"\\|?*'
336    for c in name:
337        if c in specials:
338            return True
339    return False
340
341def winnames(root, parent, flist, output):
342    ret = 0
343    output.write("Illegal filenames (Windows):\n")
344    for f in flist():
345        if haswinspecial(f):
346            output.write("  "+f+": invalid character in name\n")
347            ret |= 1
348            continue
349
350        parts = f.split('/')
351        for p in parts:
352            if iswinreserved(p):
353                output.write("  "+f+": reserved file name\n")
354                ret |= 1
355                break
356
357    return ret
358
359def run_checks(root, parent, cmds, scmds, paths='', opts={}):
360    """Run the checks given in 'cmds', expected to have well-known signatures,
361    and report results for any which fail.
362
363    Return failure if any of them did.
364
365    NB: the function name of the commands passed in is used to name the NOT
366    file which excepts files from them."""
367
368    ret = 0
369
370    for cmd in cmds:
371        s = StringIO()
372
373        exclude = not_check(root, cmd.__name__)
374        result = cmd(root, parent, gen_files(root, parent, paths, exclude),
375                     output=s)
376        ret |= result
377
378        if result != 0:
379            print(s.getvalue())
380
381    for cmd in scmds:
382        s = StringIO()
383
384        exclude = not_check(root, cmd.__name__)
385        result = cmd(root, parent, gen_links(root, parent, paths, exclude),
386                     output=s)
387        ret |= result
388
389        if result != 0:
390            print(s.getvalue())
391
392    return ret
393
394def nits(root, parent, paths):
395    cmds = [copyright,
396            cstyle,
397            hdrchk,
398            jstyle,
399            keywords,
400            manlint,
401            mapfilechk,
402            winnames,
403            wscheck]
404    scmds = [symlinks]
405    run_checks(root, parent, cmds, scmds, paths)
406
407def pbchk(root, parent, paths):
408    cmds = [comchk,
409            copyright,
410            cstyle,
411            hdrchk,
412            jstyle,
413            keywords,
414            manlint,
415            mapfilechk,
416            winnames,
417            wscheck]
418    scmds = [symlinks]
419    run_checks(root, parent, cmds, scmds)
420
421def main(cmd, args):
422    parent_branch = None
423    checkname = None
424
425    try:
426        opts, args = getopt.getopt(args, 'b:c:p:')
427    except getopt.GetoptError as e:
428        sys.stderr.write(str(e) + '\n')
429        sys.stderr.write("Usage: %s [-c check] [-p branch] [path...]\n" % cmd)
430        sys.exit(1)
431
432    for opt, arg in opts:
433        # We accept "-b" as an alias of "-p" for backwards compatibility.
434        if opt == '-p' or opt == '-b':
435            parent_branch = arg
436        elif opt == '-c':
437            checkname = arg
438
439    if not parent_branch:
440        parent_branch = git_parent_branch(git_branch())
441
442    if checkname is None:
443        if cmd == 'git-pbchk':
444            checkname = 'pbchk'
445        else:
446            checkname = 'nits'
447
448    if checkname == 'pbchk':
449        if args:
450            sys.stderr.write("only complete workspaces may be pbchk'd\n");
451            sys.exit(1)
452        pbchk(git_root(), parent_branch, None)
453    elif checkname == 'nits':
454        nits(git_root(), parent_branch, args)
455    else:
456        run_checks(git_root(), parent_branch, [eval(checkname)], args)
457
458if __name__ == '__main__':
459    try:
460        main(os.path.basename(sys.argv[0]), sys.argv[1:])
461    except GitError as e:
462        sys.stderr.write("failed to run git:\n %s\n" % str(e))
463        sys.exit(1)
464