diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..5786c46 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,107 @@ +project(git-clang-format-cmake) +cmake_minimum_required(VERSION 3.3) + +find_package(Git REQUIRED) +find_package(PythonInterp REQUIRED) + +set(GCF_DIR ${CMAKE_CURRENT_LIST_DIR} PARENT_SCOPE) + +macro(GCF_CHECK_OPTION_OR_DEFAULT ARG_NAME DEFAULT) + if(NOT DEFINED GCF_${ARG_NAME}) + if(NOT DEFINED GCF_GLOBAL_${ARG_NAME}) + set(GCF_${ARG_NAME} ${DEFAULT}) + else() + set(GCF_${ARG_NAME} ${GCF_GLOBAL_${ARG_NAME}}) + endif() + endif() +endmacro() + +function(create_git_hook) + set(options ABORT_COMMIT) + set(oneValueArgs FORMAT_STYLE MODES IGNORE_DIRS TIDY_CHECKS cpp_FORMATTERS cpp_LINTERS cmake_FORMATTERS cmake_LINTERS py_FORMATTERS py_LINTERS) + set(multiValueArgs "") + cmake_parse_arguments(GCF "${options}" "${oneValueArgs}" + "${multiValueArgs}" ${ARGN} ) + + set(GCF_SCRIPT ${GCF_DIR}/git-cmake-format.py) + set(GCF_BUILD_DIR "${CMAKE_BINARY_DIR}") + set(GCF_SOURCE_DIR "${CMAKE_SOURCE_DIR}") + + if(NOT DEFINED GCF_IGNORE_DIRS) + set(GCF_IGNORE_DIRS "") + endif() + + # clang-format style + GCF_CHECK_OPTION_OR_DEFAULT("CLANG_FORMAT_STYLE" "file") + # Checks executed by clang-tidy + GCF_CHECK_OPTION_OR_DEFAULT("CLANG_TIDY_CHECKS" + "readability-*,bugprone-*,modernize-*,google-*") + # Comma-separated list of tools to run + GCF_CHECK_OPTION_OR_DEFAULT("MODES" "format,lint") + # Abort commit if files would be formatted + GCF_CHECK_OPTION_OR_DEFAULT("ABORT_COMMIT" 0) + + # Comma-separated lists of formatters/linters for specific file types + GCF_CHECK_OPTION_OR_DEFAULT("cpp_FORMATTERS" "clang-format") + GCF_CHECK_OPTION_OR_DEFAULT("cpp_LINTERS" "clang-tidy") + GCF_CHECK_OPTION_OR_DEFAULT("cmake_FORMATTERS" "") # cmake-format + GCF_CHECK_OPTION_OR_DEFAULT("cmake_LINTERS" "") + GCF_CHECK_OPTION_OR_DEFAULT("py_FORMATTERS" "") # autopep8 + GCF_CHECK_OPTION_OR_DEFAULT("py_LINTERS" "") # pylint + + string(REPLACE "," ";" GCF_cpp_FORMATTERS_LIST "${GCF_cpp_FORMATTERS}") + string(REPLACE "," ";" GCF_cpp_LINTERS_LIST "${GCF_cpp_LINTERS}") + string(REPLACE "," ";" GCF_cmake_FORMATTERS_LIST "${GCF_cmake_FORMATTERS}") + string(REPLACE "," ";" GCF_cmake_LINTERS_LIST "${GCF_cmake_LINTERS}") + string(REPLACE "," ";" GCF_py_FORMATTERS_LIST "${GCF_py_FORMATTERS}") + string(REPLACE "," ";" GCF_py_LINTERS_LIST "${GCF_py_LINTERS}") + + list(APPEND CMAKE_MODULE_PATH ${GCF_DIR}) + if("clang-format" IN_LIST GCF_cpp_FORMATTERS_LIST) + find_package(ClangFormat REQUIRED) + endif() + if("cmake-format" IN_LIST GCF_cmake_FORMATTERS_LIST) + find_program(GCF_CMAKE_FORMAT_PATH cmake-format REQUIRED) + endif() + if("autopep8" IN_LIST GCF_py_FORMATTERS_LIST) + find_program(GCF_AUTOPEP_PATH autopep8 REQUIRED) + endif() + + if("clang-tidy" IN_LIST GCF_cpp_LINTERS_LIST) + find_package(ClangTidy REQUIRED) + endif() + if("pylint" IN_LIST GCF_py_LINTERS_LIST) + find_program(GCF_PYLINT_PATH pylint REQUIRED) + endif() + + execute_process(COMMAND ${GIT_EXECUTABLE} rev-parse --show-toplevel + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE GCF_GIT_ROOT + OUTPUT_STRIP_TRAILING_WHITESPACE) + + # --absolute-git-dir is not supported on git=2.7.3 which is installed on Ubuntu 16.04 + execute_process(COMMAND sh -c "readlink -f $(${GIT_EXECUTABLE} rev-parse --git-dir)" + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE GCF_GIT_DIR + OUTPUT_STRIP_TRAILING_WHITESPACE) + + if(NOT GCF_GIT_ROOT) + message(WARNING "Not in a git repository") + else() + configure_file( + "${GCF_DIR}/pre-commit.template.sh" + "${GCF_GIT_DIR}/hooks/pre-commit" + @ONLY) + + get_filename_component(GCF_PROJECT_NAME "${CMAKE_BINARY_DIR}" NAME) + configure_file( + "${GCF_DIR}/project.template.yaml" + "${GCF_GIT_DIR}/hooks/.${GCF_PROJECT_NAME}.config.yaml" + @ONLY) + + configure_file( + "${GCF_DIR}/run_hooks.template.sh" + "${GCF_GIT_ROOT}/run_hooks" + @ONLY) + endif() +endfunction() diff --git a/FindClangFormat.cmake b/FindClangFormat.cmake new file mode 100644 index 0000000..df0e14e --- /dev/null +++ b/FindClangFormat.cmake @@ -0,0 +1,90 @@ +# +#.rst: +# FindClangFormat +# --------------- +# +# The module defines the following variables +# +# ``CLANG_FORMAT_EXECUTABLE`` +# Path to clang-format executable +# ``CLANG_FORMAT_FOUND`` +# True if the clang-format executable was found. +# ``CLANG_FORMAT_VERSION`` +# The version of clang-format found +# ``CLANG_FORMAT_VERSION_MAJOR`` +# The clang-format major version if specified, 0 otherwise +# ``CLANG_FORMAT_VERSION_MINOR`` +# The clang-format minor version if specified, 0 otherwise +# ``CLANG_FORMAT_VERSION_PATCH`` +# The clang-format patch version if specified, 0 otherwise +# ``CLANG_FORMAT_VERSION_COUNT`` +# Number of version components reported by clang-format +# +# Example usage: +# +# .. code-block:: cmake +# +# find_package(ClangFormat) +# if(CLANG_FORMAT_FOUND) +# message("clang-format executable found: ${CLANG_FORMAT_EXECUTABLE}\n" +# "version: ${CLANG_FORMAT_VERSION}") +# endif() + +find_program(CLANG_FORMAT_EXECUTABLE + NAMES clang-format-6.0 clang-format clang-format-5.0 + clang-format-4.0 clang-format-3.9 + clang-format-3.8 clang-format-3.7 + clang-format-3.6 clang-format-3.5 + clang-format-3.4 clang-format-3.3 + DOC "clang-format executable" +) +mark_as_advanced(CLANG_FORMAT_EXECUTABLE) + +# Extract version from command "clang-format -version" +if(CLANG_FORMAT_EXECUTABLE) + execute_process(COMMAND sh -c "${CLANG_FORMAT_EXECUTABLE} -version | grep version" + OUTPUT_VARIABLE clang_format_version + # ERROR_QUIET + OUTPUT_STRIP_TRAILING_WHITESPACE) + + if (clang_format_version MATCHES "version .*") + # clang_format_version sample: "clang-format version 3.9.1-4ubuntu3~16.04.1 (tags/RELEASE_391/rc2)" + string(REGEX REPLACE + "clang-format version ([.0-9]+).*" "\\1" + CLANG_FORMAT_VERSION "${clang_format_version}") + # CLANG_FORMAT_VERSION sample: "3.9.1" + + # Extract version components + string(REPLACE "." ";" clang_format_version "${CLANG_FORMAT_VERSION}") + list(LENGTH clang_format_version CLANG_FORMAT_VERSION_COUNT) + if(CLANG_FORMAT_VERSION_COUNT GREATER 0) + list(GET clang_format_version 0 CLANG_FORMAT_VERSION_MAJOR) + else() + set(CLANG_FORMAT_VERSION_MAJOR 0) + endif() + if(CLANG_FORMAT_VERSION_COUNT GREATER 1) + list(GET clang_format_version 1 CLANG_FORMAT_VERSION_MINOR) + else() + set(CLANG_FORMAT_VERSION_MINOR 0) + endif() + if(CLANG_FORMAT_VERSION_COUNT GREATER 2) + list(GET clang_format_version 2 CLANG_FORMAT_VERSION_PATCH) + else() + set(CLANG_FORMAT_VERSION_PATCH 0) + endif() + if(CLANG_FORMAT_VERSION_MAJOR LESS 6) + message(FATAL_ERROR "Your installed clang-format version is too old! clang-format v6.0.0 or later is required!") + endif() + else() + message(FATAL_ERROR "Could not detect version of installed clang-format!") + endif() + unset(clang_format_version) +else() + message(FATAL_ERROR "Could not find clang-format! You need to install it first!") +endif() + +if(CLANG_FORMAT_EXECUTABLE) + set(CLANG_FORMAT_FOUND TRUE) +else() + set(CLANG_FORMAT_FOUND FALSE) +endif() diff --git a/FindClangTidy.cmake b/FindClangTidy.cmake new file mode 100644 index 0000000..121a1da --- /dev/null +++ b/FindClangTidy.cmake @@ -0,0 +1,90 @@ +# +#.rst: +# FindClangTidy +# --------------- +# +# The module defines the following variables +# +# ``CLANG_TIDY_EXECUTABLE`` +# Path to clang-tidy executable +# ``CLANG_TIDY_FOUND`` +# True if the clang-tidy executable was found. +# ``CLANG_TIDY_VERSION`` +# The version of clang-tidy found +# ``CLANG_TIDY_VERSION_MAJOR`` +# The clang-tidy major version if specified, 0 otherwise +# ``CLANG_TIDY_VERSION_MINOR`` +# The clang-tidy minor version if specified, 0 otherwise +# ``CLANG_TIDY_VERSION_PATCH`` +# The clang-tidy patch version if specified, 0 otherwise +# ``CLANG_TIDY_VERSION_COUNT`` +# Number of version components reported by clang-tidy +# +# Example usage: +# +# .. code-block:: cmake +# +# find_package(ClangTidy) +# if(CLANG_TIDY_FOUND) +# message("clang-tidy executable found: ${CLANG_TIDY_EXECUTABLE}\n" +# "version: ${CLANG_TIDY_VERSION}") +# endif() + +find_program(CLANG_TIDY_EXECUTABLE + NAMES clang-tidy-6.0 clang-tidy clang-tidy-5.0 + clang-tidy-4.0 clang-tidy-3.9 + clang-tidy-3.8 clang-tidy-3.7 + clang-tidy-3.6 clang-tidy-3.5 + clang-tidy-3.4 clang-tidy-3.3 + DOC "clang-tidy executable" +) +mark_as_advanced(CLANG_TIDY_EXECUTABLE) + +# Extract version from command "clang-tidy -version" +if(CLANG_TIDY_EXECUTABLE) + execute_process(COMMAND sh -c "${CLANG_TIDY_EXECUTABLE} -version | grep version" + OUTPUT_VARIABLE clang_tidy_version + # ERROR_QUIET + OUTPUT_STRIP_TRAILING_WHITESPACE) + + if (clang_tidy_version MATCHES "version .*") + # clang_tidy_version sample: " LLVM version 3.9.1" + string(REGEX REPLACE + " *LLVM version ([.0-9]+).*" "\\1" + CLANG_TIDY_VERSION "${clang_tidy_version}") + # CLANG_TIDY_VERSION sample: "3.9.1" + + # Extract version components + string(REPLACE "." ";" clang_tidy_version "${CLANG_TIDY_VERSION}") + list(LENGTH clang_tidy_version CLANG_TIDY_VERSION_COUNT) + if(CLANG_TIDY_VERSION_COUNT GREATER 0) + list(GET clang_tidy_version 0 CLANG_TIDY_VERSION_MAJOR) + else() + set(CLANG_TIDY_VERSION_MAJOR 0) + endif() + if(CLANG_TIDY_VERSION_COUNT GREATER 1) + list(GET clang_tidy_version 1 CLANG_TIDY_VERSION_MINOR) + else() + set(CLANG_TIDY_VERSION_MINOR 0) + endif() + if(CLANG_TIDY_VERSION_COUNT GREATER 2) + list(GET clang_tidy_version 2 CLANG_TIDY_VERSION_PATCH) + else() + set(CLANG_TIDY_VERSION_PATCH 0) + endif() + if(CLANG_TIDY_VERSION_MAJOR LESS 6) + message(FATAL_ERROR "Your installed clang-tidy version is too old! clang-tidy v6.0.0 or later is required!") + endif() + else() + message(FATAL_ERROR "Could not detect version of installed clang-tidy!") + endif() + unset(clang_tidy_version) +else() + message(FATAL_ERROR "Could not find clang-tidy! You need to install it first!") +endif() + +if(CLANG_TIDY_EXECUTABLE) + set(CLANG_TIDY_FOUND TRUE) +else() + set(CLANG_TIDY_FOUND FALSE) +endif() diff --git a/README.md b/README.md new file mode 100644 index 0000000..0aeb942 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +README +====== + +This repository contains a CMake file which generates a git hook that can format and check your source files before you commit them to your repository. + +Setup +----- + +0. Install the formatters and linters for the file types that you want to format/lint (by default, it is enabled only for C/C++, so only these are mandatory): + - C/C++: `sudo apt install clang-format-6.0 clang-tidy-6.0` + - CMake: `pip install --user cmake_format` + - Python: `pip install --user python-autopep8 pylint` +1. Ensure that `-DCMAKE_EXPORT_COMPILE_COMMANDS=ON` is included in the `cmake_args` of your catkin profile. It is already added to the default profile, but if you have created your own catkin profile, you need to add the flag with `catkin config -a --cmake-args "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"`) +2. If you use `aduulm_cmake_tooks`, you can include the git hooks in any of your packages' toplevel `CMakeList.txt` with a call to `setup_git_hooks()`. Otherwise, you may add the following code block explicitly (not recommended): +```cmake +set(HOOKS_PATH "${CMAKE_BINARY_DIR}/../../root/git_hooks") +if(EXISTS "${HOOKS_PATH}") + add_subdirectory("${HOOKS_PATH}" "${CMAKE_CURRENT_BINARY_DIR}/.hooks") + create_git_hook() +else() + message(WARNING "Could not find git hooks. Git hooks are not active.") +endif() +``` +3. Run `catkin build` or `catkin build PROJECT_NAME` +4. Check that `./run_hooks` and `$(git rev-parse --git-dir)/hooks/pre-commit` exist +5. Add `run_hooks` to your .gitignore file +6. Change some C++ files and see that they are formatted and checked when you commit the changes. + +Format all files +---------------- + +The pre-commit hook only checks and formats changed files. If you want to run it on all files in the repository at once, run `./run_hooks --all` + +Only run formatter or linter +--------------------------------- + + - Formatter: `./run_hooks --all --modes format` + - Linter: `./run_hooks --all --modes lint` + - Both: `./run_hooks --all --modes format,lint` or just `./run_hooks --all` + +Auto-fix linting errors +----------------------- + +To fix linter errors, run `./run_hooks --all --fix` + +Temporarily disable linting +--------------------------- + +If you want to make an intermediate commit and your source code does not compile yet, you can disable linting temporarily by setting the environment variable `LINT` to `0`, e.g.: + +```sh +LINT=0 git commit -m ... +``` + +or + +```sh +export LINT=0 +... +git commit -m ... +git commit -m ... +... +unset LINT +``` + +Configuration +------------- + +You can pass arguments to the `setup_git_hooks` or `create_git_hook` call (`setup_git_hooks` is just a wrapper for `create_git_hook`, so they take the same arguments): +```cmake +# Abort commit when files need formatting +setup_git_hooks(ABORT_COMMIT) + +# defines which tools should be run on pre-commit (comma-separated list of: format, tidy) +setup_git_hooks(MODES "format") + +# comma-separated list of directories to ignore +setup_git_hooks(IGNORE_DIRS "src,include") + +# Style option for clang-format (-style=...) +# You should probably leave this at "file", so that it uses +# the .clang-format file from the sandbox root +setup_git_hooks(CLANG_FORMAT_STYLE "file") + +# comma-separated list of clang-tidy checks to perform +setup_git_hooks(CLANG_TIDY_CHECKS "readability-*,bugprone-*,modernize-*,google-*") + +# You can set the formatters that should be run for all supported file types. +# Here are the defaults: +setup_git_hooks(cpp_FORMATTERS "clang-format") # Allowed: clang-format +setup_git_hooks(cpp_LINTERS "clang-tidy") # Allowed: clang-tidy +setup_git_hooks(cmake_FORMATTERS "") # Allowed: cmake-format +setup_git_hooks(cmake_LINTERS "") +setup_git_hooks(py_FORMATTERS "") # Allowed: autopep8 +setup_git_hooks(py_LINTERS "") # Allowed: pylint +``` + +You can specify multiple options in one call. Remember to re-build your package after changing the `CMakeLists.txt`. There also exist global variants of the above options (except for `IGNORE_DIRS`) under the `GCF_GLOBAL_` prefix, which you can set e.g. in your Catkin profile: + + - `GCF_GLOBAL_ABORT_COMMIT`: Same as `ABORT_COMMIT`, but for all projects + - `GCF_GLOBAL_MODES`: Same as `MODES`, but for all projects + - `GCF_GLOBAL_CLANG_FORMAT_STYLE`: Same as `CLANG_FORMAT_STYLE`, but for all projects + - `GCF_GLOBAL_CLANG_TIDY_CHECKS`: Same as `CLANG_TIDY_CHECKS`, but for all projects + - `GCF_GLOBAL_py_LINTERS`: Same as `py_linters`, but for all projects + +Example in Catkin profile `config.yaml`: + +```yaml +cmake_args: +- -DGCF_GLOBAL_ABORT_COMMIT=TRUE +- -DGCF_GLOBAL_MODES=format +``` + +Note that when you change your catkin config, you need to clean the workspace and re-build your packages. + +Disable a warning for a single line of code +------------------------------------------- + +In case of false positives, you can use a `// NOLINT` comment to signalize to `clang-tidy` that the line does not contain errors. Example: + +```c +return true; // NOLINT + +// or + +// NOLINTNEXTLINE +return true; +``` + +Usage in Projects without CMake +------------------------------- + +If your project does not use CMake (e.g. pure Python projects), but want to use a git hook for auto-formatting, you can use the `init.sh` script to create a minimal CMakeLists.txt file and a shell script for you, which can be used to configure the git hooks. Example: + +```sh +/path/to/aduulm_sandbox/root/git_hooks/init.sh /path/to/project +# Creates CMakeLists.txt and configure_hooks.sh in git root of project +``` +Then adapt CMakeLists.txt e.g. by changing the call to `create_git_hook()` to `create_git_hook(py_FORMATTERS "autopep8")`. Then, run `./configure_hooks.sh`, which will create/update the hooks. + +Caveat +------ + +Because `clang-tidy` can only infer compiler flags for C++ source files and not for header files, header files which are not included by any source file in your project can not be checked. `#include` your header files in a source file if you want them to be checked (or use something like [compdb](https://github.com/Sarcasm/compdb)). + +Problems +-------- + +If the git hooks produce errors about not finding header files and the following: + +``` + Could not auto-detect compilation database from directory "/home/user/aduulm_sandbox/build/package_name" + No compilation database found in /home/user/aduulm_sandbox/build/package_name or any parent directory + fixed-compilation-database: Error while opening fixed database: No such file or directory + json-compilation-database: Error while opening JSON database: No such file or directory +``` + +This means that it can not find the compilation database, which contains the flags which are needed to compile your files. This can occur in 2 cases: + + * You did not build your package before committing or the flags changed since you last built your backage. Build your package with `catkin build package_name`. + * Your package was moved or renamed. To fix this, navigate to the root of your catkin workspace and delete all files generated by the git hooks: + ```bash + find src -name "run_hooks" -exec rm {} \; + find .git -name "*.config.yaml" -exec rm {} \; + find .git -name "pre-commit" -exec rm {} \; + ``` + Then build your package (which will cause the git hooks to re-create the necessary files) and try to commit again. + +Source +------ + +Based on https://github.com/kbenzie/git-cmake-format diff --git a/git-cmake-format.py b/git-cmake-format.py new file mode 100644 index 0000000..98884f2 --- /dev/null +++ b/git-cmake-format.py @@ -0,0 +1,262 @@ +#!/usr/bin/python + +from __future__ import print_function +import os +import subprocess +import sys +import argparse +import yaml +import os +import six +from os.path import normpath, normcase +import fnmatch + +formatter_patterns = { + 'cpp': ['*.h', '*.cpp', '*.hpp', '*.c', '*.cc', '*.hh', '*.cxx', '*.hxx'], + 'cmake': ['CMakeLists.txt', '*.cmake'], # '*.cmake.in', '*.cmake.installspace.in', '*.cmake.develspace.in' + 'py': ['*.py'] +} +linter_patterns = { + 'cpp': ['*.cpp', '*.c', '*.cc', '*.cxx'], + 'cmake': ['CMakeLists.txt', '*.cmake', '*.cmake.in', '*.cmake.installspace.in', '*.cmake.develspace.in'], + 'py': ['*.py'] +} + +def callFormatter(formatter_name, project, files): + if not formatter_name in project.keys() or project[formatter_name] is None: + print("Formatter {} is not configured! Cannot run this formatter!".format(formatter_name)) + sys.exit(1) + if formatter_name == 'clang-format': + _args = [project[formatter_name], '-style', project["clang_format_style"]] + if len(files) == 1 and not isinstance(files[0], six.string_types): + ret = subprocess.Popen(_args, stdin=files[0], stdout=subprocess.PIPE) + return ret.stdout.read() + else: + subprocess.check_call(_args + ['-i'] + files) + elif formatter_name == 'cmake-format': + _args = [project[formatter_name], '--enable-markup', '0'] + if len(files) == 1 and not isinstance(files[0], six.string_types): + ret = subprocess.Popen(_args, stdin=files[0], stdout=subprocess.PIPE) + return ret.stdout.read() + else: + subprocess.check_call(_args + ['-i'] + files) + elif formatter_name == 'autopep8': + _args = [project[formatter_name]] + if len(files) == 1 and not isinstance(files[0], six.string_types): + ret = subprocess.Popen(_args, stdin=files[0], stdout=subprocess.PIPE) + return ret.stdout.read() + else: + subprocess.check_call(_args + ['-i'] + files) + else: + print('Unknown formatter (' + formatter_name + ')!') + raise Exception() + +def callLinter(linter_name, project, files): + if not linter_name in project.keys() or project[linter_name] is None: + print("Linter {} is not configured! Cannot run this linter!".format(linter_name)) + sys.exit(1) + linter_args = [project[linter_name]] + if linter_name == 'clang-tidy': + linter_args.extend(['-checks=' + project["clang_tidy_checks"], '-p', project["builddir"]]) + if args.fix: + linter_args.extend(['--fix', '-format-style', project["clang_format_style"]]) + linter_args.extend(files) + elif linter_name == 'pylint': + linter_args.extend(files) + else: + print('Unknown linter (' + linter_name + ')!') + raise Exception() + subprocess.check_call(linter_args) + +def getGitHead(): + RevParse = subprocess.Popen(['git', 'rev-parse', '--verify', 'HEAD'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + RevParse.communicate() + if RevParse.returncode: + return '4b825dc642cb6eb9a060e54bf8d69288fbee4904' + else: + return 'HEAD' + +def getGitRoot(): + RevParse = subprocess.Popen(['git', 'rev-parse', '--show-toplevel'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return RevParse.stdout.read().decode().strip() + +def getGitDir(): + RevParse = subprocess.Popen(['git', 'rev-parse', '--git-dir'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return RevParse.stdout.read().decode().strip() + +def getEditedFiles(): + Head = getGitHead() + GitArgs = \ + ['git', 'diff-index', '--cached', '--diff-filter=ACMR', '--name-only', Head] \ + if args.mode != 'all' else \ + ['git', 'ls-files', '--cached', '--others', '--exclude-standard'] + DiffIndex = subprocess.Popen(GitArgs, stdout=subprocess.PIPE) + DiffIndexRet = DiffIndex.stdout.read().strip() + DiffIndexRet = DiffIndexRet.decode() + + return DiffIndexRet.split('\n') if DiffIndexRet != "" else [] + +def matchesPattern(File, patterns): + File = os.path.split(File)[1] + return any([fnmatch.fnmatch(File, pattern) for pattern in patterns]) + +def is_subdir(path, directory): + """ + Returns true if *path* in a subdirectory of *directory*. + """ + path = normpath(normcase(path)) + directory = normpath(normcase(directory)) + if len(path) > len(directory): + sep = os.path.sep.encode('ascii') if isinstance(directory, bytes) else sep + if path.startswith(directory.rstrip(sep) + sep): + return True + return False + +def is_ignored(path, ignore_list): + for Dir in ignore_list: + if '' != Dir and '' != os.path.commonprefix([os.path.relpath(path), os.path.relpath(Dir)]): + return True + return False + +def formatFiles(projects): + filesToFormat = [] + for project in projects: + if not hasMode(args, project, 'format'): + continue + for f_type, files in project["formattable_files"].items(): + if len(files) == 0: + continue + for formatter_name in project[f_type + "_formatters"]: + if project["abort_commit"] and args.hook: + filesToFormat.extend([f for f in files if requiresFormat(f, f_type, project)]) + continue + sys.stdout.write('Formatting ' + f_type + ' files of ' + project["name"] + " with " + formatter_name + "... ") + sys.stdout.flush() + callFormatter(formatter_name, project, files) + print('done') + addFiles(files) + return filesToFormat + +def lintFiles(projects): + for project in projects: + if not hasMode(args, project, 'lint'): + continue + for f_type, files in project["source_files"].items(): + if len(files) == 0: + continue + for linter_name in project[f_type + "_linters"]: + sys.stdout.write('Running ' + f_type + ' linter ' + linter_name + ' for ' + project["name"] + "... ") + sys.stdout.flush() + callLinter(linter_name, project, files) + print('done') + addFiles(files) + +def addFiles(files): + if len(files) > 0: + subprocess.check_call(['git', 'add'] + files) + +def hasMode(args, project, mode): + modes = args.modes if not args.hook else project["modes"] + return mode in modes + +def requiresFormat(fileName, f_type, project): + with open(fileName, 'r') as f: + content = f.read() + for formatter in project[f_type + "_formatters"]: + f.seek(0) + formattedContent = callFormatter(formatter, project, [f]) + if formattedContent != content: + return True + return False + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument('--all', dest='mode', action='store_const', + const='all', default='pre-commit', + help='Run on all files, not just commited files') + parser.add_argument('--modes', dest='modes', type=str, + default='format,lint', help='Comma-separated list of tools to run. Possible values: format, lint. Default: format,lint') + parser.add_argument('--fix', dest='fix', action='store_const', const=True, default=False, + help='Allow linters to fix lint errors') + parser.add_argument('--hook', dest='hook', action='store_const', + const=True, default=False, + help='Enables hook mode (respects project modes)') + + args = parser.parse_args() + args.modes = args.modes.split(',') + for mode in args.modes: + if mode != "format" and mode != "lint": + print("Invalid mode: " + mode, file=sys.stderr) + sys.exit(1) + + NoLint = False + if 'LINT' in os.environ.keys() and int(os.environ['LINT']) == 0: + NoLint = True + + GitRoot = getGitRoot() + + EditedFiles = getEditedFiles() + + if len(EditedFiles) < 1: + sys.exit(0) + + EditedFiles = list(map(lambda f: os.path.join(GitRoot, f), EditedFiles)) + + def load_project_config(filename): + with open(filename, 'r') as f: + try: + return yaml.load(f) + except yaml.YAMLError as exc: + print("Could not read project YAML file!") + print(filename) + print(exc) + sys.exit(1) + + GitDir = getGitDir() + projects = [] + for root, dirs, files in os.walk(GitDir): + for name in files: + if name.startswith(".") and name.endswith(".yaml"): + filename = os.path.join(root, name) + projects.append(load_project_config(filename)) + manualConfig = os.path.join(GitDir, "..", "git_hooks_config.yaml") + if os.path.isfile(manualConfig): + projects.append(load_project_config(manualConfig)) + + for project in projects: + project["modes"] = project["modes"].split(',') + for f_type in formatter_patterns.keys(): + project[f_type + "_formatters"] = project[f_type + "_formatters"].split(',') if project[f_type + "_formatters"] is not None else [] + for f_type in linter_patterns.keys(): + project[f_type + "_linters"] = project[f_type + "_linters"].split(',') if project[f_type + "_linters"] is not None else [] + ignore = project["ignore"].split(':') if project["ignore"] is not None else [] + _matchesPattern = lambda patterns: lambda f: is_subdir(f, project["srcdir"]) and \ + not is_ignored(f, ignore) and matchesPattern(f, patterns) + project["formattable_files"] = {} + project["source_files"] = {} + for f_type, patterns in formatter_patterns.items(): + project["formattable_files"][f_type] = filter(_matchesPattern(patterns), EditedFiles) + for f_type, patterns in linter_patterns.items(): + project["source_files"][f_type] = filter(_matchesPattern(patterns), EditedFiles) + total_len = lambda _dict: sum([len(l) for l in _dict.values()]) + projects = filter(lambda p: total_len(p["source_files"]) > 0 or total_len(p["formattable_files"]) > 0, projects) + + try: + filesToFormat = formatFiles(projects) + if len(filesToFormat) > 0: + print('', file=sys.stderr) + print('The following files need formatting and you enabled the \'abort commit\' option. Run \'./run_hooks --modes format\' manually.', file=sys.stderr) + for f in filesToFormat: + print(' ' + f, file=sys.stderr) + sys.exit(1) + if not args.hook or not NoLint: + lintFiles(projects) + except subprocess.CalledProcessError as e: + print('', file=sys.stderr) + print('An error occured, aborting commit...', file=sys.stderr) + sys.exit(1) + + sys.exit(0) diff --git a/init.sh b/init.sh new file mode 100755 index 0000000..e8fb7fe --- /dev/null +++ b/init.sh @@ -0,0 +1,37 @@ +#! /bin/bash +if [ $# -ne 1 ]; then + echo "Usage: init.sh [PATH_TO_REPO_ROOT]" >&2 + exit 1 +fi + +HOOKSPATH="$( cd "$(dirname "$0")" ; pwd -P )" +GIT_DIR=`readlink -f $(git rev-parse --git-dir)` +REPOPATH=`realpath "$1"` +PROJECT_NAME=${REPOPATH##*/} + +if [ -f "$REPOPATH/CMakeLists.txt" ]; then + echo "Folder already contains CMakeLists.txt!" >&2 + exit 1 +fi + +cat <"$REPOPATH/CMakeLists.txt" +cmake_minimum_required(VERSION 2.8.3) +project($PROJECT_NAME) + +add_subdirectory("$HOOKSPATH" ".hooks") +create_git_hook() +EOF + +SCRIPT_PATH=$REPOPATH/configure_hooks.sh +cat <"$SCRIPT_PATH" +#!/bin/bash +CURDIR="\$( cd "\$(dirname "\$0")" ; pwd -P )" +TMP_DIR=/tmp/.git_hooks_build/$PROJECT_NAME +echo -n "Configuring hooks..." +rm -rf "\$TMP_DIR" && \\ + mkdir -p "\$TMP_DIR" && \\ + cd "\$TMP_DIR" && \\ + cmake "\$CURDIR" >/dev/null && echo " done." +EOF + +chmod +x "$SCRIPT_PATH" && "$SCRIPT_PATH" diff --git a/pre-commit.template.sh b/pre-commit.template.sh new file mode 100755 index 0000000..1f482f0 --- /dev/null +++ b/pre-commit.template.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +"@PYTHON_EXECUTABLE@" \ + "@GCF_SCRIPT@" \ + --hook diff --git a/project.template.yaml b/project.template.yaml new file mode 100644 index 0000000..3adc693 --- /dev/null +++ b/project.template.yaml @@ -0,0 +1,22 @@ +name: @GCF_PROJECT_NAME@ +srcdir: @GCF_SOURCE_DIR@ +builddir: @GCF_BUILD_DIR@ +clang_format_style: @GCF_CLANG_FORMAT_STYLE@ +clang_tidy_checks: @GCF_CLANG_TIDY_CHECKS@ +ignore: @GCF_IGNORE_DIRS@ +modes: @GCF_MODES@ +abort_commit: @GCF_ABORT_COMMIT@ + +clang-format: @CLANG_FORMAT_EXECUTABLE@ +clang-tidy: @CLANG_TIDY_EXECUTABLE@ +cmake-format: @GCF_CMAKE_FORMAT_PATH@ +autopep8: @GCF_AUTOPEP_PATH@ +pylint: @GCF_PYLINT_PATH@ + +cpp_formatters: @GCF_cpp_FORMATTERS@ +cmake_formatters: @GCF_cmake_FORMATTERS@ +py_formatters: @GCF_py_FORMATTERS@ + +cpp_linters: @GCF_cpp_LINTERS@ +cmake_linters: @GCF_cmake_LINTERS@ +py_linters: @GCF_py_LINTERS@ diff --git a/run_hooks.template.sh b/run_hooks.template.sh new file mode 100755 index 0000000..1590e4a --- /dev/null +++ b/run_hooks.template.sh @@ -0,0 +1,4 @@ +#!/bin/bash +"@PYTHON_EXECUTABLE@" \ + "@GCF_SCRIPT@" \ + $@