dotfiles/.config/shell/bin/git-review

801 lines
23 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# git-code-review - easier code reviews
# Usage - see `git-code-review --help`
# I know variables don't expand in single quotes, thank you shellcheck
# shellcheck disable=SC2016,SC1007
# Coding practice:
# - Keep main() looking simple, each line should read mostly as english,
# because this could get fairly cryptic otherwise. Make things into short
# functions to do this.
# - Try to make these ^ abstractions only one-level deep, so there isn't more
# abstraction than needed
# File layout:
# - error/logging functions (eg. error_basic)
# - git value getters (eg. get_git_remote_url)
# - value checkers (eg. exists_remote)
# - git mutating operations
# - adding operations (eg. create_remote)
# - removing operations (eg. remove_remote)
# - repo setup functions (eg. action_on_both_repos)
# - generic helper functions (eg. search_replace)
# - usage (help page)
# - argument parsing (eg. parse_ref)
# - main
# set -x
set -euo pipefail
# global for the name of the script
script_name="${0##*/}"
# Call printf, write to stderr
printf_err() {
printf "$@" >&2
}
# Call echo, write to stderr
echo_err() {
echo "$@" >&2
}
# Errors for basic usage things
# $1 - message type (eg. "Error")
# $2 - message (eg. "Monkeys failed to climb trees")
# $@?- Additional message (will be printed with a space before each line)
error_basic() {
type="${1:-ERROR}"; shift
msg_main="${1:-UNDEFINED ERROR}"; shift
printf_err '%s ' "$script_name:" "$type:" "$msg_main"
printf_err '\n'
if [ $# -gt 0 ]; then
printf_err ' %s\n' "$@"
printf_err '\n'
fi
}
# Errors for internal issues
# $1 - caller name (your $0)
# $2 - error line (your $LINENO)
# $@ - options for error_basic
error() {
caller="$1"; shift
line="$1"; shift
printf_err '%s ' "$script_name: In function $caller, line $line:"
error_basic 'Error' "$@"
}
# Errors that should exit for internal issues
# $1 - message
# $2 - Additional message (will be printed with a space before each line)
fatal_error() {
error "$@"
exit 1
}
# Errors that should exit for user-facing issues
# See error_basic for usage
fatal_error_basic() {
error_basic "$@"
exit 1
}
# Validate the value of an argument and exit if it's bad
# $1 - prepend this to the err_msg variable
# $@ - command to run to validate argument
# $err_msg - a variable containing the error to print
arg_assert() {
# prepend to the error
err_msg_internal="$1${err_msg:-}"
shift
if ! "$@"; then
echo_err 'Error: Invalid argument:' "$err_msg_internal"
arg_error=1
fi
}
# Call this after all arg_assert calls to exit if there was an error
arg_assert_end() {
if [ "${arg_error:-0}" != 0 ]; then
exit "$arg_error"
else
unset arg_error
fi
}
# Prints variables passed into it
# $@ - the name of the variables (eg. 'myvar', not "$myvar")
log_vars() {
echo "Variable dump:"
for var; do
if [ "$var" = '' ]; then
echo
else
eval value="\"\$$var\""
# shellcheck disable=SC2154
printf ' %s\t%s\n' "$var" "$value"
fi
done | column -tl 2 | sed 's/^/ /'
}
# exit if cmd is false
# $1 - name of calling function (your $0)
# $2 - line called on
# $@ - the command to assert
assert() {
caller="$1"; shift
line="$1"; shift
if ! ("$@"); then
fatal_error "$caller" "$line" "Assertion failure: $*"
fi
}
#
# Git getters
#
# find the folder for a branch's worktree, if it has one
# $1 - branch to check for
get_worktree_folder() {
branch="$1"
assert "$0" "$LINENO" exists_branch "$branch"
git worktree list | grep -F "[$branch]" | rev | cut -d ' ' -f 3- | sed -E 's/^ +//g' | rev
}
get_current_remote() {
if ! git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2> /dev/null; then
printf ''
:
fi
}
get_git_remote_url_nofail() {
# Test a few potential remotes
# WARN: this is a bit hard-coded based on standard practices, as well as my own practices
remotes=(upstream origin "$(get_current_remote)")
for remote_url in "${remotes[@]}"; do
if remote_url="$(git remote get-url "$remote_url")"; then
printf '%s' "$remote_url"
return
fi
done
printf 'NONE'
return 1
}
# Get the url for a git repository's remote, choosing from a few options.
# Accepts no arguments.
get_git_remote_url() {
if ! get_git_remote_url_nofail; then
# If no remotes were found, we get here
fatal_error "$0" "$LINENO" \
"Couldn't find a valid remote. Please specify a remote url with the --remote-url option." \
"See $script_name --help for more information."
fi
}
get_git_branch_current() {
git rev-parse --abbrev-ref --symbolic-full-name HEAD
}
#
# Checker functions
#
# Check if a stream contains a stream
# $1 - regex to filter the stream for the search term (passed to grep -P)
# $2 - the search term (must match the whole line)
check_exists() {
regex="$1"; shift
term="$1"; shift
grep -Po "$regex" | grep -Fxq "$term"
}
exists_remote() {
remote="$1"
git remote | check_exists '^.+$' "$remote"
}
# check if a branch exists
# $1 - branch
exists_branch() {
branch="$1"
git branch | check_exists '..\K.+$' "$branch"
}
# check if a worktree exists with a specific branch attached to it
# $1 - branch
exists_branch_as_worktree() {
branch="$1"
git worktree list | check_exists '\[[^\]]+\]$' "[$branch]"
}
#
# Git mutating operation functions
#
# Fetch a remote
# $1 - the remote to fetch
fetch_remote() {
remote="$1"
shell_cmd git fetch "$remote"
}
# Add a remote if it doesn't exist
# $1 - Name of the remote
# $2 - URL to the remote
create_remote() {
name="$1"; shift
url="$1"; shift
# Check if we have the remote already
if ! exists_remote "$name"; then
shell_cmd git remote add "$name" "$url"
else
shell_cmd git remote set-url "$name" "$url"
fi
fetch_remote "$remote_name"
}
# remove a remote
# $1 - remote to remove
remove_remote() {
remote="$1"
shell_cmd git remote remove "$remote"
}
# Create a git branch and set its upstream
# $1 - branch name
# $2 - remote name
# $3 - remote branch name
create_branch() {
branch="$1"; shift
remote="$1"; shift
remote_branch="$1"; shift
if ! exists_branch "$branch"; then
shell_cmd git branch "$branch" "$remote/$remote_branch"
fi
}
# remove a branch
# $1 - branch to remove
remove_branch() {
branch="$1"; shift
shell_cmd git branch -d "$branch"
}
# Create a worktree if it doesn't exist
# $1 - where to put the worktree
# $2 - branch the worktree should point at (MUST ALREADY EXIST)
create_worktree() {
dir="$1"; shift
branch="$1"; shift
# Ensure the branch exists. It should be created before this function is called.
assert "$0" "$LINENO" exists_branch "$branch"
# See if a worktree already exists with the branch attached
if ! exists_branch_as_worktree "$branch"; then
shell_cmd git worktree add --checkout "$dir" "$branch"
fi
}
# Remove a worktree if it exists
# $1 - worktree dir to remove
remove_worktree() {
dir="$1"; shift
shell_cmd git worktree remove "$dir"
}
#
# Repo setup functions
#
# Symlinks files from a git repository to another place
# $@ - command to evaluate, with '$f' passed where a file should be
# pipe - list of relative paths to files that should be linked from src to dst
action_on_both_repos() {
# shellcheck disable=SC2034
while IFS=$'\n' read -r f; do
# shellcheck disable=SC2294
eval "$@"
# error_basic 'Log' "Eval action:" "$@"
done
}
get_ignored_files() {
git clean -ndX | grep -Po 'Would remove \K.*$' | sed -E 's-/$--'
}
get_submodules() {
git submodule | grep -Po '.[^ ]+ \K.*$' | rev | grep -Po '[^( ]+\( \K.*$' | rev
}
# See usage for `symlink_targets`. Takes no pipe input
symlink_ignored() {
get_ignored_files | action_on_both_repos cp -lr "$1"/'$f' "$2"/'$f'
}
symlink_subs() {
# Hardlink files
get_submodules | action_on_both_repos rmdir "$2"/'$f' '&&' cp -lr "$1"/'$f' "$2"/'$f'
# Update submodule ".git" files
get_submodules | action_on_both_repos rm "$2"/'$f'/.git '&&' echo 'gitdir: '"$1"'/.git/modules/$f' '>' "$2"/'$f'/.git
}
remove_symlinked_ignores() {
get_ignored_files | action_on_both_repos rm -r "$2"/'$f'
}
remove_symlinked_subs() {
# error_basic Info Removing submodules
get_submodules | action_on_both_repos rm -r "$2"/'$f'
get_submodules | action_on_both_repos git -C "$2" restore "$2"/'$f'
}
create_links() {
symlink_ignored "$@" || true
symlink_subs "$@" || true
}
remove_links() {
remove_symlinked_ignores "$@" || true
remove_symlinked_subs "$@" || true
}
#
# Generic helper functions
#
# run a shell command and print the command that was run
shell_cmd() {
Green=""
Nc="(B" # No Color
printf_err '%s' "$Green"
printf_err '%s' " \$ $*$Nc"
printf_err '\n'
"$@"
}
# Run a command in a directory without changing the current directory
# $1 - What directory to run it in
# $@ - Command to run
shell_cmd_dir() {
dir="$1"; shift
(
# Cd
shell_cmd cd "$dir" || fatal_error "$0" "$LINENO" "Couldn't \`cd\` into directory:" " $dir"
# Run
shell_cmd "$@"
)
}
# Extract a field N fields from the end.
# $1? - how many fields from the end to select (default: 1)
# $2? - field separator (default: /)
get_from_end() {
dist_from_end="${1:-1}"; shift
sep="${1:-/}"; shift
rev | cut -d "${sep:0:1}" -f "$dist_from_end" | rev
}
# Search and replace a string *literal* (WITHOUT evaluating a regular expression or code of any kind)
# $1 - search term
# $2 - replace with
# pipe - input
search_replace() {
search="$1"; shift
replace="$1"; shift
awk -v search="$search" -v replace="$replace" '{sub(search, replace); print}'
}
#
# Usage information (--help)
#
# FUTURE INTERFACE FOR `opt` (NOT YET IMPLEMENTED)
# # makes an option for getopts
# # $1 - short option, single character. (eg. 'f')
# # $2 - long option (eg. 'file')
# # $3 - option category, controls what array the options' descriptions are put in (eg. 'info' puts it in opts_info)
# # $4?- argument for the option, for `getopt` (eg. ':' or '::')
# # $4?- true/false to indicate whether the option is a boolean option. Requires
# # a long opt to be passed. Generates a corresponding version of the long
# # option with "no-" prepended to it. (eg. 'true', which generates
# # `--cool-opt` and `--no-cool-opt`)
# $1 - short option, single character. (eg. 'f')
# $2 - long option (eg. 'file')
# $3?- argument for the option, for `getopt` (eg. ':' or '::')
# $3?- true/false to indicate whether the option is a boolean option. Requires
# a long opt to be passed. Generates a corresponding version of the long
# option with "no-" prepended to it. (eg. 'true', which generates
# `--cool-opt` and `--no-cool-opt`)
# TODO: make this create data to go in the usage information
opt() {
# help_category="${1:-info}"; shift
short="${1:0:1}"; shift
long="$1"; shift
arg="${1:-false}"; shift
# arg_type="${1:-}"; shift
# # take data from pipe
# if [ -p /dev/stdin ];then
# desc="$(cat)"
# else
# fatal_error "$0" "$LINENO" "Received no description for command $long (category $help_category)"
# fi
case "$arg" in
true) arg= getopts_long+="${getopts_long:+,}no-$long" ;; #longbool_help="[no-]"
false) arg=;;
# :) arg_type="";;
:|::);;
esac
case "$short" in
'') ;;#shorthelp=" "
*) getopts_short+="$short$arg";; #shorthelp="-$short,"
esac
case "$long" in
'') ;; #longhelp=
*) getopts_long+="${getopts_long:+,}$long$arg";; #longhelp="--$longbool_help$long"
esac
# # help stuff
# eval opts_"$help_category"+='("$shorthelp$longhelp" "$desc")'
}
opt_is_supported() {
[[ "$1" =~ $2 ]]
}
opt_accepts_arg() {
[[ "$1:" =~ $2 ]]
}
opt_requires_arg() {
! [[ "$1::" =~ $2 ]]
}
usage() {
# ideas for new options
# $script_name review [(--no-cleanup | )] <REF> [CMD...] Open a shell to review code from REF, then clean up
# $script_name clean [REF] Clean the results of REF
# --no-cleanup Don't remove the remote and cloned code by default
cat << EOF
$script_name - review changes on a remote branch
Usage:
$script_name [opts] <branch_ref> [cmd...]
$script_name review [opts] <branch_ref> [cmd...]
$script_name clean [opts] [branch_ref]
Subcommands: (NOT IMPLEMENTED)
(none) If no subcommand is specified, the review subcommand will be assumed.
review Review branch_ref and execute the cmd
clean Remove remotes, branches, and worktrees associated with a
specific branch_ref. If no branch_ref is provided, all remotes,
branches, and worktrees containing the "review" prefix will be
pruned.
Options for all subcommands: (NOT IMPLEMENTED)
$(printf "%s\n" "${opts_info[@]}" | paste - - | column -tc 80 -s $'\t' -N opt,desc -W desc -d)
Options for 'review' subcommand: (NOT IMPLEMENTED)
--[no-]clean-on-success Clean the worktree only if the command exits with
error code 0.
Options for 'clean' subcommand: (NOT IMPLEMENTED)
-f, --force Force clean worktree (any changes will be lost).
Definitions:
branch_ref - Where the remote code and branch is. One of the following:
BRANCH branch located on the default remote
USERNAME:BRANCH username and branch to use as the source for a remote.
Repository name is be assumed to be the same as the
default remote. (currently $(get_git_remote_url_nofail))
USERNAME/REPO username and repo to use as the source for the
remote (current branch is assumed to be the name of
the remote branch)
USERNAME/REPO:BRANCH repository, username, and branch name to use as the
cmd - A command and arguments to run on the remote. Default: \`bash\`
Example:
$ pwd
~/git/gimp
$ git remote get-url origin
https://gitlab.gnome.org/GNOME/gimp
$ $script_name joe/cool-feature
# git remote add, git fetch, git worktree add
$ pwd
~/.cache/$script_name/gimp/joe-cool-feature
$ exit
# git worktree remove, rm -r, git remote remove
$ pwd
~/git/gimp
$ ls ~/.cache/$script_name/gimp/joe-cool-feature
No such file or directory.
The URL for the remote git repository is needed to be able to make new remotes.
Since many code forges follow the same format for their URL's, we are often
able to extrapolate the information we need to make a new remote based on just
the contents of the URL on the defult remote.
The assumed format for URL's on remotes is as follows:
https://git.example.net/joe-mama/code
git@forge.site.io:joe-mama/code
^~~~~~~^ ^~~^
USERNAME REPO
The USERNAME and REPO parts of the URL will be replaced with their
corresponding parts from the given branch_ref to create a new URL. If the remote
on your URL doesn't follow this format, specify the URL with the --remote-url
option. In this case, the specified branch_ref will still be used to get
information used for creating folders.
EOF
}
# Parse a branch_ref into the ref_type, username, repo, and branch variables
# $1 - a branch_ref
# stdout - the normalized ref
normalize_ref() {
ref="$1"
# assume U/R:B form, and take stuff from it
# take the end of ref
branch_name_fromref="${ref##*:}"
# take the beginning of ref
username_fromref="${ref%%/*}"
# isolate the middle part of the ref
repo_fromref="${ref#*/}"
repo_fromref="${repo_fromref%:*}"
# TODO: process --remote-url here
remote_url_fromlocalrepo="$(get_git_remote_url)"
# remove all trailing slashes from the url
remote_url_fromlocalrepo="${remote_url_fromlocalrepo%%/}"
# Default value for the repo
## assign to the repo name part of the url - "foo[:/]username/(repo).git"
repo_fromremote="${remote_url_fromlocalrepo##*/}" # url with everything but the last slash
repo_fromremote="${repo_fromremote%.git}" # remove ".git" (if it's there)
## assign to the username part of the url - "foo[:/](username)/repo.git"
username_fromremote="$(echo "$remote_url_fromlocalrepo" | search_replace "/$repo_fromremote" "" | rev | grep -Po '^[^/:]+' | rev)"
# Decide how to proceed based on the format of ref
case "$ref" in
*/*:*)
ref_type="USERNAME/REPO:BRANCH"
username="$username_fromref"
repo="$repo_fromref"
branch="$branch_name_fromref"
;;
*:*)
ref_type="USERNAME:BRANCH"
username="${ref%:*}"
repo="$repo_fromremote"
branch="$branch_name_fromref"
;;
*/*)
ref_type="USERNAME/REPO"
username="$username_fromref"
repo="$repo_fromref"
branch="$(get_git_branch_current)"
;;
*)
ref_type="BRANCH"
username="$username_fromremote"
repo="$repo_fromremote"
branch="$ref"
;;
esac
ref="$username/$repo:$branch"
}
process_opt() {
case "$1" in
-h|--help) usage; exit;;
esac
}
# Set the username, repo, and branch_name_remote variables from a ref
# $1 - a branch ref to pass to normalize_ref
setup_from_ref() {
ref="$1"
normalize_ref "$1"
# take the beginning of ref
username="${ref%%/*}"
# isolate the middle part of the ref
repo="${ref#*/}"
repo="${repo%:*}"
# take the end of ref
branch_name_remote="${ref##*:}"
}
# Parse arguments into variables that the rest of the script needs
process_opts() {
opt h help
opt v verbose
opt r remote-url :
opt w worktree-dir :
opt f force
opt c clean-on-success true
# opts_long=('help,verbose,remote-url:,worktree-dir:,force,clean-on-success,no-clean-on-success')
# args=$(getopt \
# -o "$getopts_short" \
# --long "$getopts_long" \
# -n "$script_name" \
# -- "$@")
#
# getopt_exitcode=$?
# if [ $getopt_exitcode != 0 ]; then
# exit $getopt_exitcode
# fi
# track errors
error_encountered=false
# throw errors when passed --opt=arg when opt takes no argument
error_on_longopts_withEqualSigns_thatTakeNoArgs=true
while true; do
case "$1" in
(--) # Process command
shift;
# now, ${@:-bash} is the command to run
cmd=("${@:-${SHELL}}")
break;
;;
(--*) # longopts
# --opt=arg -> --opt
opt="${1%%=*}"
# --opt=arg -> =arg
# --opt -> --opt
argeq="${1/#*=/=}"
shift
if opt_is_supported "$opt" "$getopts_long"; then
# Handle arguments set with equal signs
case "$argeq" in
=*) # --opt=arg
if opt_accepts_arg "$opt" "$getopts_long"; then
arg="${argeq#=}"
elif [ "$error_on_longopts_withEqualSigns_thatTakeNoArgs" = true ]; then
error="Option $opt recieved an argument ('$arg'), but does not accept arguments"
fi
;;
*) # --opt arg OR --opt --otheropt
if opt_requires_arg "$opt" "$getopts_long"; then
if [ $# = 0 ]; then
error="Option $opt requires an argument, but none was given"
else
arg="$1"
fi
elif opt_accepts_arg "$opt" "$getopts_long"; then
# If we get here, the option doesn't require an argument, but it may optionally accept one
if [ $# != 0 ]; then
case "$1" in
-*) ;; # option will get no argument since next argument is an option (whether the option is a valid option or not)
*) arg="$1"; shift;;
esac
fi
fi
;;
esac
# Now our option is normalized and $@ has been shifted to not
# include it or any of the arguments it takes
# if arg is set (if we have an argument), pass it along
if [ "${arg:+true}" = true ]; then
process_opt "$opt" "$arg" "$@"
else
process_opt "$opt" "$@"
fi
else
error="No such option: $opt"
fi
;;
(-*);;
(*);;
esac
if [ "$error" != false ]; then
error=false
# error_encountered=true
error_basic 'Error' "$error"
shift
continue
fi
done
}
main() {
if [ $# = 0 ]; then
error_basic 'Error' "Please pass at least one argument."
printf_err '\n'
usage
exit 1
fi
# ensure running inside a git repository
if ! git_repo="$(git rev-parse --show-toplevel)"; then
fatal_error "$0" "$LINENO" "Please run from a git repository"
fi
ref="$1"
setup_from_ref "$ref"
shift
cmd=("${@:-${SHELL}}")
# TODO: process --remote-url here
remote_url="$(echo "$remote_url_fromlocalrepo" | search_replace "$username_fromremote" "$username" | search_replace "$repo_fromremote" "$repo")"
err_msg=" in $ref_type-style branch_ref cannot be empty"
arg_assert url [ "$remote_url" != '' ]
arg_assert repo [ "$repo" != '' ]
arg_assert branch [ "$branch_name_remote" != '' ]
arg_assert username [ "$username" != '' ]
arg_assert_end
# Constants
k_git_ref_prefix=review
# derive:
# - REMOTE (review-USER)
# - BRANCH_LOCAL (review-BRANCH)
# - WORKTREE_FOLDER (~/.cache/REPO/REMOTE_BRANCH)
# Derived variables
remote_name="$k_git_ref_prefix-$username"
branch_name_local="$k_git_ref_prefix-$username-$branch_name_remote"
worktree_folder="${XDG_CACHE_HOME:-"$HOME"/.cache}/$script_name/$repo/$username-$branch_name_remote"
clean_worktree=true
#
# Main actions
#
# make remote
create_remote "$remote_name" "$remote_url"
# make branch
create_branch "$branch_name_local" "$remote_name" "$branch_name_remote"
# make worktree if it doesn't exist
if ! exists_branch_as_worktree "$branch_name_local"; then
# make worktree
create_worktree "$worktree_folder" "$branch_name_local"
create_links "$git_repo" "$worktree_folder"
else
# worktree already exists, use it
worktree_folder="$(get_worktree_folder "$branch_name_local")"
# Since we didn't create the worktree, don't remove it
# clean_worktree=false
fi
# do the command in the worktree folder
shell_cmd_dir "$worktree_folder" "${cmd[@]}"
if [ "$clean_worktree" = true ]; then
# clean symlinked files
remove_links "$git_repo" "$worktree_folder" &&
# clean up worktree
remove_worktree "$worktree_folder" &&
# clean up branch
remove_branch "$branch_name_local" &&
# clean up remote
remove_remote "$remote_name"
fi
}
main "$@"