#!/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 # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # Add, commit, and push changes with automatic pull/rebase if needed # Usage: gacp -m "message" [file1 file2 ...] gacp() { local PARSED PARSED=$(getopt -o hm: --long help,message: -n 'gacp' -- "$@") # shellcheck disable=SC2181 # getopt return code is checked immediately after if [[ $? -ne 0 ]]; then disp E "Invalid options, use \"gacp --help\" to display usage." return 1 fi eval set -- "$PARSED" local msg="" while true; do case "$1" in -h|--help) printf "gacp: Run git add, git commit, and git push in one command.\n" printf "Usage: gacp -m \"message\" [file1 file2 ...]\n" printf "Options:\n" printf "\t-m, --message\tCommit message (mandatory)\n" printf "\n" printf "If files are provided, only those paths are added.\n" printf "If no file is provided, all changes are added with git add -A.\n" printf "If the remote branch moved forward, gacp pulls with rebase before pushing.\n" return 0 ;; -m|--message) msg="$2" shift 2 ;; --) shift break ;; *) disp E "Invalid options, use \"gacp --help\" to display usage." return 1 ;; esac done _git_require_repo || return 1 if [[ -z $msg ]]; then disp E "Missing commit message. Use -m or --message." return 1 fi local branch upstream remote tracking_branch ahead behind counts 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 if [[ $# -gt 0 ]]; then disp I "Adding selected paths..." git add -- "$@" || return 1 else disp I "Adding all changes..." git add -A || return 1 fi if git diff --cached --quiet; then disp W "No staged changes to commit." return 1 fi disp I "Creating commit..." git commit -m "$msg" || return 1 if [[ -n $upstream ]]; then remote="${upstream%%/*}" tracking_branch="${upstream#*/}" else remote="$GIT_DEFAULT_REMOTE" tracking_branch="$branch" fi disp I "Fetching from $remote..." git fetch --prune "$remote" || return 1 if git rev-parse --verify --quiet "refs/remotes/${remote}/${tracking_branch}" >/dev/null; then counts=$(git rev-list --left-right --count HEAD..."${remote}/${tracking_branch}" 2>/dev/null) || return 1 read -r ahead behind <<< "$counts" if [[ ${behind:-0} -gt 0 ]]; then disp I "Remote branch is ahead, rebasing before push..." git pull --rebase "$remote" "$tracking_branch" || return 1 fi fi if [[ -n $upstream ]]; then disp I "Pushing to $upstream..." git push || return 1 else disp I "Pushing and setting upstream to ${remote}/${branch}..." git push -u "$remote" "$branch" || return 1 fi disp I "gacp complete." return 0 } export -f gacp # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # 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