From 3b28bc4ff5e36dbc2262d8f6a3841e1bb7576e81 Mon Sep 17 00:00:00 2001 From: PowerUser64 Date: Tue, 29 Apr 2025 00:32:23 -0700 Subject: [PATCH] add git-review script (v0.1) --- .config/shell/bin/git-review | 801 +++++++++++++++++++++++++++++++++++ 1 file changed, 801 insertions(+) create mode 100755 .config/shell/bin/git-review diff --git a/.config/shell/bin/git-review b/.config/shell/bin/git-review new file mode 100755 index 0000000..caa6816 --- /dev/null +++ b/.config/shell/bin/git-review @@ -0,0 +1,801 @@ +#!/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 | )] [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] [cmd...] + $script_name review [opts] [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 "$@"