#!/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. # ------------------------------------------------------------------------------ load_conf git # Common helpers, then the ones private to this module (helpers/git.sh) # shellcheck source=../helpers/common.sh declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" _profile_load_helpers "${BASH_SOURCE[0]}" # ------------------------------------------------------------------------------ # Built-in defaults (can be overridden from [git] section in profile.conf) : "${GIT_MAIN_BRANCH:=main}" : "${GIT_DEFAULT_REMOTE:=origin}" : "${GIT_WIP_PREFIX:=wip}" : "${GIT_GACP_AUTO_ADD:=1}" # ------------------------------------------------------------------------------ # 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 # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" 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:-.}" if ! command -v git >/dev/null 2>&1; then disp E "git command not found." return 1 fi if ! git -C "$target" rev-parse --is-inside-work-tree >/dev/null 2>&1; then disp E "'$target' is not inside a git repository." return 1 fi 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 # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" 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 =~ ^[1-9][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 # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" 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 branch tracking remote upstream branch=$(_git_current_branch) || return 1 upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null) || true # Without argument, fetch the remote the branch actually tracks, so the # fetched refs and the rebase target always match; fall back to the # configured default remote. remote="${1:-}" if [[ -z $remote ]]; then if tracking=$(_git_tracking "$branch"); then remote="${tracking%%$'\t'*}" else remote="$GIT_DEFAULT_REMOTE" fi fi 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 ham: --long help,auto,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 # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" local msg="" auto_add=0 case "${GIT_GACP_AUTO_ADD,,}" in 1|true|yes|on) auto_add=1 ;; esac 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 [-a] -m \"message\" [file1 file2 ...]\n" printf "Options:\n" printf "\t-a, --auto\tAutomatically add all modified files (git add -A)\n" printf "\t-m, --message\tCommit message (mandatory)\n" printf "\n" printf "If files are provided, only those paths are added (-a is ignored).\n" printf "If no file is provided and -a is active, all changes are added with git add -A.\n" printf "Default for -a can be set via GIT_GACP_AUTO_ADD in profile.conf.\n" printf "If the remote branch moved forward, gacp pulls with rebase before pushing.\n" return 0 ;; -a|--auto) auto_add=1 shift ;; -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 behind counts tracking branch=$(_git_current_branch) || return 1 # Remote and branch tracked by the current branch (empty when none, or when # it tracks another local branch) if tracking=$(_git_tracking "$branch"); then remote="${tracking%%$'\t'*}" tracking_branch="${tracking#*$'\t'}" upstream="${remote}/${tracking_branch}" fi if [[ $# -gt 0 ]]; then auto_add=0 disp I "Adding selected paths..." git add -- "$@" || return 1 elif [[ $auto_add -eq 1 ]]; then disp I "Adding all changes..." git add -A || return 1 else disp E "No files specified. Use -a/--auto or provide file paths." 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 [[ -z $upstream ]]; then 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 _ behind <<< "$counts" if [[ ${behind:-0} -gt 0 ]]; then disp I "Remote branch is ahead, rebasing before push..." git pull --rebase "$remote" "$tracking_branch" || { disp E "Rebase failed. Your commit is kept locally: fix the conflicts (or 'git rebase --abort') and push manually." 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 # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" 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); they are\n" printf "\t\t\t\tnot stashed, so this cannot be undone\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 root 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" # A target starting with "-" would be taken for an option by git if [[ $target == -* ]]; then disp E "Invalid target '$target'." return 1 fi if [[ -z $target ]]; then if [[ -n $upstream ]]; then target="$upstream" elif [[ $branch == "HEAD" ]]; then disp E "HEAD is detached and has no upstream: give the target explicitly." return 1 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 # Fetch and validate the target BEFORE touching the working tree: with an # unknown target, the function must fail without having stashed anything. # Only fetch when the derived remote is an actual configured remote; a bare # tag or local-branch target has no remote to update from. if git remote get-url "$remote" >/dev/null 2>&1; then disp I "Fetching from $remote..." git fetch --prune "$remote" || return 1 else disp I "Target '$target' is not a remote ref; skipping fetch." fi if ! git rev-parse --verify --quiet "${target}^{commit}" >/dev/null; then disp E "Target '$target' does not exist." return 1 fi # "status --porcelain" covers the whole repository (untracked files # included), whatever the current subdirectory is. if [[ -n $(git status --porcelain 2>/dev/null) ]]; then stash_msg="greset:${branch}:$(date +'%Y-%m-%d %H:%M:%S')" disp I "Stashing local changes as '$stash_msg'..." local stash_before stash_after stash_before=$(git rev-parse --quiet --verify refs/stash 2>/dev/null) || true 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_after=$(git rev-parse --quiet --verify refs/stash 2>/dev/null) || true # A new stash means the top refs/stash sha changed (locale-independent). if [[ -n $stash_after && $stash_after != "$stash_before" ]]; then stash_created=1 fi 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 # git clean only works below the current directory: run it from the root so # it covers the same tree as the reset and the stash above. root=$(git rev-parse --show-toplevel) || return 1 if (( clean_ignored )); then git -C "$root" clean -fdx || return 1 else git -C "$root" 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" disp I "Previous HEAD was ${old_head:0:12}; 'git reset --hard ${old_head:0:12}' restores it." 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 # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" 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 [-D|--deep] [main-branch] # # Default mode: deletes branches already merged into locally # (git branch --merged). # Deep mode (-D/--deep): additionally deletes branches whose tracking remote # has disappeared (remote pruned after a merge request / pull request merge). # Those branches are detected via 'git fetch --prune' + remote-tracking gone. # This is the common case when the MR was merged upstream and the remote # branch was deleted by the forge. Deletion uses 'git branch -D' (force) # because the local branch has no merged ancestor that git can verify locally. gprune() { local PARSED deep=0 PARSED=$(getopt -o hD --long help,deep -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 # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) printf "gprune: Delete local branches already merged into main branch.\n\n" printf "Usage: gprune [-D|--deep] [main-branch]\n\n" printf "Options:\n" printf "\t-D, --deep\tAlso delete branches whose upstream was removed\n" printf "\t\t\t(remote deleted after MR/PR merge). Uses 'git branch -D'.\n" printf "\t-h, --help\tDisplay this help screen\n\n" printf "Arguments:\n" printf "\tmain-branch\tBase branch to check merges against (default: auto-detected)\n" return 0 ;; -D|--deep) deep=1 shift ;; --) 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 b current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || return 1 if ! git rev-parse --verify --quiet "refs/heads/${base}" >/dev/null; then disp E "Base branch '$base' does not exist locally." return 1 fi # Branches that are never deleted: the base, the checked-out one, the usual # trunk names and the configured main branch. local -a protected=("$base" "$current" master main develop dev "$GIT_MAIN_BRANCH") _gprune_is_protected() { local p for p in "${protected[@]}"; do [[ $1 == "$p" ]] && return 0 done return 1 } # ── Standard mode: branches locally merged into base ────────────────────── disp I "Pruning branches merged into $base..." # for-each-ref gives plain names (git branch output carries "*", "+" and # colour markers); "-d" refuses anything not fully merged. while IFS= read -r b; do [[ -z $b ]] && continue _gprune_is_protected "$b" && continue if git branch -d "$b" >/dev/null 2>&1; then printf "Deleted (merged): %s\n" "$b" deleted=$(( deleted + 1 )) fi done < <(git for-each-ref --merged="$base" --format='%(refname:short)' refs/heads/) # ── Deep mode: branches whose remote tracking ref was deleted upstream ───── if (( deep )); then disp I "Deep mode: pruning remote-tracking refs, then checking for gone branches..." git fetch --prune --quiet || disp W "Fetch failed: using the remote-tracking refs as they are." local upstream sha # %(upstream) is the full ref configured as upstream, and stays set once # the remote branch is gone, whereas "git rev-parse @{upstream}" # fails then. It also avoids parsing the localized "[gone]" marker. while IFS=$'\t' read -r b upstream; do [[ -z $b ]] && continue _gprune_is_protected "$b" && continue [[ -z $upstream ]] && continue # no tracking branch at all — skip # If the upstream ref still exists, it is not gone. git show-ref --verify --quiet "$upstream" 2>/dev/null && continue sha=$(git rev-parse --short "$b" 2>/dev/null) if git branch -D "$b" >/dev/null 2>&1; then printf "Deleted (gone upstream): %s (%s)\n" "$b" "$sha" deleted=$(( deleted + 1 )) fi done < <(git for-each-ref --format='%(refname:short)%09%(upstream)' refs/heads/) fi unset -f _gprune_is_protected # "if" keeps the exit status at 0 when branches were deleted if (( deleted == 0 )); then disp I "No branches to delete." fi } 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 # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" 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 # ------------------------------------------------------------------------------ # EOF