Skip to content

Instantly share code, notes, and snippets.

@jamesonwilliams
Created April 10, 2024 20:37
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jamesonwilliams/27435255ba543c9b9f407b1a61d750ca to your computer and use it in GitHub Desktop.
Save jamesonwilliams/27435255ba543c9b9f407b1a61d750ca to your computer and use it in GitHub Desktop.
#!/bin/bash
# Print a message and kill the script.
die() {
echo "$@" 1>&2
exit 1
}
# Finds the top of the repo.
find_git_repo_top() {
local current_dir=$(pwd)
# Loop until reaching the root directory "/"
while [ "$current_dir" != "/" ]; do
# Check if ".git" directory exists
if [ -d "$current_dir/.git" ]; then
echo "$current_dir"
return
fi
# Move up one directory
current_dir=$(dirname "$current_dir")
done
# If ".git" directory is not found
echo "Git repository not found."
exit 1
}
# Ask the user a yes/no question and await their response. Return 0 if
# they say yes (in some format).
await_yes_no() {
read -r answer
case "$answer" in
[yY]|[yY][eE][sS])
echo 0
;;
*)
echo 1
;;
esac
}
# Delete whatever hooks may be active in the .git/hooks directory. This
# may include things like the old pre-commit hook we had been using
# prior for April, 2024.
delete_existing_hooks_with_confirmation() {
project_root="$1"
hooks=$(find "${project_root}/.git/hooks/" -mindepth 1 ! -name "*.sample")
echo "Found hook files: $hooks"
echo "OK to delete? [Y/n]"
if [ "$(await_yes_no)" -ne 0 ]; then
die "OK; aborting."
fi
rm -f -r $hooks
}
# Install the pre-push script and any hooks found under the ./scripts
# directory.
install_pre_push_hooks() {
project_root="$1"
echo "Installing scripts into .git/hooks ..."
mkdir -p "${project_root}/.git/hooks"
ln -s "${project_root}/scripts/pre-push" "${project_root}/.git/hooks/pre-push"
}
# Installs pre-push scripts after ensuring we're running from the
# directory root, and after cleaning up any old git hook scripts.
main() {
project_root="$(find_git_repo_top)"
delete_existing_hooks_with_confirmation "$project_root"
install_pre_push_hooks "$project_root"
}
main
#!/bin/bash
#
# This script looks for Kotlin files that have been changed locally and
# ensures that they are correctly formatted according to ktfmt. This
# script makes an effort to only run on the smallest possible set of
# changed files as opposed to running broadly over the codebase, to
# improve pre-push performance.
#
# The script takes the following steps:
#
# 1. Figure out the name of the remote for your repo on
# GitHub. If there is no remote (via `git remote`) add one called
# "your_company"
# 2. Fetch the latest `main` ref from that remote.
# 3. For any commits that are about to be pushed (git push can push
# multiple references at once), do the following steps, 4-8:
# 4. Find an ancestor commit that is before both origin/main and the
# commit you're trying to push.
# 5. Compute a list of .kt or .kts files that have changed between that
# ancestor and the commit being pushed
# 6. Run ./gradlew ktfmtFormatPartial on only those files using --run-over=<list>.
# 7. If there are no formatting changes applied, proceed to push.
# Otherwise, print a descriptive error message noting that files
# have been formatted and that the user will need to commit manually
# and push.
# Print the name of the configured remote (usually "origin")
expected_remote() {
git remote -v | awk '/git@github.com:Your-org\/your-repo.git \(fetch\)/ { print $1 }'
}
# Returns the name of the your_company origin. If it is not found locally,
# we'll add one called "your_company" (conservative name so it doesn't clash
# with whatever else you have going on.)
ensure_remote_installed() {
remote_name="$(expected_remote)"
if [ -z "$remote_name" ]; then
git remote add your_company "git@github.com:Your-org/your-repo.git"
echo "your_company"
else
echo "$remote_name"
fi
}
# Fetches, but does not apply, the remote references from GitHub's copy
# of the project.
fetch_remote_refs() {
remote_name="$1"
git fetch "$remote_name" main &>/dev/null
}
# Computes a list of the names of the files that have changed between
# two commit hashes.
compute_changed_files() {
from_hash="$1"
to_hash="$2"
git diff --name-only "$from_hash" "$to_hash"
}
# Determines if a file is a kotlin file.
is_kotlin_file() {
file_path="$1"
if [[ "$file_path" =~ .kts?$ ]]; then
echo 0
else
echo 1
fi
}
# Computes which .kts? files have changed.
compute_changed_kotlins() {
from_hash="$1"
to_hash="$2"
changed_kotlins=""
for changed_file in $(compute_changed_files "$from_hash" "$to_hash"); do
if [ "$(is_kotlin_file $changed_file)" -eq 0 ]; then
changed_kotlins="$changed_file $changed_kotlins"
fi
done
echo "$changed_kotlins"
}
# This finds a common ancestor between origin/main and whatever commit
# is being pushed. The idea here is that the local tree may not be
# rebased onto origin/main itself, so we need to look backwards in
# origin/main to find a commit that *is* in our history. This is the
# developer's current marker for origin/main.
compute_from_hash() {
remote_name="$1"
local_hash="$2"
git merge-base "$remote_name/main" "$local_hash"
}
# Given two lists of strings compute the insersection of the two lists,
# e.g., if A="foo bar", and B="foo", the intersection is "foo".
compute_intersection() {
intersection=""
foo="$1"
bar="$2"
for f in $foo; do
for b in $bar; do
if [[ "$b" == "$f" ]]; then
intersection="$intersection $b"
fi
done
done
echo $intersection | sort | uniq | xargs
}
# Checks the result of the ktfmt task to see if formatted any files. If
# files were formatted, fail the hook and emit an error. If none were
# formatted, continue to exit the hook successfully.
fail_if_any_formatted() {
changed_since_main="$1"
changed_after_fmt="$(git diff --name-only | xargs)"
changed_in_both=$(compute_intersection "$changed_since_main" "$changed_after_fmt")
if [ ! -z "$changed_in_both" ]; then
cat <<- EOF
The following files were not formatted correctly and have been fixed locally. Please commit them and try your push again.
$changed_in_both
EOF
exit 1
fi
}
# Runs ktfmt over any changed .kts? files.
validate_ktfmt() {
remote_name="$1"
to_hash="$2"
from_hash="$(compute_from_hash $remote_name $to_hash)"
changed_kotlins="$(compute_changed_kotlins $from_hash $to_hash | xargs)"
if [ -z "$changed_kotlins" ]; then
exit 0
fi
echo "Running ktfmtFormatPartial over changed Kotlin files: $changed_kotlins ..."
./gradlew ktfmtFormatPartial --run-over="$changed_kotlins" &>/dev/null
fail_if_any_formatted "$changed_kotlins"
}
remote_name=$(ensure_remote_installed)
fetch_remote_refs "$remote_name"
while read localname localhash remotename remotehash; do
validate_ktfmt "$remote_name" "$localhash"
done
exit 0
#!/bin/bash
#
# Don't write actual logic in this file. This file just fans out to
# .git/hooks/pre-push.d/<your_file>, so that we can add a number of
# checks into one place. This file aims to honor the original pre-push
# contract and fan it out to stub files.
# Finds the top of the repo.
find_git_repo_top() {
local current_dir=$(pwd)
# Loop until reaching the root directory "/"
while [ "$current_dir" != "/" ]; do
# Check if ".git" directory exists
if [ -d "$current_dir/.git" ]; then
echo "$current_dir"
return
fi
# Move up one directory
current_dir=$(dirname "$current_dir")
done
# If ".git" directory is not found
echo "Git repository not found."
exit 1
}
project_root=$(find_git_repo_top)
hooks=$(find ${project_root}/scripts/pre-push.d -type f ! -name "*.sw*" | sort)
# pre-push.d receives four pieces of information for each source-target
# push that may be in play. For exmaple, origin->refs/heads/origin, and
# my_kooll->refs/heads/my_kool_branch would cause two iterations of this
# while loop.
while read localname localhash remotename remotehash; do
# For each set of push data, iterate over the hooks in alphabetical
# order. Pass the hook data in using the same contract.
for hook in $hooks; do
echo "$localname $localhash $remotename $remotehash" | bash "$hook"
RESULT="$?"
if [ $RESULT != 0 ]; then
exit "$RESULT"
fi
done
done
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment