add git helpers

This commit is contained in:
fatalerrors
2026-04-23 17:18:01 +02:00
parent 241d53ebc4
commit fa573bce8f
2 changed files with 504 additions and 0 deletions

497
profile.d/git.sh Executable file
View File

@@ -0,0 +1,497 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * Redistribution and use in source and binary forms,
# * with or without modification, are permitted provided
# * that the following conditions are met:
# *
# * Redistributions of source code must retain the above
# * copyright notice, this list of conditions and the
# * following disclaimer.
# *
# * Redistributions in binary form must reproduce the above
# * copyright notice, this list of conditions and the following
# * disclaimer in the documentation and/or other materials
# * provided with the distribution.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Built-in defaults (can be overridden from [git] section in profile.conf)
: "${GIT_MAIN_BRANCH:=main}"
: "${GIT_DEFAULT_REMOTE:=origin}"
: "${GIT_WIP_PREFIX:=wip}"
# ------------------------------------------------------------------------------
# Internal helper: ensure git is available and cwd is a git worktree
_git_require_repo()
{
if ! command -v git >/dev/null 2>&1; then
disp E "git command not found."
return 1
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
disp E "Current directory is not inside a git repository."
return 1
fi
return 0
}
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Internal helper: return default branch from remote HEAD, fallback to config
_git_default_branch()
{
local remote="${1:-$GIT_DEFAULT_REMOTE}"
local head
head=$(git symbolic-ref --quiet --short "refs/remotes/${remote}/HEAD" 2>/dev/null) || true
if [[ -n $head ]]; then
printf "%s\n" "${head#${remote}/}"
return 0
fi
printf "%s\n" "$GIT_MAIN_BRANCH"
return 0
}
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Display compact git status + branch tracking information
# Usage: gst [path]
gst()
{
local PARSED
PARSED=$(getopt -o h --long help -n 'gst' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"gst --help\" to display usage."
return 1
fi
eval set -- "$PARSED"
while true; do
case "$1" in
-h|--help)
printf "gst: Display short git status and branch tracking info.\n"
printf "Usage: gst [path]\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"gst --help\" to display usage."
return 1
;;
esac
done
local target="${1:-.}"
git -C "$target" status --short --branch
}
export -f gst
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Show a readable commit graph
# Usage: ggraph [-n limit]
ggraph()
{
local PARSED
PARSED=$(getopt -o hn: --long help,limit: -n 'ggraph' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"ggraph --help\" to display usage."
return 1
fi
eval set -- "$PARSED"
local limit=30
while true; do
case "$1" in
-h|--help)
printf "ggraph: Display decorated git history graph.\n"
printf "Usage: ggraph [-n limit]\n"
printf "Options:\n"
printf "\t-n, --limit\tNumber of commits to display (default: 30)\n"
return 0
;;
-n|--limit)
limit="$2"
shift 2
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"ggraph --help\" to display usage."
return 1
;;
esac
done
[[ $limit =~ ^[0-9]+$ ]] || {
disp E "Invalid limit: must be a positive integer."
return 1
}
_git_require_repo || return 1
git log --graph --decorate --oneline --all --max-count="$limit"
}
export -f ggraph
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Sync current branch with remote (fetch + rebase)
# Usage: gsync [remote]
gsync()
{
local PARSED
PARSED=$(getopt -o h --long help -n 'gsync' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"gsync --help\" to display usage."
return 1
fi
eval set -- "$PARSED"
while true; do
case "$1" in
-h|--help)
printf "gsync: Fetch and rebase current branch onto its remote tracking branch.\n"
printf "Usage: gsync [remote]\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"gsync --help\" to display usage."
return 1
;;
esac
done
_git_require_repo || return 1
local remote="${1:-$GIT_DEFAULT_REMOTE}"
local branch upstream
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || return 1
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null) || true
disp I "Fetching from $remote..."
git fetch --prune "$remote" || return 1
if [[ -z $upstream ]]; then
disp W "No upstream configured for $branch, skipping rebase."
disp I "Set one with: git branch --set-upstream-to ${remote}/${branch} ${branch}"
return 0
fi
disp I "Rebasing $branch onto $upstream..."
git rebase "$upstream"
}
export -f gsync
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Reset local branch to exact upstream state (stash local changes first)
# Usage: greset [target]
greset()
{
local PARSED
PARSED=$(getopt -o hx --long help,with-ignored -n 'greset' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"greset --help\" to display usage."
return 1
fi
eval set -- "$PARSED"
local clean_ignored=0
while true; do
case "$1" in
-h|--help)
printf "greset: Reset current branch to upstream, stashing local changes first.\n"
printf "Usage: greset [target]\n"
printf "Options:\n"
printf "\t-x, --with-ignored\tAlso remove ignored files (git clean -fdx)\n"
printf "\n"
printf "Default target is current branch upstream (@{u}).\n"
printf "If no upstream exists, fallback target is <remote>/<branch>.\n"
printf "This command stashes local modifications (tracked + untracked),\n"
printf "drops local unpushed commits by hard-reset, and cleans untracked files.\n"
return 0
;;
-x|--with-ignored)
clean_ignored=1
shift
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"greset --help\" to display usage."
return 1
;;
esac
done
_git_require_repo || return 1
local branch upstream target remote old_head stash_msg stash_out stash_created=0 dropped=0
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || return 1
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null) || true
target="$1"
if [[ -z $target ]]; then
if [[ -n $upstream ]]; then
target="$upstream"
else
remote="$GIT_DEFAULT_REMOTE"
target="${remote}/${branch}"
fi
fi
if [[ -z $remote ]]; then
remote="${target%%/*}"
fi
old_head=$(git rev-parse HEAD 2>/dev/null) || return 1
if ! git diff --quiet || ! git diff --cached --quiet || [[ -n $(git ls-files --others --exclude-standard) ]]; then
stash_msg="greset:${branch}:$(date +'%Y-%m-%d %H:%M:%S')"
disp I "Stashing local changes as '$stash_msg'..."
stash_out=$(git stash push -u -m "$stash_msg" 2>&1) || {
disp E "Failed to stash local changes."
printf "%s\n" "$stash_out"
return 1
}
[[ $stash_out != "No local changes to save"* ]] && stash_created=1
fi
disp I "Fetching from $remote..."
git fetch --prune "$remote" || return 1
if ! git rev-parse --verify --quiet "$target" >/dev/null; then
disp E "Target '$target' does not exist."
return 1
fi
dropped=$(git rev-list --count "${target}..${old_head}" 2>/dev/null || printf "0")
disp W "Hard-resetting $branch to $target..."
git reset --hard "$target" || return 1
if (( clean_ignored )); then
git clean -fdx || return 1
else
git clean -fd || return 1
fi
if (( stash_created )); then
disp I "Local changes were stashed. Use 'git stash list' and 'git stash pop' when needed."
fi
disp I "greset complete. Dropped local-only commits: $dropped"
return 0
}
export -f greset
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Create a quick WIP commit for local checkpointing
# Usage: gwip [message]
gwip()
{
local PARSED
PARSED=$(getopt -o h --long help -n 'gwip' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"gwip --help\" to display usage."
return 1
fi
eval set -- "$PARSED"
while true; do
case "$1" in
-h|--help)
printf "gwip: Create a local checkpoint commit with all tracked/untracked changes.\n"
printf "Usage: gwip [message]\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"gwip --help\" to display usage."
return 1
;;
esac
done
_git_require_repo || return 1
local msg
if [[ $# -gt 0 ]]; then
msg="$*"
else
msg="$GIT_WIP_PREFIX: $(date +'%Y-%m-%d %H:%M:%S')"
fi
git add -A || return 1
git commit -m "$msg"
}
export -f gwip
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Delete merged local branches (except protected branches)
# Usage: gprune [main-branch]
gprune()
{
local PARSED
PARSED=$(getopt -o h --long help -n 'gprune' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"gprune --help\" to display usage."
return 1
fi
eval set -- "$PARSED"
while true; do
case "$1" in
-h|--help)
printf "gprune: Delete local branches already merged into main branch.\n"
printf "Usage: gprune [main-branch]\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"gprune --help\" to display usage."
return 1
;;
esac
done
_git_require_repo || return 1
local base="${1:-$(_git_default_branch "$GIT_DEFAULT_REMOTE")}" current deleted=0
current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || return 1
disp I "Pruning branches merged into $base..."
while IFS= read -r b; do
[[ -z $b ]] && continue
[[ $b == "$current" ]] && continue
[[ $b == "$base" ]] && continue
[[ $b == "master" || $b == "main" || $b == "develop" || $b == "dev" ]] && continue
git branch -d "$b" >/dev/null 2>&1 && {
printf "Deleted: %s\n" "$b"
((deleted++))
}
done < <(git branch --merged "$base" | sed -E 's/^\*?\s*//')
(( deleted == 0 )) && disp I "No merged branches to delete."
}
export -f gprune
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Print repository root path
# Usage: groot
groot()
{
local PARSED
PARSED=$(getopt -o hg --long help,go -n 'groot' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"groot --help\" to display usage."
return 1
fi
eval set -- "$PARSED"
local do_go=0
while true; do
case "$1" in
-h|--help)
printf "groot: Display the absolute path of the current repository root.\n"
printf "Usage: groot [-g|--go]\n"
printf "Options:\n"
printf "\t-g, --go\tChange current directory to repository root\n"
return 0
;;
-g|--go)
do_go=1
shift
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"groot --help\" to display usage."
return 1
;;
esac
done
_git_require_repo || return 1
local root
root=$(git rev-parse --show-toplevel) || return 1
if (( do_go )); then
cd "$root" || {
disp E "Failed to move to repository root: $root"
return 1
}
return 0
fi
printf "%s\n" "$root"
}
export -f groot
# ------------------------------------------------------------------------------
load_conf git
# EOF

View File

@@ -53,6 +53,13 @@ help()
printf "finddead\tFind dead symbolic links in the given or current directory\n"
printf "findzero\tFind empty files in the given or current directory\n"
printf "genpwd\t\tGenerate one or more random secure passwords with configurable constraints\n"
printf "ggraph\t\tDisplay decorated git history graph\n"
printf "gprune\t\tDelete local branches already merged into main branch\n"
printf "greset\t\tReset branch to upstream (stash local, drop local commits)\n"
printf "groot\t\tDisplay repository root path (or cd to it with -g)\n"
printf "gsync\t\tFetch and rebase current branch onto upstream\n"
printf "gst\t\tDisplay short git status and branch tracking info\n"
printf "gwip\t\tCreate a quick WIP checkpoint commit\n"
printf "gpid\t\tGive the list of PIDs matching the given process name(s)\n"
printf "isipv4\t\tTell if the given parameter is a valid IPv4 address\n"
printf "isipv6\t\tTell if the given parameter is a valid IPv6 address\n"