diff --git a/profile.d/git.sh b/profile.d/git.sh new file mode 100755 index 0000000..9b857c8 --- /dev/null +++ b/profile.d/git.sh @@ -0,0 +1,497 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) 2013-2026 Geoffray Levasseur +# 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 /.\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 diff --git a/profile.d/help.sh b/profile.d/help.sh index 0f93db6..d0237f3 100644 --- a/profile.d/help.sh +++ b/profile.d/help.sh @@ -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"