new file mode 100755
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0 only
+#
+# Copyright: Masahiro Yamada <masahiroy@kernel.org>
+
+import argparse
+import collections
+import logging
+import os
+import pathlib
+
+
+class IgnorePattern:
+
+ def __init__(self, pattern, dirpath):
+
+ logging.debug(f" Add pattern: {pattern}")
+
+ self.orig = pattern
+ self.negate = False
+ self.dir_only = False
+ self.double_asterisk = False
+
+ # The prefix '!' negates the pattern.
+ if pattern[0] == '!':
+ self.negate = True
+ pattern = pattern[1:]
+
+ # a pattern with a trailing slash only matches to directories
+ if pattern[-1] == '/':
+ self.dir_only = True
+ pattern = pattern[:-1]
+
+ # FIXME:
+ # Escape sequence ('\') does not work correctly.
+ # Just strip a backslash at the beginning to support '\#*#'.
+ if pattern[0] == '\\':
+ pattern = pattern[1:]
+
+ # It is tricky to handle double asterisks ("**").
+ # pathlib's match() cannot handle it but glob() can.
+ if '**' in pattern:
+ self.double_asterisk = True
+ if pattern[0] == '/':
+ pattern = pattern[1:]
+
+ self.pattern = []
+
+ for f in dirpath.glob(pattern):
+ self.pattern.append(f.absolute())
+
+ return
+ # If a slash appears at the beginning or middle of the pattern,
+ # (e.g. "/a", "a/b", etc.) it is relative to the directory level.
+ elif '/' in pattern:
+ if pattern[0] == '/':
+ pattern = pattern[1:]
+ pattern = str(dirpath.joinpath(pattern).absolute())
+
+ self.pattern = pattern
+
+ def is_ignored(self, path):
+
+ if path.is_dir() and path.name == '.git':
+ logging.debug(f' Ignore: {path}')
+ return True
+
+ if self.dir_only and not path.is_dir():
+ return None
+
+ if self.double_asterisk:
+ # Special handling for double asterisks
+ if not path.absolute() in self.pattern:
+ return None
+ else:
+ # make the path abosolute before matching, so it works for patterns
+ # that contain slash(es) such as "/a", "a/b".
+ if not path.absolute().match(self.pattern):
+ return None
+
+ logging.debug(f' {"Not" if self.negate else ""}Ignore: {path} (pattern: {self.orig})')
+
+ return not self.negate
+
+
+class GitIgnore:
+ def __init__(self, path):
+ # If a path matches multiple patterns, the last one wins.
+ # So, patterns must be checked in the reverse order.
+ # Use deque's appendleft() to add a new pattern.
+ self.patterns = collections.deque()
+ if path is not None:
+ logging.debug(f" Add gitignore: {path}")
+ self._parse(path)
+
+ def _parse(self, path):
+ with open(path) as f:
+ for line in f:
+ if line.startswith('#'):
+ continue
+
+ line = line.strip()
+ if not line:
+ continue
+
+ self.patterns.appendleft(IgnorePattern(line, path.parent))
+
+ def is_ignored(self, path):
+ for pattern in self.patterns:
+ ret = pattern.is_ignored(path)
+ if ret is not None:
+ return ret
+
+ return None
+
+
+class GitIgnores:
+ def __init__(self):
+ self.ignore_files = collections.deque()
+
+ def append(self, path):
+ self.ignore_files.appendleft(GitIgnore(path))
+
+ def pop(self):
+ self.ignore_files.popleft()
+
+ def is_ignored(self, path):
+
+ for gitignore in self.ignore_files:
+ ret = gitignore.is_ignored(path)
+ if ret is not None:
+ return ret
+
+ logging.debug(f" NoMatch: {path}")
+
+ return False
+
+
+def parse_arguments():
+
+ parser = argparse.ArgumentParser(description='Print files that are not ignored by .gitignore')
+
+ parser.add_argument('-d', '--debug', action='store_true', help='enable debug log')
+
+ parser.add_argument('--prefix', default='', help='prefix added to each path')
+
+ parser.add_argument('--rootdir', default='.', help='root of the source tree (default: current working directory)')
+
+ args = parser.parse_args()
+
+ return args
+
+
+def traverse_directory(path, gitignores, printer):
+
+ logging.debug(f"Enter: {path}")
+
+ gitignore_file = None
+ all_ignored = True
+
+ for f in path.iterdir():
+ if f.is_file() and f.name == '.gitignore':
+ gitignore_file = f
+
+ gitignores.append(gitignore_file)
+
+ for f in path.iterdir():
+ logging.debug(f" Check: {f}")
+ if gitignores.is_ignored(f):
+ printer(f)
+ else:
+ if f.is_dir() and not f.is_symlink():
+ ret = traverse_directory(f, gitignores, printer)
+ all_ignored = all_ignored and ret
+ else:
+ all_ignored = False
+
+ if all_ignored:
+ printer(path)
+
+ gitignores.pop()
+ logging.debug(f"Leave: {path}")
+
+ return all_ignored
+
+
+def main():
+
+ args = parse_arguments()
+
+ logging.basicConfig(format='%(levelname)s: %(message)s',
+ level='DEBUG' if args.debug else 'WARNING')
+
+ os.chdir(args.rootdir)
+
+ traverse_directory(pathlib.Path('.'), GitIgnores(),
+ lambda path: print(f'{args.prefix}{str(path)}'))
+
+
+if __name__ == '__main__':
+ main()