703 lines
21 KiB
Bash
703 lines
21 KiB
Bash
#!/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.
|
|
# ------------------------------------------------------------------------------
|
|
|
|
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 <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 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 <main-branch> 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 <branch>@{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
|