Last active
May 30, 2024 19:42
-
-
Save Ragnoroct/7c90a1e55a5ff3ef7972e87971cae015 to your computer and use it in GitHub Desktop.
Git force push history for a branch
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/env bash | |
# Copyright (c) 2024 Will Bender | |
# Copyright (c) 2011 Dominic Tarr https://github.com/dominictarr/JSON.sh | |
# Permission is hereby granted, free of charge, to any person obtaining a copy | |
# of this software and associated documentation files (the "Software"), to deal | |
# in the Software without restriction, including without limitation the rights | |
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
# copies of the Software, and to permit persons to whom the Software is | |
# furnished to do so, subject to the following conditions: | |
# The above copyright notice and this permission notice shall be included in all | |
# copies or substantial portions of the Software. | |
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
# SOFTWARE. | |
## EXAMPLE | |
# $ git fphistory | |
# # Date | Before | | After | Command diff | |
# -------------------------------------------------------------------------------- | |
# * 2024-05-29T17:30:10Z a1b2c3d4 to e5f6a7b8 | git range-diff a1b2c3d4...e5f6a7b8 | |
# 2024-05-17T17:46:20Z c9d8e7f6 to a1b2c3d4 | git range-diff c9d8e7f6...a1b2c3d4 | |
script_path="$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)/$(basename -- "${BASH_SOURCE[0]}")" | |
main () { | |
branch=${1:-$(git rev-parse --abbrev-ref HEAD)} | |
if [[ -z "$branch" ]]; then | |
echo "Failed to get the branch name" | |
exit 1 | |
fi | |
if git remote get-url origin | grep -q 'github.com'; then | |
[[ "$(git remote get-url origin)" =~ github\.com[:/](.*)/(.*)\.git ]] && owner=${BASH_REMATCH[1]} && repo=${BASH_REMATCH[2]} | |
api_key="$GITHUB_API_KEY" | |
api_key="${api_key:-$(cat "$GITHUB_API_KEY_PATH" 2>/dev/null)}" | |
test -n "$api_key" || { echo "error: github api key not found in GITHUB_API_KEY or GITHUB_API_KEY_PATH"; exit 2; } | |
read -r -d '' graphql_query_force_pushes <<'EOF' | |
query($owner: String!, $repo: String!, $branch: String) { | |
repository(owner: $owner, name: $repo) { | |
refs(first: 1, after: null, refPrefix: "refs/heads/", query: $branch) { | |
edges { | |
cursor | |
node { | |
associatedPullRequests(first: 1, after: null) { | |
nodes { | |
title | |
timelineItems(first: 100, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]) { | |
edges { | |
node { | |
__typename | |
... on HeadRefForcePushedEvent { | |
actor { | |
login | |
} | |
createdAt | |
beforeCommit { | |
oid | |
message | |
} | |
afterCommit { | |
oid | |
message | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
pageInfo { | |
endCursor | |
hasNextPage | |
} | |
} | |
} | |
} | |
EOF | |
response_push_events="$( | |
curl -sLX POST \ | |
-H "Authorization: bearer $api_key" \ | |
-H "Content-Type: application/json" \ | |
--data-binary @- "https://api.github.com/graphql" <<EOF | |
{ | |
"query": "$(echo "$graphql_query_force_pushes" | sed -e ':a;N;$!ba;s/\n/ /g' -e 's/"/\\"/g')", | |
"variables": { | |
"owner": "$owner", | |
"repo": "$repo", | |
"branch": "$branch" | |
} | |
} | |
EOF | |
)" | |
# shellcheck disable=SC2119 | |
parsed_json=$(echo "$response_push_events" | json_sh) | |
local index=0 force_pushes_tabdelim="" tab=$'\t' newline=$'\n' | |
while true; do | |
local created_at="" oid_after="" oid_before="" | |
re_created='\["data","repository","refs","edges",0,"node","associatedPullRequests","nodes",0,"timelineItems","edges",'$index',"node","createdAt"][[:space:]]*"([^"]+)"' | |
re_oid_old='\["data","repository","refs","edges",0,"node","associatedPullRequests","nodes",0,"timelineItems","edges",'$index',"node","beforeCommit","oid"][[:space:]]*"([^"]+)"' | |
re_oid_new='\["data","repository","refs","edges",0,"node","associatedPullRequests","nodes",0,"timelineItems","edges",'$index',"node","afterCommit","oid"][[:space:]]*"([^"]+)"' | |
if [[ $parsed_json =~ $re_created ]]; then created_at=${BASH_REMATCH[1]}; fi | |
if [[ $parsed_json =~ $re_oid_old ]]; then oid_before=${BASH_REMATCH[1]}; fi | |
if [[ $parsed_json =~ $re_oid_new ]]; then oid_after=${BASH_REMATCH[1]}; fi | |
if [[ -n "$created_at" ]]; then | |
force_pushes_tabdelim+="${created_at}${tab}${oid_before}${tab}${oid_after}${newline}" | |
else | |
break | |
fi | |
((index++)) | |
done | |
# sort | |
force_pushes_tabdelim=$(echo "$force_pushes_tabdelim" | sort -t$'\t' -k1,1r) | |
else | |
echo "error: remote is not github and code is not implemented to handle other remotes" | |
exit 2 | |
fi | |
current_commit_hash=$(git rev-parse HEAD) | |
echo "# Date | Before | | After | Command diff" | |
echo "--------------------------------------------------------------------------------" | |
if [[ ! "$force_pushes_tabdelim" =~ ^[[:space:]]*$ ]]; then | |
while IFS=$'\t' read -r datetime before_oid after_oid; do | |
before_short_hash=${before_oid:0:8} | |
after_short_hash=${after_oid:0:8} | |
if [ "$current_commit_hash" == "$after_oid" ]; then match_star="*"; else match_star=" "; fi | |
echo "$match_star $datetime $before_short_hash to $after_short_hash | git range-diff $before_short_hash...$after_short_hash" | |
done <<< "$force_pushes_tabdelim" | |
else | |
echo "<none>" | |
fi | |
} | |
(return 0 2>/dev/null) && sourced=1 || sourced=0 | |
if [[ $sourced -eq 0 ]]; then | |
# shellcheck disable=SC1090 | |
source "$script_path" | |
main "$@" | |
fi | |
throw() { | |
echo "$*" >&2 | |
exit 1 | |
} | |
BRIEF=0 | |
LEAFONLY=0 | |
PRUNE=0 | |
NO_HEAD=0 | |
NORMALIZE_SOLIDUS=0 | |
usage() { | |
echo | |
echo "Usage: JSON.sh [-b] [-l] [-p] [-s] [-h]" | |
echo | |
echo "-p - Prune empty. Exclude fields with empty values." | |
echo "-l - Leaf only. Only show leaf nodes, which stops data duplication." | |
echo "-b - Brief. Combines 'Leaf only' and 'Prune empty' options." | |
echo "-n - No-head. Do not show nodes that have no path (lines that start with [])." | |
echo "-s - Remove escaping of the solidus symbol (straight slash)." | |
echo "-h - This help text." | |
echo | |
} | |
parse_options() { | |
set -- "$@" | |
local ARGN=$# | |
while [ "$ARGN" -ne 0 ] | |
do | |
case $1 in | |
-h) usage | |
exit 0 | |
;; | |
-b) BRIEF=1 | |
LEAFONLY=1 | |
PRUNE=1 | |
;; | |
-l) LEAFONLY=1 | |
;; | |
-p) PRUNE=1 | |
;; | |
-n) NO_HEAD=1 | |
;; | |
-s) NORMALIZE_SOLIDUS=1 | |
;; | |
?*) echo "ERROR: Unknown option." | |
usage | |
exit 0 | |
;; | |
esac | |
shift 1 | |
ARGN=$((ARGN-1)) | |
done | |
} | |
awk_egrep () { | |
local pattern_string=$1 | |
gawk '{ | |
while ($0) { | |
start=match($0, pattern); | |
token=substr($0, start, RLENGTH); | |
print token; | |
$0=substr($0, start+RLENGTH); | |
} | |
}' pattern="$pattern_string" | |
} | |
tokenize () { | |
local GREP | |
local ESCAPE | |
local CHAR | |
# shellcheck disable=SC2196 | |
if echo "test string" | egrep -ao --color=never "test" >/dev/null 2>&1 | |
then | |
GREP='egrep -ao --color=never' | |
else | |
GREP='egrep -ao' | |
fi | |
# shellcheck disable=SC2196 | |
if echo "test string" | egrep -o "test" >/dev/null 2>&1 | |
then | |
ESCAPE='(\\[^u[:cntrl:]]|\\u[0-9a-fA-F]{4})' | |
CHAR='[^[:cntrl:]"\\]' | |
else | |
GREP=awk_egrep | |
ESCAPE='(\\\\[^u[:cntrl:]]|\\u[0-9a-fA-F]{4})' | |
CHAR='[^[:cntrl:]"\\\\]' | |
fi | |
local STRING="\"$CHAR*($ESCAPE$CHAR*)*\"" | |
local NUMBER='-?(0|[1-9][0-9]*)([.][0-9]*)?([eE][+-]?[0-9]*)?' | |
local KEYWORD='null|false|true' | |
local SPACE='[[:space:]]+' | |
# Force zsh to expand $A into multiple words | |
# shellcheck disable=SC2155 | |
local is_wordsplit_disabled=$(unsetopt 2>/dev/null | grep -c '^shwordsplit$') | |
# shellcheck disable=SC2086 | |
if [ $is_wordsplit_disabled != 0 ]; then setopt shwordsplit; fi | |
# shellcheck disable=SC2196 | |
$GREP "$STRING|$NUMBER|$KEYWORD|$SPACE|." | egrep -v "^$SPACE$" | |
# shellcheck disable=SC2086 | |
if [ $is_wordsplit_disabled != 0 ]; then unsetopt shwordsplit; fi | |
} | |
parse_array () { | |
local index=0 | |
local ary='' | |
read -r token | |
case "$token" in | |
']') ;; | |
*) | |
while : | |
do | |
parse_value "$1" "$index" | |
index=$((index+1)) | |
ary="$ary""$value" | |
read -r token | |
case "$token" in | |
']') break ;; | |
',') ary="$ary," ;; | |
*) throw "EXPECTED , or ] GOT ${token:-EOF}" ;; | |
esac | |
read -r token | |
done | |
;; | |
esac | |
[ "$BRIEF" -eq 0 ] && value=$(printf '[%s]' "$ary") || value= | |
: | |
} | |
parse_object () { | |
local key | |
local obj='' | |
read -r token | |
case "$token" in | |
'}') ;; | |
*) | |
while : | |
do | |
case "$token" in | |
'"'*'"') key=$token ;; | |
*) throw "EXPECTED string GOT ${token:-EOF}" ;; | |
esac | |
read -r token | |
case "$token" in | |
':') ;; | |
*) throw "EXPECTED : GOT ${token:-EOF}" ;; | |
esac | |
read -r token | |
parse_value "$1" "$key" | |
obj="$obj$key:$value" | |
read -r token | |
case "$token" in | |
'}') break ;; | |
',') obj="$obj," ;; | |
*) throw "EXPECTED , or } GOT ${token:-EOF}" ;; | |
esac | |
read -r token | |
done | |
;; | |
esac | |
[ "$BRIEF" -eq 0 ] && value=$(printf '{%s}' "$obj") || value= | |
: | |
} | |
parse_value () { | |
local jpath="${1:+$1,}$2" isleaf=0 isempty=0 print=0 | |
case "$token" in | |
'{') parse_object "$jpath" ;; | |
'[') parse_array "$jpath" ;; | |
# At this point, the only valid single-character tokens are digits. | |
''|[!0-9]) throw "EXPECTED value GOT ${token:-EOF}" ;; | |
*) value=$token | |
# if asked, replace solidus ("\/") in json strings with normalized value: "/" | |
# shellcheck disable=SC2001 | |
[ "$NORMALIZE_SOLIDUS" -eq 1 ] && value=$(echo "$value" | sed 's#\\/#/#g') | |
isleaf=1 | |
[ "$value" = '""' ] && isempty=1 | |
;; | |
esac | |
[ "$value" = '' ] && return | |
[ "$NO_HEAD" -eq 1 ] && [ -z "$jpath" ] && return | |
[ "$LEAFONLY" -eq 0 ] && [ "$PRUNE" -eq 0 ] && print=1 | |
[ "$LEAFONLY" -eq 1 ] && [ "$isleaf" -eq 1 ] && [ $PRUNE -eq 0 ] && print=1 | |
[ "$LEAFONLY" -eq 0 ] && [ "$PRUNE" -eq 1 ] && [ "$isempty" -eq 0 ] && print=1 | |
[ "$LEAFONLY" -eq 1 ] && [ "$isleaf" -eq 1 ] && \ | |
[ $PRUNE -eq 1 ] && [ $isempty -eq 0 ] && print=1 | |
[ "$print" -eq 1 ] && printf "[%s]\t%s\n" "$jpath" "$value" | |
: | |
} | |
parse () { | |
read -r token | |
parse_value | |
read -r token | |
case "$token" in | |
'') ;; | |
*) throw "EXPECTED EOF GOT $token" ;; | |
esac | |
} | |
# shellcheck disable=SC2120 | |
json_sh () { | |
parse_options "$@" | |
tokenize | parse | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
There are some assumptions made here.