Skip to content

Improve format-incremental script #996

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 9 commits into from
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