Skip to content

Instantly share code, notes, and snippets.

@zebreus
Last active January 12, 2024 20:43
Show Gist options
  • Save zebreus/5480ed0ce728e49cce2db2523c6fc52b to your computer and use it in GitHub Desktop.
Save zebreus/5480ed0ce728e49cce2db2523c6fc52b to your computer and use it in GitHub Desktop.
Bash pre-commit hook for the incremental adoption of code-formatting in large software projects
#!/usr/bin/env bash
# # Overview
# The goal of the enforce-style script is to make sure that submitters run clang-format before submitting a patch. It does not enforce styling for existing manually formatted files, but will enforce that NEW/MANAGED files that are staged when committing are formatted with clang-format. This way we can incrementally adopt clang-format.
#
# # Managed files
# Managed files are those whose styling is managed by this script. This means that the styling is enforced for these files. A precommit-hook will automatically format these files and stage the result. Gerrit will also automatically format these files and stage the result.
#
# # Incremental adoption
# This script is designed for incremental adoption of styling. It achieves this by differentiating between managed (styling enforced) and ignored (styling not enforced) files. Initially all existing files were added to the list of ignored files. When a staged file matches the clang-format style, it is automatically removed from the list of ignored files and added as a managed file and styling will be enforced in the future. New files are managed by default.
#
# If you want to permanently want to disable styling for a whole directory, you can add it to the `.enforce-style-ignore` file. You can find more details on ignored files in the section below. Ignored files are not automatically formatted, nor is the styling enforced for them. For managed files the styling is enforced in a pre-commit hook and in gerrit.
#
# # Style guidelines
# The style guidelines are defined in `.clang-format`. The file tries to define a style that follows
# the established coreboot style. There are going to be
# some trade-offs in any code formatter. If you find a case where the formatter
# does not do what you want, you can exclude that section of a file as shown below. You should try to avoid this as much as possible and always add a comment explaining why you are excluding that section from formatting.
#
# Example:
# ```
# // Explain why this region is excluded from formatting here.
# // clang-format off
# ... code that should not be formatted ...
# // clang-format on
# ```
#
# # Ignored files
# Files can be ignored by adding them to `.enforce-style-ignore`. The format of
# this file is the same as the [`.gitignore` file](https://git-scm.com/docs/gitignore#_pattern_format). The script will
# automatically ignore all files that are matched by `.enforce-style-ignore`.
#
# Example:
# ```
# # Ignore all files in the src/drivers/amd directory
# /src/drivers/amd
# ```
#
# You can specify a custom ignore file location by setting the `IGNORE_FILE` variable. Files that are ignored by the regular `.gitignore` files are also ignored by enforce-style.
#
# If clang-format is not installed and ignored files have changed, enforce-style will only print a warning that you should install clang-format. Nothing else will happen.
#
# If clang-format is installed, enforce-style will print warnings for ignored files that are not formatted according to the style guidelines and suggest a patch that would format them correctly.
# Also if a ignored staged file matches the style guidelines, it is automatically removed from the list of ignored files and the updated list will be staged.
#
# # Managed files
# Styling is enforced for all managed files.
#
# Files that are not ignored are managed by enforce-style. When a staged managed file is to be committed, the script will automatically format it and stage the result. It will also format the file in the worktree. If the file in the worktree has unstaged changes, it will be formatted independently of the staged file so not to lose the unstaged changes. If you dont want enforce-style to format the worktree, you can set the `NEVER_MODIFY_WORKTREE` variable to `true`. This way only the staging area will be formatted.
#
# # Deleting/renaming/adding/modified files
# A staged file is either deleted, modified, added, or renamed. Only one.
# If a file is deleted it will be removed from the ignore list.
# If a file is created it will be added to the ignore list, if $IGNORE_NEW_FILES is set to true.
# If a file is renamed and on the ignore list, the old one will be removed and the new one will be added.
# If a file is renamed and not on the ignore list, the new one will not be added.
# If a file is modified it will be processed as usual.
#
# # Dependencies
# Besides some common unix tools (bash, diff, awk, sed, tr) this script only depends on git. However, if you want to commit changes to managed files you also need to have clang-format installed. You can either build it from source by runningn `make CPUS=$(nproc) clang` or by installing it with your systems package manager.
#
# If you want to develop this script you also need to install shunit2 to run the tests with `RUN_TESTS=true ./enforce-style`.
#
# # Features
# - [x] Has a mechanism to allow manual formatting of legacy files.
# - [x] Has a mechanism to make sure formatted files stay formatted.
# - [x] Has a mechanism to ignore files.
# - [x] Has an entrypoint for a pre-commit hook.
# - [ ] Has an entrypoint for gerrit or pre-receive hook.
# - [ ] Has an entrypoint for a pre-push hook.
# - [x] Support partially staged files.
# - [ ] Helpful error, info, and warning messages.
# - [x] Suggest patches for legacy files that are not formatted correctly.
# - [x] Automatically add files to the managed list if they are formatted correctly.
# - [x] Error out if managed files are modified but clang-format is not installed.
# - [ ] ~Clean~ Bash code
# - [x] Automated tests
# - [ ] Finished documentation
# - [ ] Has an initial ignore list for all existing files and unformatted directories.
# - [x] Dry run / verify mode
# - [x] The script focuses on formatting the staging area and not the worktree.
# - [x] Defined behaviour for deleting/renaming/adding files
# - [x] Makes sure that the script is in a valid environment (root of the repo)
#
# # Adding more formatters
# The script is designed to support more formatters than just clang-format. If you want to add support for a new formatter, you only need to modify the 'formatting primitives' and the 'formatting primitives helpers' sections. You can find more details in those sections.
#
######## State and configuration variables ########
# State and configuration of this script are tracked in global variables. Configuration variables change the behaviour of the script while the state variables are used internally to track the state of the script.
# This function sets unset config variables to their default values.
function default_config() {
# Path to the clang-format executable.
# If this is not set, the script will try to find it automatically.
CLANG_FORMAT=${CLANG_FORMAT:-}
# If this is set to true, the script will delete old patches.
DELETE_OLD_PATCHES=${DELETE_OLD_PATCHES:-"false"}
# This file contains a list of files that should not be managed by enforce-style.
#
# The format of this file is the same as the format of the .gitignore file.
#
# IGNORE_FILE needs to point to a path relative to the root of the repository.
# The script uses the version of the file in the staging area to determine the list of ignored files.
IGNORE_FILE=${IGNORE_FILE:-"util/lint/.enforce-style-ignore"}
# These are the extensions that will be formatted with clang-format.
DEFAULT_CLANG_FORMAT_EXTENSIONS=(.cpp .cc .c\+\+ .cxx .cppm .ccm .cxxm .c\+\+m .c .cl .h .hh .hpp)
CLANG_FORMAT_EXTENSIONS=("${CLANG_FORMAT_EXTENSIONS[@]:-${DEFAULT_CLANG_FORMAT_EXTENSIONS[@]}}")
# If this is set to true, the script will never modify files in the worktree but only files in the staging area.
#
# So when commiting your staged changes will be always be formatted but the file in your worktree will not be.
NEVER_MODIFY_WORKTREE=${NEVER_MODIFY_WORKTREE:-"false"}
# Print errors instead of fixing problems.
#
# Instead of fixing things, the script will print a errors and exit with a non-zero exit code.
# It will also print more info about what the script is doing.
#
# If this is set to true the script will not modify anything in the worktree or staging area.
#
# `enforce-style check` just sets this variable to true.
VERIFY=${VERIFY:-"false"}
# Add newly created files to the list of ignored files.
IGNORE_NEW_FILES=${IGNORE_NEW_FILES:-"false"}
}
# This function resets the config variables to their default values.
function reset_config() {
unset CLANG_FORMAT
unset DELETE_OLD_PATCHES
unset IGNORE_FILE
unset CLANG_FORMAT_EXTENSIONS
unset NEVER_MODIFY_WORKTREE
default_config
}
# This function resets the state and configuration of the script.
function reset_state() {
# Will be filled with the list of files that have staged changes.
unset MODIFIED_FILES
unset DELETED_FILES
unset CREATED_FILES
unset RENAMED_FILES
# Will be filled with the list of files that are staged but not managed by enforce-style.
unset IGNORED_FILES
# Will be filled with the list of files that are staged and managed by enforce-style.
# MANAGED_FILES will contain the list of files that are staged and managed by enforce-style. It is the difference between MODIFIED_FILES and IGNORED_FILES.
unset MANAGED_FILES
# Will be filled with the list of files that are staged and managed by enforce-style but also have unstaged changes.
unset PARTIALLY_STAGED_MANAGED_FILES
# Will be filled with the list of files that are staged and managed by enforce-style and have no unstaged changes.
unset FULLY_STAGED_MANAGED_FILES
# Will be set to true if an error occured in VERIFY mode.
unset ERROR_OCCURRED
# Adjusts what called functions will print
# Can be "warnings", "errors", or "none". Everything else will be treated as "none".
unset LOGGING_MODE
# Will be set to the files that were removed from the ignore file.
# Used to print a summary at the end of the script.
unset FILES_THAT_WERE_REMOVED_FROM_THE_IGNORE_FILE
# Will be set if populate_new_and_deleted_files was called once to prevent it from being called again.
unset NEW_AND_DELETED_FILES_POPULATED
}
######## Formatting primitives ########
# This section function defines the formatting primitives that are used in the rest of the script.
#
# If you want to add support for a new formatter, you need to modify the functions here. If you need some helper functions, for example to find the formatter, you can add them in the next section after this.
# assert that the required tools for the files are installed.
#
# It is guaranteed that this function is called before any other function in this section for a file. If you need to find the formatter, you can do it here.
#
# Returns 1 and prints errors if the required tools are not installed.
function has_required_tools() {
local filenames=("$@")
local clang_format_files
mapfile -t clang_format_files < <(filter_for_clang_format "${filenames[@]}")
if [[ ${#clang_format_files[@]} -ne 0 ]]; then
if ! populate_clang_format; then
case "$LOGGING_MODE" in
fatal | error)
print_message "$LOGGING_MODE" "files that require formatting with clang-format were changed, but $CLANG_FORMAT_ERROR."
;;
*)
print_message "warning" "files that could be formatted with clang-format were changed, but $CLANG_FORMAT_ERROR."
;;
esac
print_message "note" "To install clang-format either run 'make CPUS=$(nproc) clang' or install it with your system package manager."
ERROR_OCCURRED="true"
return 1
fi
fi
return 0
}
# Check if a file is relevant for enforce-style.
#
# Returns 0 if enforce-style knows how to format the file and 1 otherwise.
function is_relevant_file() {
check_clang_format "$1"
}
# Helper function to format a list of files.
#
# This and format_file_stream are the only functions that actually call clang-format.
#
# Prints an error/warning to stderr if the required formatter is not available.
function format_files() {
local files
files=("$@")
local clang_format_files
mapfile -t clang_format_files < <(filter_for_clang_format "${files[@]}")
if [[ ${#clang_format_files[@]} -ne 0 ]]; then
if verify_clang_format_command; then
"$CLANG_FORMAT" --style=file -i "${clang_format_files[@]}"
else
for file in "${clang_format_files[@]}"; do
print_file_message "$file:1" "auto" "file should be clang-formatted, but $CLANG_FORMAT_ERROR."
done
fi
fi
}
# Helper function to format a single file from stdin and write the result to stdout.
#
# The first argument is the filename. It is only used to determine the formatter.
#
# Prints an error/warning to stderr and returns 1 if the required formatter is not available.
function format_file_pipe() {
local filename
filename="$1"
if check_clang_format "$filename"; then
if verify_clang_format_command; then
"$CLANG_FORMAT" --style=file --assume-filename="$filename" -
return 0
else
print_file_message "$file:1" "auto" "file should be clang-formatted, but $CLANG_FORMAT_ERROR."
cat
return 1
fi
fi
# If no formatter matches: Just print the file
cat
}
# Generate warnings for a file from stdin if it is not formatted correctly.
#
# If LOGGING_MODE is set to error, all formatting warnings will be reported as errors.
function analyze_files() {
local files
files=("$@")
local clang_format_files
mapfile -t clang_format_files < <(filter_for_clang_format "${files[@]}")
if [[ ${#clang_format_files[@]} -ne 0 ]]; then
if verify_clang_format_command; then
local flags
flags=(--dry-run)
if [[ "$LOGGING_MODE" = "error" ]]; then
flags+=(--Werror)
fi
"$CLANG_FORMAT" --style=file "${flags[@]}" "${clang_format_files[@]}"
else
for file in "${clang_format_files[@]}"; do
print_file_message "$file:1" "auto" "file should get checked with clang-format, but $CLANG_FORMAT_ERROR."
done
fi
fi
return 0
}
# Print warnings or errors for a file from stdin if it is not formatted correctly.
#
# The first argument is the filename. It is only used to determine the file type.
#
# If LOGGING_MODE is set to error, all formatting warnings will be reported as errors. In this case the function will return 1 if any errors were printed.
#
# Will return 1 if the formatter is not available.
function analyze_file_pipe() {
local filename="$1"
if check_clang_format "$filename"; then
if verify_clang_format_command; then
local flags
flags=(--dry-run)
if [[ "$LOGGING_MODE" = "error" ]]; then
flags+=(--Werror)
fi
"$CLANG_FORMAT" --style=file "${flags[@]}" --assume-filename="$filename" -
return $?
else
print_file_message "$file:1" "auto" "file should get checked with clang-format, but $CLANG_FORMAT_ERROR."
return 1
fi
fi
}
######## Formatting primitives helpers ########
# These functions are helper functions for the formatting primitives. You can add more helper functions here if you are adding a new formatter.
# Check if a file should be formatted with clang-format.
function check_clang_format() {
matches_extension "$1" "${CLANG_FORMAT_EXTENSIONS[@]}"
}
# Verify that the clang-format command is valid.
#
# In case of an error it returns 1 and the CLANG_FORMAT_ERROR variable is set to a human readable error message.
function verify_clang_format_command() {
local minimum_version="16.0.0"
# Shortcut if the clang-format command is already verified
if [[ -n "$VERIFIED_CLANG_FORMAT" ]] && [[ "$CLANG_FORMAT" == "$VERIFIED_CLANG_FORMAT" ]]; then
return 0
fi
if [[ -z "$CLANG_FORMAT" ]]; then
CLANG_FORMAT_ERROR="no clang-format command was given"
return 1
fi
local version
version=$("$CLANG_FORMAT" --version 2>/dev/null | grep -Eo '[0-9]+\.[0-9]+\.[0-9]')
if [[ -z "$version" ]]; then
CLANG_FORMAT_ERROR="failed to get the version of '$CLANG_FORMAT'"
return 1
fi
if ! version_greater_or_equal "$minimum_version" "$version"; then
CLANG_FORMAT_ERROR="your clang-format version is too old ($version < $minimum_version)"
return 1
fi
VERIFIED_CLANG_FORMAT="$CLANG_FORMAT"
}
# Populate CLANG_FORMAT with the path of the clang-format executable.
#
# If the crossgcc clang-format is found, use that because we know that it is the correct version. Otherwise, use the system clang-format.
#
# In case of an error it returns 1 and the CLANG_FORMAT_ERROR variable is set to a human readable error message.
function populate_clang_format() {
if [[ -n "$CLANG_FORMAT" ]]; then
verify_clang_format_command
return $?
fi
CLANG_FORMAT="$(pwd)/util/crossgcc/xgcc/bin/clang-format"
if verify_clang_format_command; then
return 0
fi
CLANG_FORMAT=$(command -v clang-format)
if check_clang_format; then
return 0
fi
CLANG_FORMAT_ERROR="you do not have clang-format (at least version 16.0.0) installed. Run 'make CPUS=$(nproc) clang' to build clang-format or install it with your package manager."
return 1
}
# Filter a list of files for files that should be formatted with clang-format.
function filter_for_clang_format() {
local files
files=("$@")
local clang_format_files
for file in "${files[@]}"; do
if check_clang_format "$file"; then
echo "$file"
fi
done
}
######## Helper functions ########
# Generic helper functions that are not really specific to this script.
# Check if a version is greater or equal than a reference version.
function version_greater_or_equal() {
local reference="$1"
local version="$2"
# Equal
if [[ "$reference" = "$version" ]]; then
return 0
fi
# Reference is not the greater version
[[ "$reference" != "$(printf "%s\n%s" "$reference" "$version" | sort -Vr | head -n1)" ]]
}
# Change into the toplevel of the current repo. Fail if we are not in a git repo.
function cd_to_repo_root() {
local toplevel
toplevel=$(git rev-parse --show-toplevel)
if [[ $? -ne 0 ]]; then
print_message "fatal" "not in a git repository"
return 1
fi
cd "$toplevel" || return 1
}
# Reference: http://stackoverflow.com/questions/1055671/how-can-i-get-the-behavior-of-gnus-readlink-f-on-a-mac
function canonicalize_filename() {
local target_file=$1
local physical_directory=""
local result=""
# Need to restore the working directory after work.
pushd "$(pwd)" >/dev/null || exit 1
cd "$(dirname "$target_file")" || return 1
target_file=$(basename "$target_file")
# Iterate down a (possible) chain of symlinks
while [ -L "$target_file" ]; do
target_file=$(readlink "$target_file")
cd "$(dirname "$target_file")" || return 1
target_file=$(basename "$target_file")
done
# Compute the canonicalized name by finding the physical path
# for the directory we're in and appending the target file.
physical_directory=$(pwd -P)
result="$physical_directory"/"$target_file"
# restore the working directory after work.
popd >/dev/null || exit 1
echo "$result"
}
# check whether the given file matches any of the set extensions
function matches_extension() {
local filename
filename=$(basename "$1")
shift
local extensions=("$@")
local extension=".${filename##*.}"
local ext
for ext in "${extensions[@]}"; do [[ "$ext" == "$extension" ]] && return 0; done
return 1
}
# Check if a variable is declared. We need to do it this way, because empty arrays are weird in bash.
function is_declared() {
declare -p "$1" >/dev/null 2>&1
}
######## Git helper functions ########
# Helper functions for interacting with git.
# Get the git blob of a staged file.
function git_staged_blob() {
local file=$1
git ls-files -s "$1" | cut -d" " -f2
}
# Get the content of a staged file.
#
# Returns 1 if the file is not checked in.
function git_staged_file_content() {
local file=$1
local blob
blob=$(git_staged_blob "$file")
[[ -z "$blob" ]] && return 1
git show "$blob"
}
# Get the hash of current HEAD commit.
function get_head_commit() {
# git rev-parse failst if there is no previous commit.
# In that case, return the empty tree hash.
git rev-parse --verify HEAD 2>/dev/null || printf "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
}
# Print a list of files that will be added/modified
function get_staged_files() {
git diff-index --cached --diff-filter=CM -M9 --name-only "$(get_head_commit)" --
}
# Print a list of files that have been deleted
function get_deleted_files() {
git diff-index --cached --diff-filter=D -M9 --name-only "$(get_head_commit)" --
}
# Print a list of files that have been added
function get_created_files() {
git diff-index --cached --diff-filter=A -M9 --name-only "$(get_head_commit)" --
}
# Print a list of files that have been renamed
# Every line is like new_name<tab>old_name
function get_renamed_files() {
git diff-index --cached --diff-filter=R -M9 --raw "$(get_head_commit)" -- | cut -f2,3
}
# Test if a staged file also has unstaged changes.
#
# Returns 0 if the file is staged and has unstaged changes and 1 otherwise.
#
# Undefined behaviour if the file is not staged at all.
function is_only_partially_staged() {
local file=$1
test -n "$(git ls-files -m "$file" 2>/dev/null)"
}
######## Formatting helper functions ########
# Wrappers around the formatting primitives defined in the first section.
# Print a diff that transforms the file from stdin into a formatted file.
#
# Prints nothing if the file is already formatted or if the formatting is not applicable.
function diff_against_formatted() {
# Path to a file containing the content that is beeing formatted.
local content
content=$(cat)
# Path to a where to file is located in the repository
local filename
filename="$1"
echo "$content" | format_file_pipe "$filename" |
diff -u <(echo "$content") - |
sed -e "1s|--- .*|--- a/$filename|" -e "2s|+++ .*|+++ b/$filename|"
}
# Create a patch that formats the supplied staged files in the staging area.
#
# This patch can be applied with `git apply --cached` to format the staging area.
function patch_for_files_in_staging_area() {
local files
files=("$@")
for file in "${files[@]}"; do
local blob
blob=$(git_staged_blob "$file")
# shellcheck disable=SC2002
git show "$blob" | diff_against_formatted "$file"
done
}
# Create a patch that formats the supplied files.
#
# This patch can be applied with `git apply` to format the worktree.
function patch_for_files_in_worktree() {
local files
files=("$@")
for file in "${files[@]}"; do
# shellcheck disable=SC2002
cat "$file" | diff_against_formatted "$file"
done
}
# Run clang-format on only in the staging area.
#
# Does not touch the worktree.
function format_files_in_staging_area() {
local files
files=("$@")
populate_clang_format
for file in "${files[@]}"; do
local unformatted_blob
local formatted_blob
local permissions
# Get the blob of the staged file
unformatted_blob=$(git_staged_blob "$file")
permissions=$(git ls-files -s "$file" | cut -d" " -f1)
# Format the blobs contents and load the result into the git object database
formatted_blob=$(git show "$unformatted_blob" | format_file_pipe "$file" | git hash-object -t blob -w --stdin --path "$file")
# Update the staging area with the new blob
git update-index --cacheinfo "$permissions,$formatted_blob,$file"
done
}
# Run clang-format on only in the staging area.
#
# Does not modify the worktree or staging area.
#
# If the first argument is 'error': errors will be printed instead of warnings and 1 will be returned if any errors occured.
function analyze_files_in_staging_area() {
local files=("$@")
populate_clang_format
local no_error="true"
for file in "${files[@]}"; do
local blob
# Get the blob of the staged file
blob=$(git_staged_blob "$file")
git show "$blob" | analyze_file_pipe "$file"
if [[ $? -ne 0 && "$LOGGING_MODE" == "error" ]]; then
no_error="false"
fi
done
"$no_error"
}
######## Message printing ########
# This section contains functions that are used to print messages to the user.
# Check if stderr supports color
function stderr_supports_color() {
[[ -t 2 ]] && [[ "$(tput colors 2>/dev/null)" -ge 8 ]]
}
# Set global variables that are used to print colored messages to stderr.
#
# The variables are set to the ANSI escape codes for the colors if the terminal supports colors and to empty strings otherwise.
function set_color_variables() {
REMARK='\e[34m'
NOTE='\e[36m'
WARNING='\e[35m'
ERROR='\e[31m'
FATAL='\e[31m'
BOLD='\e[1m'
RESET='\e[0m'
if ! stderr_supports_color; then
REMARK=''
NOTE=''
WARNING=''
ERROR=''
FATAL=''
BOLD=''
RESET=''
fi
}
set_color_variables
# Print a message that relates to a file to stderr.
function print_file_message() {
local file="$1"
local type="$2"
local message="$3"
if [[ "$type" = "auto" ]]; then
type=${LOGGING_MODE:-"none"}
fi
if [[ "$LOGGING_MODE" = "none" ]]; then
return 0
fi
local type_color
case "$type" in
remark) type_color="$REMARK" ;;
note) type_color="$NOTE" ;;
warning) type_color="$WARNING" ;;
error) type_color="$ERROR" ;;
fatal) type_color="$FATAL" ;;
*) type_color="$NOTE" ;;
esac
local message_color
case "$type" in
warning | error | fatal) message_color="$BOLD" ;;
*) message_color="" ;;
esac
local file_part
if [[ -n "$file" ]]; then
file_part="$BOLD$file:$RESET "
fi
echo -e "$file_part$type_color$BOLD$type:$RESET $message_color$message$RESET" >&2
}
# Print a message to stderr.
function print_message() {
print_file_message "" "$@"
}
######## Analyze repo state ########
# These functions are used to analyze the state of the repository. They are used to populate the state variables.
# Set the MODIFIED_FILES variable to a list of files that are staged for commit and relevant for enforce-style.
function populate_modified_files() {
declare -ga MODIFIED_FILES=()
while IFS='' read -r staged_file; do
if is_relevant_file "$staged_file"; then
MODIFIED_FILES+=("$staged_file")
fi
done < <(get_staged_files)
}
# Set the DELETED_FILES variable to a list of files that are staged for commit and relevant for enforce-style.
function populate_deleted_files() {
DELETED_FILES=()
while IFS='' read -r staged_file; do
if is_relevant_file "$staged_file"; then
DELETED_FILES+=("$staged_file")
fi
done < <(get_deleted_files)
}
# Set the CREATED_FILES variable to a list of files that are staged for commit and relevant for enforce-style.
function populate_created_files() {
CREATED_FILES=()
while IFS='' read -r staged_file; do
if is_relevant_file "$staged_file"; then
CREATED_FILES+=("$staged_file")
fi
done < <(get_created_files)
}
# Set the RENAMED_FILES variable to a list of files that are staged for renaming and are relevant for enforce-style.
#
# Also sets the RENAMED_FILES_MAP variable to a map of old file names to the new file names.
function populate_renamed_files() {
RENAMED_FILES=()
declare -gA RENAMED_FILES_MAP=()
while IFS='' read -r staged_file; do
local old_name="${staged_file%%$'\t'*}"
local new_name="${staged_file#*$'\t'}"
if is_relevant_file "$new_name"; then
RENAMED_FILES+=("$new_name")
RENAMED_FILES_MAP["$old_name"]="$new_name"
fi
done < <(get_renamed_files)
}
# Print filter the list of given files for those that are ignored by the ignore file.
function filter_for_ignored_files() {
local ignore_file="$1"
shift
local files=("$@")
local flags
if [[ -n "$ignore_file" ]]; then
flags=("-c" "core.excludesFile=$ignore_file")
fi
git "${flags[@]}" check-ignore --no-index "${files[@]}"
}
# Copy the staged ignore file to a temporary file and return its path.
function get_staged_ignores_as_file() {
# Patch can be a perl expression that can be used to modify the ignore file
local patch
patch="${1:-""}"
if [[ -z "$IGNORE_FILE" ]]; then
# No ignore file
return 1
fi
local staged_ignore_file_blob
staged_ignore_file_blob=$(git_staged_blob "$IGNORE_FILE")
if [[ -z "$staged_ignore_file_blob" ]]; then
# Not staged
return 1
fi
local hash
hash=$(echo "$blob$patch" | shasum | cut -f1 -d" ")
temporary_ignore_file="${TMPDIR:-/tmp}/.enforce-style-ignore-$hash"
mkdir -p "${TMPDIR:-/tmp}"
git show "$staged_ignore_file_blob" | ([[ -n "$patch" ]] && perl -pe "$patch" || cat) >"$temporary_ignore_file"
echo "$temporary_ignore_file"
}
# Populate IGNORED_FILES based on IGNORE_FILE and MODIFIED_FILES
#
# IGNORED_FILES will contain the list of files that are staged but not managed by enforce-style.
function populate_ignored_files() {
# Patch can be a perl expression that can be used to update the ignore file in the staging area.
local patch
patch="$1"
if [[ -z "$IGNORE_FILE" ]]; then
IGNORED_FILES=()
return 0
fi
if ! is_declared MODIFIED_FILES; then
populate_modified_files
fi
if [[ "${#MODIFIED_FILES[@]}" -eq 0 ]]; then
IGNORED_FILES=()
return 0
fi
local temporary_ignore_file
temporary_ignore_file="$(get_staged_ignores_as_file "$patch")"
if [[ $? -ne 0 ]]; then
if [[ -f "$IGNORE_FILE" ]]; then
print_file_message "$IGNORE_FILE:1" "warning" "the ignore file will not be used because it is not checked into the repository"
else
print_file_message "$IGNORE_FILE:1" "warning" "the ignore file will not be used because it does not exist"
fi
# No staged ignore file; Nothing to ignore
IGNORED_FILES=()
return 0
fi
mapfile -t IGNORED_FILES < <(filter_for_ignored_files "$temporary_ignore_file" "${MODIFIED_FILES[@]}")
rm "$temporary_ignore_file"
}
# Remove a file from the list of ignored files
#
# Only removes a file if it is explicitly ignored (absolute git path like /foo/bar.c) and not if it is ignored by a pattern (like bar.c) or directory (/foo).
#
# Second argument is the reason why the file is removed from the ignore file. Will be printed to the user.
#
# Third argument is the new filename. If it is set, the file will be renamed in the ignore file.
#
# If the first argument is empty and the second argument is not, the file will always be added to the ignore file.
#
# When renaming a file, the new file will not be added to the ignore file if the old file was not explicitly ignored.
#
# Does nothing if IGNORE_FILE is not set or does not exist.
#
# Will set the UPDATE_IGNORE_FILE_EXPRESSION variable to a perl expression that can be used to update the ignore file in the staging area.
function update_ignore_file() {
local filename
filename="$1"
local reason
reason="${2:-"of reasons"}"
local renamed_filename="$3"
UPDATE_IGNORE_FILE_EXPRESSION=""
local filename_in_gitignore="${filename/#[^\/]//&}"
local new_filename_in_gitignore
local perl_replace_expression='s|^\Q'"$filename_in_gitignore"'\E[\n]?$||g'
local perl_print_line_expression='say $. if m|^\Q'"$filename_in_gitignore"'\E[\n]?$|'
if [[ -n $renamed_filename ]]; then
new_filename_in_gitignore="${renamed_filename/#[^\/]//&}"
if [[ -n $filename ]]; then
# Rename
perl_replace_expression='s|^\Q'"$filename_in_gitignore"'\E$|'"$new_filename_in_gitignore"'|g'
else
# Add
perl_replace_expression='BEGIN{$/=undef};s|([\n]?)$|\n'"$new_filename_in_gitignore"'|'
perl_print_line_expression='say $. if m|^\Q'"$new_filename_in_gitignore"'\E[\n]?$|'
fi
fi
local blob
blob=$(git_staged_blob "$IGNORE_FILE")
if [[ -z "$blob" ]]; then
# No staged ignore file; Nothing to do
return 0
fi
line=$(git show "$blob" | perl -nE "$perl_print_line_expression" | head -n1)
# Early return if it is already removed or added
if [[ -n "$renamed_filename" && -z "$filename" ]]; then
if [[ -n "$line" ]]; then
# New line already exists; Nothing to do
return 0
fi
# shellcheck disable=SC2016
perl_print_line_expression='END{say $x}$x=$.+1;$x=$.if m|^$|'
line=$(git show "$blob" | perl -nE "$perl_print_line_expression" | head -n1)
else
if [[ -z "$line" ]]; then
# Old line is already removed; Nothing to do
return 0
fi
fi
# After this point we only abort if this is a dry run
UPDATE_IGNORE_FILE_EXPRESSION="$perl_replace_expression"
local change_description="unignored"
if [[ -n "$renamed_filename" ]]; then
change_description="changed to '$renamed_filename'"
if [[ -z "$filename" ]]; then
change_description="ignored"
fi
fi
local change_main_file=$filename
if [[ -n "$renamed_filename" && -z "$filename" ]]; then
change_main_file=$renamed_filename
fi
if [[ "$VERIFY" == "true" ]]; then
print_file_message "$IGNORE_FILE:$line" "warning" "'$change_main_file' should be $change_description because $reason"
return 0
fi
# Filter the ignore file in the staging area
local permissions
local modified_blob
permissions=$(git ls-files -s "$IGNORE_FILE" | cut -d" " -f1)
modified_blob=$(git show "$blob" | perl -pe "$perl_replace_expression" | git hash-object -t blob -w --stdin --path "$IGNORE_FILE")
# Remove the line from the ignore file in the staging area
git update-index --cacheinfo "$permissions,$modified_blob,$IGNORE_FILE"
if [[ -f "$IGNORE_FILE" && $NEVER_MODIFY_WORKTREE != "true" ]]; then
# Remove the line from the ignore file in the worktree
perl -i -pe "$perl_replace_expression" "$IGNORE_FILE"
fi
FILES_THAT_WERE_REMOVED_FROM_THE_IGNORE_FILE+=("$filename")
print_file_message "$IGNORE_FILE:$line" "remark" "'$change_main_file' was $change_description because $reason"
}
######## enforce-style core ########
# This section contains the core of the enforce-style script. It is responsible for formatting files and managing the list of managed and ignored files.
# Manage new and deleted files before doing anything else.
#
# Also adds the files to IGNORED_FILES and to MODIFIED_FILES if they are relevant for enforce-style.
#
# If VERIFY is set to true, a heuristic is used to determine if a renamed file was ignored before. This is not 100% accurate, but it is good enough for now.
#
# Adds all managed files that are partially staged to the PARTIALLY_STAGED_MANAGED_FILES array and all managed files that are fully staged to the FULLY_STAGED_MANAGED_FILES array.
#
# A file is partially staged if it is staged but also has unstaged changes. A file is fully staged if it is staged and has no unstaged changes.
function prepare_new_and_deleted_files() {
if [[ "$NEW_AND_DELETED_FILES_POPULATED" = "true" ]]; then
# Already ran once.
# Running it again is not an error, but it is also not necessary.
return 0
fi
# Reset the partially staged files
unset FULLY_STAGED_MANAGED_FILES
unset PARTIALLY_STAGED_MANAGED_FILES
# Force reload of modified files
populate_modified_files
populate_deleted_files
populate_created_files
populate_renamed_files
local update_ignore_file_expression=""
for file in "${DELETED_FILES[@]}"; do
update_ignore_file "$file" "it was deleted"
update_ignore_file_expression+=";$UPDATE_IGNORE_FILE_EXPRESSION"
done
for file in "${CREATED_FILES[@]}"; do
MODIFIED_FILES+=("$file")
if [[ "$IGNORE_NEW_FILES" = "true" ]]; then
update_ignore_file "" "enforce-style is currently configured to ignore all new files" "$file"
update_ignore_file_expression+=";$UPDATE_IGNORE_FILE_EXPRESSION"
fi
done
for previous_file in "${!RENAMED_FILES_MAP[@]}"; do
local new_file="${RENAMED_FILES_MAP["$previous_file"]}"
MODIFIED_FILES+=("$new_file")
update_ignore_file "$previous_file" "the file was renamed" "$new_file"
update_ignore_file_expression+=";$UPDATE_IGNORE_FILE_EXPRESSION"
done
if [[ $VERIFY = "true" ]]; then
# If we are in verify mode, we did not modify the ignore file previously
populate_ignored_files "$update_ignore_file_expression"
else
populate_ignored_files
fi
# Populate managed files
mapfile -t MANAGED_FILES < <(comm -23 <(printf '%s\n' "${MODIFIED_FILES[@]}" | sort) <(printf '%s\n' "${IGNORED_FILES[@]}" | sort))
mapfile -t PARTIALLY_STAGED_MANAGED_FILES < <(git ls-files -m "${MANAGED_FILES[@]}" 2>/dev/null)
mapfile -t FULLY_STAGED_MANAGED_FILES < <(comm -23 <(printf '%s\n' "${MANAGED_FILES[@]}" | sort) <(printf '%s\n' "${PARTIALLY_STAGED_MANAGED_FILES[@]}" | sort))
NEW_AND_DELETED_FILES_POPULATED="true"
}
# Format managed files with clang-format and stage the result
function format_managed_files() {
prepare_new_and_deleted_files
if [[ "${#MANAGED_FILES[@]}" -eq 0 ]]; then
return 0
fi
LOGGING_MODE="fatal"
if ! has_required_tools "${MANAGED_FILES[@]}"; then
return 1
fi
LOGGING_MODE="error"
if [[ "true" = "$VERIFY" ]]; then
analyze_files_in_staging_area "${MANAGED_FILES[@]}"
if [[ $? -ne 0 ]]; then
ERROR_OCCURRED="true"
return 1
fi
return 0
fi
if [[ "true" = "$NEVER_MODIFY_WORKTREE" ]]; then
# Only format the staging area, not the worktree.
format_files_in_staging_area "${MANAGED_FILES[@]}"
return 0
fi
# When the files are fully staged, we can just format them and stage the result.
# This way we only need to call clang-format once.
if [[ ${#FULLY_STAGED_MANAGED_FILES[@]} -ne 0 ]]; then
format_files "${FULLY_STAGED_MANAGED_FILES[@]}"
git add "${FULLY_STAGED_MANAGED_FILES[@]}" >/dev/null 2>&1
fi
# When the files are only partially staged, we need to format the staging area and the worktree separately.
if [[ ${#PARTIALLY_STAGED_MANAGED_FILES[@]} -ne 0 ]]; then
# Format and update staging area without touching the worktree
format_files_in_staging_area "${PARTIALLY_STAGED_MANAGED_FILES[@]}"
# Separatly format the files in the worktree
format_files "${PARTIALLY_STAGED_MANAGED_FILES[@]}"
fi
}
# Process unmanaged files and stage the result
function process_ignored_files() {
prepare_new_and_deleted_files
if [[ "${#IGNORED_FILES[@]}" -eq 0 ]]; then
return 0
fi
LOGGING_MODE="warning"
if ! has_required_tools; then
print_message "note" "The currently staged files are excluded from formatting, but keep in mind that you need clang-format if you want to commit files where styling is enforced."
fi
# Print warnings for ignored files that are not formatted correctly
LOGGING_MODE="warning"
analyze_files_in_staging_area "${IGNORED_FILES[@]}"
# Delete old patches
if [[ "true" = "$DELETE_OLD_PATCHES" ]]; then
rm -f "${TMPDIR:-/tmp}"/enforce-style-*.patch
fi
# Offer patches for ignored files that are not formatted correctly
local worktree_patch
local staged_patch
local worktree_patch_file
local staged_patch_file
worktree_patch=$(patch_for_files_in_worktree "${IGNORED_FILES[@]}")
staged_patch=$(patch_for_files_in_staging_area "${IGNORED_FILES[@]}")
if [[ -n "$worktree_patch" ]]; then
worktree_patch_file="${TMPDIR:-/tmp}/enforce-style-$(echo "$worktree_patch" | sha1sum | head -c10).patch"
echo "$worktree_patch" >"$worktree_patch_file"
fi
if [[ -n "$staged_patch" ]]; then
staged_patch_file="${TMPDIR:-/tmp}/enforce-style-$(echo "$staged_patch" | sha1sum | head -c10).patch"
echo "$staged_patch" >"$staged_patch_file"
fi
if [[ -n "$worktree_patch_file" && "$worktree_patch_file" = "$staged_patch_file" ]]; then
print_message "remark" "apply this patch to fix these warnings: 'git apply --index \"$worktree_patch_file\"'"
else
if [[ -n "$staging_patch_file" ]]; then
print_message "remark" "apply this patch to fix the styling for your staged changes: 'git apply --cached \"$worktree_patch_file\"'"
fi
if [[ -n "$worktree_patch_file" ]]; then
print_message "remark" "your worktree contains partially staged files so you need a separate patch for that: 'git apply \"$worktree_patch_file\"'"
fi
fi
# Remove files from the ignore list if they match the style guidelines
LOGGING_MODE="error"
for file in "${IGNORED_FILES[@]}"; do
local blob
blob=$(git_staged_blob "$file")
if git show "$blob" | analyze_file_pipe "$file" 2>/dev/null; then
update_ignore_file "$file" "it matches the coreboot style"
fi
done
}
function main() {
# Load the default values for all unset config variables
default_config
reset_state
# Make sure we are in the root of the repository
cd_to_repo_root || return 1
# case "$1" in
case "$1" in
enforce | "")
format_managed_files
if [[ "true" = "$ERROR_OCCURRED" ]]; then
return 1
fi
process_ignored_files
if [[ "true" = "$ERROR_OCCURRED" ]]; then
return 1
fi
;;
check)
VERIFY="true"
format_managed_files
if [[ "true" = "$ERROR_OCCURRED" ]]; then
return 1
fi
process_ignored_files
if [[ "true" = "$ERROR_OCCURRED" ]]; then
return 1
fi
;;
*)
echo "Usage: $0 {enforce|check}" >&2
return 1
;;
esac
if [[ ${#MANAGED_FILES[@]} -ne 0 ]]; then
echo "Enforced formatting for ${#MANAGED_FILES[@]} files." >&2
fi
if [[ ${#IGNORED_FILES[@]} -ne 0 ]]; then
echo "Checked formatting for ${#IGNORED_FILES[@]} files." >&2
fi
if [[ ${#FILES_THAT_WERE_REMOVED_FROM_THE_IGNORE_FILE[@]} -ne 0 ]]; then
echo "Ensuring correct formatting for ${#FILES_THAT_WERE_REMOVED_FROM_THE_IGNORE_FILE[@]} new files." >&2
fi
}
######## Entrypoint ########
if test "${RUN_TESTS}" != "true"; then
main "$@"
exit $?
else
######## Tests ########
# enforce-style uses shunit2 for testing. You can run the tests with `RUN_TESTS=true ./enforce-style`.
function test_process_ignored_files_removes_deleted_file_from_ignores_when_enforcing() {
setup_test_repo
IGNORE_FILE=".enforce-style-ignore"
cat <<EOF >"$IGNORE_FILE"
/unformatted.cpp
EOF
git add "$IGNORE_FILE" >/dev/null 2>&1
git add unformatted.cpp >/dev/null 2>&1
git commit -m "Add ignored file" >/dev/null 2>&1
git rm unformatted.cpp >/dev/null 2>&1
VERIFY=false
process_ignored_files 2>/dev/null
# # Assert that the output contains a information that the file was removed from the ignore list
# assertContains "$output" "removed"
# Assert that the file was removed from the ignore list
local ignore_file_content
mapfile -t ignore_file_content <"$IGNORE_FILE"
assertArrayNotContains "/unformatted.cpp" "${ignore_file_content[@]}"
# Assert that the file was removed from the staged ignore list
mapfile -t ignore_file_content < <(git_staged_file_content "$IGNORE_FILE")
assertArrayNotContains "/unformatted.cpp" "${ignore_file_content[@]}"
}
function test_update_ignore_file_works() {
setup_test_repo
IGNORE_FILE=".enforce-style-ignore"
cat <<EOF >"$IGNORE_FILE"
/foo.c
/bar.c
/baz.c
EOF
git add "$IGNORE_FILE" >/dev/null 2>&1
local ignore_file_content
update_ignore_file "bar.c" 2>/dev/null
mapfile -t ignore_file_content < <(cat "$IGNORE_FILE")
assertArrayContains "/foo.c" "${ignore_file_content[@]}"
assertArrayNotContains "/bar.c" "${ignore_file_content[@]}"
assertArrayContains "/baz.c" "${ignore_file_content[@]}"
update_ignore_file "/foo.c" 2>/dev/null
mapfile -t ignore_file_content < <(cat "$IGNORE_FILE")
assertArrayNotContains "/foo.c" "${ignore_file_content[@]}"
assertArrayNotContains "/bar.c" "${ignore_file_content[@]}"
assertArrayContains "/baz.c" "${ignore_file_content[@]}"
update_ignore_file "baz.c" 2>/dev/null
mapfile -t ignore_file_content < <(cat "$IGNORE_FILE")
assertArrayNotContains "/foo.c" "${ignore_file_content[@]}"
assertArrayNotContains "/bar.c" "${ignore_file_content[@]}"
assertArrayNotContains "/baz.c" "${ignore_file_content[@]}"
assertEquals 0 "$(wc -l <"$IGNORE_FILE")"
}
function test_update_ignore_file_can_rename() {
setup_test_repo
IGNORE_FILE=".enforce-style-ignore"
cat <<EOF >"$IGNORE_FILE"
/foo.c
/bar.c
/baz.c
EOF
git add "$IGNORE_FILE" >/dev/null 2>&1
local ignore_file_content
update_ignore_file "bar.c" "" "rab.c" 2>/dev/null
mapfile -t ignore_file_content < <(cat "$IGNORE_FILE")
assertArrayContains "/foo.c" "${ignore_file_content[@]}"
assertArrayNotContains "/bar.c" "${ignore_file_content[@]}"
assertArrayContains "/baz.c" "${ignore_file_content[@]}"
assertArrayNotContains "/oof.c" "${ignore_file_content[@]}"
assertArrayContains "/rab.c" "${ignore_file_content[@]}"
assertArrayNotContains "/zab.c" "${ignore_file_content[@]}"
update_ignore_file "/foo.c" "" "/oof.c" 2>/dev/null
mapfile -t ignore_file_content < <(cat "$IGNORE_FILE")
assertArrayNotContains "/foo.c" "${ignore_file_content[@]}"
assertArrayNotContains "/bar.c" "${ignore_file_content[@]}"
assertArrayContains "/baz.c" "${ignore_file_content[@]}"
assertArrayContains "/oof.c" "${ignore_file_content[@]}"
assertArrayContains "/rab.c" "${ignore_file_content[@]}"
assertArrayNotContains "/zab.c" "${ignore_file_content[@]}"
update_ignore_file "baz.c" "" "/zab.c" 2>/dev/null
mapfile -t ignore_file_content < <(cat "$IGNORE_FILE")
assertArrayNotContains "/foo.c" "${ignore_file_content[@]}"
assertArrayNotContains "/bar.c" "${ignore_file_content[@]}"
assertArrayNotContains "/baz.c" "${ignore_file_content[@]}"
assertArrayContains "/oof.c" "${ignore_file_content[@]}"
assertArrayContains "/rab.c" "${ignore_file_content[@]}"
assertArrayContains "/zab.c" "${ignore_file_content[@]}"
assertEquals "$(wc -l <"$IGNORE_FILE")" 3
}
function test_update_ignore_file_can_add_a_new_file() {
setup_test_repo
IGNORE_FILE=".enforce-style-ignore"
cat <<EOF >"$IGNORE_FILE"
/foo.c
EOF
git add "$IGNORE_FILE" >/dev/null 2>&1
local ignore_file_content
update_ignore_file "" "" "bar.c" 2>/dev/null
mapfile -t ignore_file_content < <(cat "$IGNORE_FILE")
assertArrayContains "/foo.c" "${ignore_file_content[@]}"
assertArrayContains "/bar.c" "${ignore_file_content[@]}"
assertArrayNotContains "/baz.c" "${ignore_file_content[@]}"
assertEquals "${#ignore_file_content[@]}" 2
update_ignore_file "" "" "bar.c" 2>/dev/null
mapfile -t ignore_file_content < <(cat "$IGNORE_FILE")
assertEquals "${#ignore_file_content[@]}" 2
update_ignore_file "" "" "bar.c" 2>/dev/null
mapfile -t ignore_file_content < <(cat "$IGNORE_FILE")
assertEquals "${#ignore_file_content[@]}" 2
update_ignore_file "" "" "baz.c" 2>/dev/null
mapfile -t ignore_file_content < <(cat "$IGNORE_FILE")
assertArrayContains "/foo.c" "${ignore_file_content[@]}"
assertArrayContains "/bar.c" "${ignore_file_content[@]}"
assertArrayContains "/baz.c" "${ignore_file_content[@]}"
assertEquals "${#ignore_file_content[@]}" 3
}
function test_update_ignore_file_prints_messages() {
setup_test_repo
IGNORE_FILE=".enforce-style-ignore"
cat <<EOF >"$IGNORE_FILE"
/foo.c
/bar.c
/baz.c
EOF
git add "$IGNORE_FILE" >/dev/null 2>&1
local ignore_file_content
# Test remove messages
messages=$(update_ignore_file "foo.c" "toast 12345" 2>&1)
assertContains "$messages" "unignored"
assertContains "$messages" "toast 12345"
# Test rename messages
messages=$(update_ignore_file "bar.c" "testtesttest messssssage" "rab.c" 2>&1)
assertContains "$messages" "changed"
assertContains "$messages" "testtesttest messssssage"
# Test already removed messages
messages=$(update_ignore_file "foo.c" "abcdefg" 2>&1)
assertNotContains "$messages" "unignored"
assertNotContains "$messages" "changed"
assertNotContains "$messages" "abcdefg"
}
function test_analyze_files_in_staging_area_fails_when_there_are_unformatted_managed_files() {
setup_test_repo
git add unformatted.cpp >/dev/null 2>&1
LOGGING_MODE="error"
analyze_files_in_staging_area unformatted.cpp 2>/dev/null
assertEquals 1 $?
}
function test_analyze_files_in_staging_area_succeeds_when_there_are_only_formatted_managed_files() {
setup_test_repo
format_files unformatted.cpp >/dev/null 2>&1
git add unformatted.cpp >/dev/null 2>&1
LOGGING_MODE="error"
analyze_files_in_staging_area unformatted.cpp
assertEquals 0 $?
}
function test_analyze_files_in_staging_area_succeeds_when_all_staged_files_are_formatted_even_if_the_unstaged_versions_are_not() {
setup_test_repo
git add unformatted.cpp >/dev/null 2>&1
format_files_in_staging_area unformatted.cpp >/dev/null 2>&1
LOGGING_MODE="error"
analyze_files_in_staging_area unformatted.cpp 2>/dev/null
assertEquals 0 $?
}
# Setup a test repo with an initial commit and a few unstaged files
function setup_test_repo() {
cd "$(mktemp -dp "$SHUNIT_TMPDIR")" || fail
git init >/dev/null 2>&1
git config --local user.email "[email protected]"
git config --local user.name "test"
IGNORE_FILE=".enforce-style-ignore"
touch "$IGNORE_FILE"
cat <<EOF >"formatted.cpp"
int main(int argc, char *argv[]) {
return 0;
}
EOF
cat <<EOF >"unformatted.cpp"
int main(int argc,
char *argv[]) {
return 0;
}
int formattedA(int argc, char *argv[]) { return 0; }
int formattedB(int argc, char *argv[]) { return 0; }
int another_unformatted_fn
(int argc, char *argv[]) {
return 0;
}
int formattedC(int argc, char *argv[]) { return 0; }
EOF
echo "Test repo" >README.md
git add "$IGNORE_FILE" README.md >/dev/null 2>&1
git commit -m "Initial commit" >/dev/null 2>&1
}
function test_matches_extension() {
assertTrue "matches_extension foo.c .c .hpp .cpp"
assertTrue "matches_extension foo.cpp .c .hpp .cpp"
assertFalse "matches_extension foo.sh .c .hpp .cpp"
assertTrue "matches_extension foo.sh .sh"
assertFalse "matches_extension foo.cpp .sh"
}
function test_populate_renamed_files() {
setup_test_repo
git add unformatted.cpp >/dev/null 2>&1
git commit -m "Add file" >/dev/null 2>&1
git mv unformatted.cpp unformatted2.cpp >/dev/null 2>&1
populate_renamed_files
assertEquals "unformatted2.cpp" "${RENAMED_FILES[0]}"
assertEquals "unformatted2.cpp" "${RENAMED_FILES_MAP[unformatted.cpp]}"
assertEquals 1 "${#RENAMED_FILES[@]}"
}
function test_get_head_commit_seems_to_work() {
cd "$(mktemp -dp "$SHUNIT_TMPDIR")" || fail
git init >/dev/null 2>&1
assertEquals "4b825dc642cb6eb9a060e54bf8d69288fbee4904" "$(get_head_commit)"
touch file.cpp
git add file.cpp >/dev/null 2>&1
git commit -m "Initial commit" >/dev/null 2>&1
assertNotEquals "4b825dc642cb6eb9a060e54bf8d69288fbee4904" "$(get_head_commit)"
}
function test_get_staged_files_seems_to_work() {
setup_test_repo
# No staged files; nothing to do
reset_state
populate_modified_files
assertEquals 0 ${#MODIFIED_FILES[@]}
# One added staged file; should not be detected as by populate_modified_files
touch file.cpp
git add file.cpp >/dev/null 2>&1
reset_state
populate_modified_files
assertEquals ${#MODIFIED_FILES[@]} 0
# No staged files again
git commit -m "Initial commit" >/dev/null 2>&1
reset_state
populate_modified_files
assertEquals ${#MODIFIED_FILES[@]} 0
# One modified staged file; should be detected as by populate_modified_files
echo "foo" >file.cpp
git add file.cpp >/dev/null 2>&1
reset_state
populate_modified_files
assertEquals ${#MODIFIED_FILES[@]} 1
assertEquals "file.cpp" "${MODIFIED_FILES[0]}"
}
function test_populate_ignored_files() {
setup_test_repo
cat <<EOF >"$IGNORE_FILE"
/foo.c
/bar.c
/toast/bar.c
/test/*.c
!/test/baz.c
EOF
git add "$IGNORE_FILE" >/dev/null 2>&1
MODIFIED_FILES=(foo.c bar.c baz.c toast/foo.c toast/bar.c test/foo.c test/baz.c test/bar.c)
populate_ignored_files
assertArrayContains "foo.c" "${IGNORED_FILES[@]}"
assertArrayContains "bar.c" "${IGNORED_FILES[@]}"
assertArrayNotContains "baz.c" "${IGNORED_FILES[@]}"
assertArrayNotContains "toast/foo.c" "${IGNORED_FILES[@]}"
assertArrayContains "toast/bar.c" "${IGNORED_FILES[@]}"
assertArrayContains "test/foo.c" "${IGNORED_FILES[@]}"
assertArrayNotContains "test/baz.c" "${IGNORED_FILES[@]}"
assertArrayContains "test/bar.c" "${IGNORED_FILES[@]}"
}
function test_prepare_new_and_deleted_files_detects_new_files() {
setup_test_repo
cat <<EOF >"$IGNORE_FILE"
/foo.c
/bar.c
/toast/bar.c
/test/*.c
!/test/baz.c
EOF
git add "$IGNORE_FILE" >/dev/null 2>&1
mkdir -p toast
mkdir -p test
# shellcheck disable=SC2002
cat unformatted.cpp | tee foo.c bar.c baz.c toast/foo.c toast/bar.c test/foo.c test/baz.c test/bar.c >/dev/null
git add foo.c bar.c baz.c toast/foo.c toast/bar.c test/foo.c test/baz.c test/bar.c >/dev/null 2>&1
prepare_new_and_deleted_files
assertArrayNotContains "foo.c" "${MANAGED_FILES[@]}"
assertArrayNotContains "bar.c" "${MANAGED_FILES[@]}"
assertArrayContains "baz.c" "${MANAGED_FILES[@]}"
assertArrayContains "toast/foo.c" "${MANAGED_FILES[@]}"
assertArrayNotContains "toast/bar.c" "${MANAGED_FILES[@]}"
assertArrayNotContains "test/foo.c" "${MANAGED_FILES[@]}"
assertArrayContains "test/baz.c" "${MANAGED_FILES[@]}"
assertArrayNotContains "test/bar.c" "${MANAGED_FILES[@]}"
}
function test_prepare_new_and_deleted_files_renames_ignores_on_file_rename() {
setup_test_repo
cat <<EOF >"$IGNORE_FILE"
/unformatted.cpp
EOF
git add "$IGNORE_FILE" >&/dev/null
git add unformatted.cpp >&/dev/null
reset_state
prepare_new_and_deleted_files >&/dev/null
assertArrayContains "unformatted.cpp" "${IGNORED_FILES[@]}"
assertArrayNotContains "unformatted2.cpp" "${IGNORED_FILES[@]}"
git commit -m test >&/dev/null
git mv unformatted.cpp unformatted2.cpp >&/dev/null
reset_state
prepare_new_and_deleted_files >&/dev/null
assertArrayNotContains "unformatted.cpp" "${IGNORED_FILES[@]}"
assertArrayContains "unformatted2.cpp" "${IGNORED_FILES[@]}"
}
function test_prepare_new_and_deleted_files_works_without_staged_files() {
setup_test_repo
git commit -m "Make sure there are no staged files" >/dev/null 2>&1
prepare_new_and_deleted_files
assertEquals 0 ${#MANAGED_FILES[@]}
}
function test_patch_produces_same_result_as_directly_formatting() {
setup_test_repo
cp unformatted.cpp directly_formatted.cpp
cp unformatted.cpp patch_formatted.cpp
# Directly format the file as a reference.
format_files directly_formatted.cpp
# Format the file using a patch
local patch
# shellcheck disable=SC2002
patch=$(cat patch_formatted.cpp | diff_against_formatted patch_formatted.cpp)
git apply <(echo "$patch")
# Ensure that the result is the same as the directly formatted file.
assertEquals "$(cat patch_formatted.cpp)" "$(cat directly_formatted.cpp)"
}
function test_patch_works_when_there_is_nothing_wrong() {
setup_test_repo
# Directly format the file
format_files unformatted.cpp
# Generate a patch
local patch
# shellcheck disable=SC2002
patch=$(cat unformatted.cpp | diff_against_formatted unformatted.cpp)
# Make sure the patch is empty
assertEquals "$patch" ""
}
function test_is_only_partially_staged() {
cd "$(mktemp -dp "$SHUNIT_TMPDIR")" || fail
git init >/dev/null 2>&1
echo "foo" >file.cpp
git add file.cpp &>/dev/null
reset_state
prepare_new_and_deleted_files 2>/dev/null
assertArrayNotContains "file.cpp" "${PARTIALLY_STAGED_MANAGED_FILES[@]}"
assertArrayContains "file.cpp" "${FULLY_STAGED_MANAGED_FILES[@]}"
echo "bar" >>file.cpp
reset_state
prepare_new_and_deleted_files 2>/dev/null
assertArrayContains "file.cpp" "${PARTIALLY_STAGED_MANAGED_FILES[@]}"
assertArrayNotContains "file.cpp" "${FULLY_STAGED_MANAGED_FILES[@]}"
}
function test_format_managed_files_works_for_managed_fully_staged_files() {
setup_test_repo
local unformatted_hash
unformatted_hash=$(shasum unformatted.cpp)
# Unstaged files should not be formatted.
format_managed_files
assertEquals "$unformatted_hash" "$(shasum unformatted.cpp)"
# shellcheck disable=SC2046 # Should be fine in tests
assertArrayContains "unformatted.cpp" $(git ls-files -z -om --exclude-standard | tr '\0' ' ')
reset_state
# Fully staged files should be formatted.
git add unformatted.cpp >/dev/null 2>&1
format_managed_files
# bash
# The file should have been formatted as it is staged.
assertNotEquals "$unformatted_hash" "$(shasum unformatted.cpp)"
# Make sure the changes are staged.
# shellcheck disable=SC2046 # Should be fine in tests
assertArrayNotContains "unformatted.cpp" $(git ls-files -z -om --exclude-standard | tr '\0' ' ')
# Partially staged files should get formatted only in the staging area.
}
function test_format_managed_files_works_for_managed_partially_staged_files() {
setup_test_repo
cp unformatted.cpp partially.cpp
git add partially.cpp >/dev/null 2>&1
printf "\nint other() { return 3; }" >>partially.cpp
local before_unstaged_hash
before_unstaged_hash=$(shasum partially.cpp)
local before_staged_blob
before_staged_blob=$(git_staged_blob partially.cpp)
# Workdir should not change for partially staged files.
format_managed_files
local after_unstaged_hash
after_unstaged_hash=$(shasum partially.cpp)
local after_staged_blob
after_staged_blob=$(git_staged_blob partially.cpp)
assertNotEquals "$before_unstaged_hash" "$after_unstaged_hash"
assertNotEquals "$before_staged_blob" "$after_staged_blob"
assertNotEquals "$(cat partially.cpp)" "$(git show "$after_staged_blob")"
reset_state
git add partially.cpp >/dev/null 2>&1
format_managed_files
local final_unstaged_hash
final_unstaged_hash=$(shasum partially.cpp)
local final_staged_blob
final_staged_blob=$(git_staged_blob partially.cpp)
assertEquals "$after_unstaged_hash" "$final_unstaged_hash"
assertEquals "$(cat partially.cpp)" "$(git show "$final_staged_blob")"
assertNotEquals "$after_staged_blob" "$final_staged_blob"
}
function test_format_managed_files_respects_never_modify_worktree_config() {
setup_test_repo
NEVER_MODIFY_WORKTREE="true"
cp unformatted.cpp partially.cpp
git add partially.cpp >/dev/null 2>&1
printf "\nint other() { return 3; }" >>partially.cpp
local before_unstaged_hash
before_unstaged_hash=$(shasum partially.cpp)
local before_staged_blob
before_staged_blob=$(git_staged_blob partially.cpp)
# Workdir should not change for partially staged files.
format_managed_files
local after_unstaged_hash
after_unstaged_hash=$(shasum partially.cpp)
local after_staged_blob
after_staged_blob=$(git_staged_blob partially.cpp)
assertEquals "$before_unstaged_hash" "$after_unstaged_hash"
assertNotEquals "$before_staged_blob" "$after_staged_blob"
assertNotEquals "$(cat partially.cpp)" "$(git show "$after_staged_blob")"
reset_state
git add partially.cpp >/dev/null 2>&1
format_managed_files
local final_unstaged_hash
final_unstaged_hash=$(shasum partially.cpp)
local final_staged_blob
final_staged_blob=$(git_staged_blob partially.cpp)
assertEquals "$before_unstaged_hash" "$final_unstaged_hash"
assertNotEquals "$(cat partially.cpp)" "$(git show "$final_staged_blob")"
assertNotEquals "$after_staged_blob" "$final_staged_blob"
}
function test_process_ignored_files_does_not_change_files() {
setup_test_repo
IGNORE_FILE=".enforce-style-ignore"
cat <<EOF >"$IGNORE_FILE"
/unformatted.cpp
EOF
git add "$IGNORE_FILE" >/dev/null 2>&1
cp unformatted.cpp unformatted_old.cpp
git add unformatted.cpp >/dev/null 2>&1
process_ignored_files 2>/dev/null
assertEquals "$(cat unformatted_old.cpp)" "$(cat unformatted.cpp)"
}
function test_process_ignored_files_outputs_patch() {
setup_test_repo
cat <<EOF >"$IGNORE_FILE"
/unformatted.cpp
EOF
git add "$IGNORE_FILE" >/dev/null 2>&1
cp unformatted.cpp unformatted_old.cpp
git add unformatted.cpp >/dev/null 2>&1
local output
output=$(process_ignored_files 2>&1)
# Assert that the output contains something that looks like a patch recommendation
assertContains "$output" "git apply"
}
function test_process_ignored_files_outputs_warnings() {
setup_test_repo
cat <<EOF >"$IGNORE_FILE"
/unformatted.cpp
EOF
git add "$IGNORE_FILE" >/dev/null 2>&1
cp unformatted.cpp unformatted_old.cpp
git add unformatted.cpp >/dev/null 2>&1
local output
output=$(process_ignored_files 2>&1)
# Assert that the output contains something that looks like a warning
assertContains "$output" "warning"
}
function test_process_ignored_files_removes_already_formatted_file_from_ignores() {
setup_test_repo
IGNORE_FILE=".enforce-style-ignore"
cat <<EOF >"$IGNORE_FILE"
/unformatted.cpp
EOF
git add "$IGNORE_FILE" >/dev/null 2>&1
cp unformatted.cpp unformatted_old.cpp
format_files unformatted.cpp
git add unformatted.cpp >/dev/null 2>&1
output=$(process_ignored_files 2>&1)
# Assert that the output contains a information that the file was removed from the ignore list
assertContains "$output" "coreboot style"
# Assert that the file was removed from the ignore list
local ignore_file_content
mapfile -t ignore_file_content <"$IGNORE_FILE"
assertArrayNotContains "/unformatted.cpp" "${ignore_file_content[@]}"
# Assert that the file was removed from the staged ignore list
mapfile -t ignore_file_content < <(git_staged_file_content "$IGNORE_FILE")
assertArrayNotContains "/unformatted.cpp" "${ignore_file_content[@]}"
}
function test_format_files_does_not_modify_files_with_the_wrong_extension() {
setup_test_repo
cp unformatted.cpp unformatted
format_files unformatted
assertEquals "$(cat unformatted)" "$(cat unformatted.cpp)"
}
function test_format_managed_files_does_not_modify_files_with_the_wrong_extension() {
setup_test_repo
cat <<EOF >"$IGNORE_FILE"
/unformatted.cpp
EOF
cp unformatted.cpp unformatted
git add unformatted >/dev/null 2>&1
format_managed_files
assertEquals "$(cat unformatted)" "$(cat unformatted.cpp)"
}
function test_version_comparison() {
assertTrue 'version_greater_or_equal "16.0.0" "16.0.0"'
assertTrue 'version_greater_or_equal "16.0.0" "16.0.1"'
assertTrue 'version_greater_or_equal "16.0.0" "17.0.1"'
assertTrue 'version_greater_or_equal "16.0.0" "111.0.1"'
assertTrue 'version_greater_or_equal "16.0.0" "17"'
assertFalse 'version_greater_or_equal "16.0.1" "16"'
assertFalse 'version_greater_or_equal "16.0.0" "15.0.0"'
assertFalse 'version_greater_or_equal "16.0.0" "15.999.999"'
assertFalse 'version_greater_or_equal "16.0.0" "9.999.999"'
assertFalse 'version_greater_or_equal "16.0.0" ""'
assertFalse 'version_greater_or_equal "16.0.0" "0"'
assertFalse 'version_greater_or_equal "16.0.0" "7"'
assertFalse 'version_greater_or_equal "16.0.0" "15"'
}
function test_is_relevant_file_works_for_cpp_files() {
assertTrue 'is_relevant_file "foo.cpp"'
assertTrue 'is_relevant_file "foo.hpp"'
assertTrue 'is_relevant_file "foo.c"'
assertTrue 'is_relevant_file "foo.h"'
assertTrue 'is_relevant_file "foo.hh"'
assertFalse 'is_relevant_file "foo.inl"'
assertFalse 'is_relevant_file "foo.inc"'
assertTrue 'is_relevant_file "foo.extra.cpp"'
assertTrue 'is_relevant_file ".cpp"'
assertTrue 'is_relevant_file "bar/foo.cpp"'
assertTrue 'is_relevant_file "bar/.cpp"'
assertFalse 'is_relevant_file ".cppX"'
assertFalse 'is_relevant_file "bar/.cppX"'
assertFalse 'is_relevant_file ".cpp/bar"'
assertFalse 'is_relevant_file "foo/bar.cpp/baz"'
}
function test_enforce_style_check_fails_when_there_are_unformatted_managed_files() {
setup_test_repo
git add unformatted.cpp >/dev/null 2>&1
(RUN_TESTS=false main check 2>/dev/null)
assertEquals 1 $?
}
function test_enforce_style_check_succeeds_when_there_are_only_formatted_managed_files() {
setup_test_repo
format_files unformatted.cpp >/dev/null 2>&1
git add unformatted.cpp >/dev/null 2>&1
(RUN_TESTS=false main check >/dev/null 2>&1)
assertEquals 0 $?
}
function test_enforce_style_check_succeeds_when_all_staged_files_are_formatted_even_if_the_unstaged_versions_are_not() {
setup_test_repo
git add unformatted.cpp >/dev/null 2>&1
format_files_in_staging_area unformatted.cpp >/dev/null 2>&1
(RUN_TESTS=false main check 2>/dev/null)
assertEquals 0 $?
}
function test_enforce_style_warning_if_ignore_file_missing() {
setup_test_repo
git add unformatted.cpp >/dev/null 2>&1
IGNORE_FILE=".no-enforce-style-ignore"
messages=$(RUN_TESTS=false main check 2>&1)
# Assert that the output contains a warning about the missing ignore file
assertContains "$messages" "warning"
assertContains "$messages" "exist"
assertContains "$messages" "$IGNORE_FILE"
}
function test_enforce_style_check_succeeds_when_there_is_a_unformatted_deleted_file() {
setup_test_repo
git add unformatted.cpp >/dev/null 2>&1
echo "" >"$IGNORE_FILE"
git add "$IGNORE_FILE" >/dev/null 2>&1
git commit -m "Add managed file" >/dev/null 2>&1
git rm unformatted.cpp unformatted2.cpp >/dev/null 2>&1
(RUN_TESTS=false main check 2>&1)
assertEquals "$?" 0
}
function test_enforce_style_enforce_succeeds_when_there_are_unformatted_managed_files() {
setup_test_repo
git add unformatted.cpp >/dev/null 2>&1
(RUN_TESTS=false main enforce 2>/dev/null)
assertEquals 0 $?
}
function test_enforce_style_ignored_renamed_file_stays_ignored() {
setup_test_repo
git add unformatted.cpp >/dev/null 2>&1
echo "/unformatted.cpp" >"$IGNORE_FILE"
git add "$IGNORE_FILE" >/dev/null 2>&1
(RUN_TESTS=false main check &>/dev/null)
assertEquals "$?" 0
git commit -m "Add ignored file" >/dev/null 2>&1
git mv unformatted.cpp unformatted2.cpp >/dev/null 2>&1
(RUN_TESTS=false main check &>/dev/null)
assertEquals "$?" 0
}
function test_enforce_ignores_new_files_if_the_config_is_set() {
setup_test_repo
git add unformatted.cpp >/dev/null 2>&1
echo "" >"$IGNORE_FILE"
git add "$IGNORE_FILE" >/dev/null 2>&1
IGNORE_NEW_FILES=true
(RUN_TESTS=false main check &>/dev/null)
assertEquals "$?" 0
IGNORE_NEW_FILES=false
(RUN_TESTS=false main check &>/dev/null)
assertNotEquals "$?" 0
# No error, because the file is ignored
IGNORE_NEW_FILES=true
(RUN_TESTS=false main enforce &>/dev/null)
assertEquals "$?" 0
# No error, because the previous enforce run added the file to the ignore file
IGNORE_NEW_FILES=false
(RUN_TESTS=false main check &>/dev/null)
assertEquals "$?" 0
echo "" >"$IGNORE_FILE"
git add "$IGNORE_FILE" &>/dev/null
# No error, because the file gets fixed
IGNORE_NEW_FILES=false
(RUN_TESTS=false main enforce &>/dev/null)
assertEquals "$?" 0
# No error, because the file got fixed
IGNORE_NEW_FILES=false
(RUN_TESTS=false main check &>/dev/null)
assertEquals "$?" 0
}
function setUp {
PREVIOUS_WORKING_DIR=$(pwd)
PREVIOUS_IFS="$IFS"
reset_state
reset_config
populate_clang_format
}
function tearDown {
# Restore working directory
cd "$PREVIOUS_WORKING_DIR" || exit 1
IFS="$PREVIOUS_IFS"
}
# Get the file and line of the callers caller.
function caller_line() {
local line
local file
line=$(caller 1 | awk '{print $1}')
file="${BASH_SOURCE[0]}"
echo "$file:$line"
}
# Usage: assertArrayContains <needle> <haystack>
function assertArrayContains {
local expected="$1"
shift
local actual=("$@")
local element
local found=false
for element in "${actual[@]}"; do
if [[ "$element" == "$expected" ]]; then
found=true
break
fi
done
assertTrue " $(caller_line): Expected '$expected' to be in the array but it is not." "$found"
}
# Usage: assertArrayNotContains <needle> <haystack>
function assertArrayNotContains {
local expected="$1"
shift
local actual=("$@")
local element
local found=true
for element in "${actual[@]}"; do
if [[ "$element" == "$expected" ]]; then
found=false
break
fi
done
assertTrue " $(caller_line): Expected '$expected' not to be in the array but it is." "$found"
}
function test_populate_clang_format() {
populate_clang_format
assertEquals "$(basename "$CLANG_FORMAT")" clang-format
}
# shellcheck source=/dev/null
. shunit2
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment