Skip to content
Closed
259 changes: 170 additions & 89 deletions check/format-incremental
Original file line number Diff line number Diff line change
@@ -1,112 +1,193 @@
#!/usr/bin/env bash

################################################################################
# Formats python files that have been modified.
#
# Usage:
# check/format-incremental [BASE_REVISION] [--apply] [--all]
#
# By default, the script analyzes python files that have changed relative to the
# base revision and determines whether they need to be formatted. If any changes
# are needed, it prints the diff and exits with code 1, otherwise it exits with
# code 0.
#
# With '--apply', reformats the files instead of printing the diff and exits
# with code 0.
# Copyright 2025 Google LLC
#
# With '--all', analyzes all python files, instead of only changed files.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# You can specify a base git revision to compare against (i.e. to use when
# determining whether or not a file is considered to have "changed"). For
# example, you can compare against 'origin/master' or 'HEAD~1'.
# https://www.apache.org/licenses/LICENSE-2.0
#
# If you don't specify a base revision, the following defaults will be tried, in
# order, until one exists:
#
# 1. upstream/master
# 2. origin/master
# 3. master
#
# If none exists, the script fails.
################################################################################
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Summary: check files against style guidelines and optionally reformat them.
# Run this program with the argument --help for usage information.
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

read -r -d '' usage <<-EOF
Usage:

${0##*/} [BASE_REV] [--help] [--apply] [--all] [--no-color] [--quiet]

Check the format of Python source files against project style guidelines. If
any changes are needed, this program prints the differences to stdout and exits
with code 1; otherwise, it exits with code 0.

Main options
~~~~~~~~~~~~

If the option '--apply' is supplied as an argument, then instead of printing
differences, this program reformats the files and exits with code 0 if
successful or 1 if an error occurs.

By default, this program examines only those files that git reports to have
changed in relation to the git revision (see next paragraph). With option
'--all', this program will examine all files instead of only the changed files.

File changes are considered relative to the base git revision in the repository
unless a different git revision is given as an argument to this program. The
revision can be given as a SHA value or a name such as 'origin/main' or
'HEAD~1'. If no git revision is provided as an argument, this program tries the
following defaults, in order, until one is found to exist:

1. upstream/main (or upstream/master)
2. origin/main (or origin/master)
3. main (or master)

If none of them exists, the program will fail and return exit code 1.

# Get the working directory to the repo root.
Additional options
~~~~~~~~~~~~~~~~~~

Informative messages are printed to stdout unless option '--quiet' is given.
(Error messages are always printed.)

Color is used to enhance the output unless the option '--no-color' is given.

Running this program with the option '--help' will make it print this help text
and exit with exit code 0 without doing anything else.

If an error occurs in Black itself, this program will return the non-zero error
code returned by Black.
EOF

# Change the working directory of this script to the root of the repo.
thisdir="$(dirname "${BASH_SOURCE[0]}")" || exit $?
topdir="$(git -C "${thisdir}" rev-parse --show-toplevel)" || exit $?
cd "${topdir}" || exit $?


# Parse arguments.
only_print=1
only_changed=1
rev=""
for arg in "$@"; do
if [[ "${arg}" == "--apply" ]]; then
only_print=0
elif [[ "${arg}" == "--all" ]]; then
only_changed=0
elif [ -z "${rev}" ]; then
if [ "$(git cat-file -t "${arg}" 2> /dev/null)" != "commit" ]; then
echo -e "\033[31mNo revision '${arg}'.\033[0m" >&2
cd "$(git -C "${thisdir}" rev-parse --show-toplevel)" || exit $?

# Set default values.
declare only_print=true
declare only_changed=true
declare no_color=false
declare be_quiet=false

function print() {
local type="$1" msg="$2"
local red="" green="" reset=""
$no_color || red="\033[31;1m"
$no_color || green="\033[32;1m"
$no_color || reset="\033[0m"
case $type in
error) echo -e "${reset}${red}Error: $msg${reset}" >&2;;
info) $be_quiet || echo -e "${reset}${green}$msg${reset}";;
*) echo "$msg";;
esac
}

declare rev=""

# Parse the command line.
# Don't be fussy about whether options are written upper case or lower case.
shopt -s nocasematch
while (( $# > 0 )); do
case $1 in
-h | --help)
echo "$usage"
exit 0
;;
--apply)
only_print=false
shift
;;
--all)
only_changed=false
shift
;;
--no-color)
no_color=true
shift
;;
--quiet)
be_quiet=true
shift
;;
-*)
print error "Unrecognized option $1."
echo "$usage"
exit 1
fi
rev="${arg}"
else
echo -e "\033[31mToo many arguments. Expected [revision] [--apply] [--all].\033[0m" >&2
exit 1
fi
;;
*)
if [[ -n "$rev" ]]; then
print error "Too many arguments."
echo "$usage"
exit 1
fi
if ! git rev-parse -q --verify --no-revs "$1^{commit}"; then
print error "Cannot find revision $1."
exit 1
fi
rev="$1"
shift
;;
esac
done
shopt -u nocasematch

typeset -a format_files
if (( only_changed == 1 )); then
# Gather a list of Python files that have been modified, added, or moved.
declare -a modified_files=("")
if $only_changed; then
# Figure out which branch to compare against.
if [ -z "${rev}" ]; then
if [ "$(git cat-file -t upstream/master 2> /dev/null)" == "commit" ]; then
rev=upstream/master
elif [ "$(git cat-file -t origin/master 2> /dev/null)" == "commit" ]; then
rev=origin/master
elif [ "$(git cat-file -t master 2> /dev/null)" == "commit" ]; then
rev=master
else
echo -e "\033[31mNo default revision found to compare against. Argument #1 must be what to diff against (e.g. 'origin/master' or 'HEAD~1').\033[0m" >&2
if [[ -z "$rev" ]]; then
declare -r -a try=("upstream/main" "origin/main" "main"
"upstream/master" "origin/master" "master")
for name in "${try[@]}"; do
if [[ "$(git cat-file -t "$name" 2> /dev/null)" == "commit" ]]; then
rev="$name"
break
fi
done
if [[ -z "$rev" ]]; then
print error "None of the defaults (${try[*]}) were found and no" \
" git revision was provided as argument. Argument #1 must" \
" be what to diff against (e.g., 'origin/main' or 'HEAD~1')."
exit 1
fi
fi
base="$(git merge-base "${rev}" HEAD)"
if [ "$(git rev-parse "${rev}")" == "${base}" ]; then
echo -e "Comparing against revision '${rev}'." >&2
else
echo -e "Comparing against revision '${rev}' (merge base ${base})." >&2
rev="${base}"
declare base base_info
base="$(git merge-base "$rev" HEAD)"
if [[ "$(git rev-parse "$rev")" != "$base" ]]; then
rev="$base"
base_info=" (merge base $base)"
fi
print info "Comparing files to revision '$rev'$base_info."

# Get the modified, added and moved python files.
IFS=$'\n' read -r -d '' -a format_files < \
<(git diff --name-only --diff-filter=MAR "${rev}" -- '*.py' ':(exclude)*_pb2.py')
# Get the list of changed files.
IFS=$'\n' read -r -d '' -a modified_files < \
<(git diff --name-only --diff-filter=MAR "$rev" -- '*.py')
else
echo -e "Formatting all python files." >&2
IFS=$'\n' read -r -d '' -a format_files < \
<(git ls-files '*.py' ':(exclude)*_pb2.py')
# The user asked for all files.
print info "Formatting all Python files."
IFS=$'\n' read -r -d '' -a modified_files < <(git ls-files '*.py')
fi

if (( ${#format_files[@]} == 0 )); then
echo -e "\033[32mNo files to format\033[0m."
if (( ${#modified_files[@]} == 0 )); then
print info "No modified files found – no changes needed."
exit 0
fi

BLACKVERSION="$(black --version)"
declare black_version
black_version="$(black --version)"
black_version=${black_version//[$'\n']/ } # Remove annoying embedded newline.
black_version=${black_version#black, } # Remove leading "black, "
print info "Running Black (version $black_version) ..."

echo "Running the black formatter... (version: $BLACKVERSION)"
declare -a black_args
$only_print && black_args+=("--check" "--diff")
$be_quiet && black_args+=("--quiet")
$no_color && black_args+=("--no-color")

args=("--color")
if (( only_print == 1 )); then
args+=("--check" "--diff")
fi

black "${args[@]}" "${format_files[@]}"
BLACKSTATUS=$?

if [[ "$BLACKSTATUS" != "0" ]]; then
exit 1
fi
exit 0
black "${black_args[@]}" "${modified_files[@]}"
Loading