Skip to content

Instantly share code, notes, and snippets.

@Ragnoroct
Last active May 30, 2024 19:42
Show Gist options
  • Save Ragnoroct/7c90a1e55a5ff3ef7972e87971cae015 to your computer and use it in GitHub Desktop.
Save Ragnoroct/7c90a1e55a5ff3ef7972e87971cae015 to your computer and use it in GitHub Desktop.
Git force push history for a branch
#!/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
}
@Ragnoroct
Copy link
Author

There are some assumptions made here.

  1. Remote origin is Github
  2. The specific branch has an associated pull request where it gets the force push history.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment