diff --git a/README.md b/README.md index 0450645..202d3b1 100644 --- a/README.md +++ b/README.md @@ -108,10 +108,10 @@ A bar-style prompt showing current time, execution time of the last command | `genpwd` | pwd | Generate one or more random secure passwords with configurable constraints | | `ggraph` | git | Display a decorated git history graph | | `gpid` | processes | Give the list of PIDs matching the given process name(s) | -| `gprune` | git | Delete local branches already merged into the main branch | -| `greset` | git | Reset the current branch to upstream, stashing local changes first | +| `gprune` | git | Delete local branches already merged into the main branch; `-D`/`--deep` also deletes (force) branches whose upstream branch was deleted on the remote, e.g. after a merge request | +| `greset` | git | Reset the current branch to upstream, stashing local changes first (the target is checked before anything is stashed; the previous HEAD is printed for recovery); `-x` also removes ignored files, which cannot be undone | | `groot` | git | Display the repository root path, or change directory to it with `-g` | -| `gsync` | git | Fetch and rebase the current branch onto its upstream | +| `gsync` | git | Fetch (the remote the branch tracks, or the given one) and rebase the current branch onto its upstream; needs a checked-out branch | | `gst` | git | Display compact git status with branch tracking information | | `gwip` | git | Create a quick WIP checkpoint commit | | `hack` | fun | Simulate a dramatic multi-phase hacking sequence | diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 829d18c..ff0445a 100755 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -71,6 +71,15 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`). `busy` documented in its usage now works; `grep`/`read` no longer mangle patterns starting with `-` or lines with leading blanks; `awk` is no longer forked for each pause. +- `git.sh`: `gprune --deep` never deleted anything (`@{upstream}` fails + once the remote branch is gone; it now reads the configured upstream ref); + `gprune` returned 1 on success and could not be told which branches are + protected (`GIT_MAIN_BRANCH` is now honoured); `greset` stashed the local + changes before checking that the target exists, ignored untracked files + outside the current directory and cleaned only below it; `gsync` could fetch + a different remote than the one it rebased on; `gacp`/`gsync` handle remote + and branch names containing slashes, and refuse a detached HEAD; `ggraph` + rejects a limit of 0; internal helpers are exported with the commands. - `compress.sh`: option flags and loop variables no longer leak into the interactive shell. diff --git a/profile.d/git.sh b/profile.d/git.sh index 511be09..5747d49 100644 --- a/profile.d/git.sh +++ b/profile.d/git.sh @@ -60,6 +60,7 @@ _git_require_repo() return 0 } +export -f _git_require_repo # ------------------------------------------------------------------------------ @@ -79,6 +80,41 @@ _git_default_branch() printf "%s\n" "$GIT_MAIN_BRANCH" return 0 } +export -f _git_default_branch +# ------------------------------------------------------------------------------ + + +# ------------------------------------------------------------------------------ +# Internal helper: print the name of the checked-out branch; fail with an error +# message on a detached HEAD (commands that push or rebase need a branch). +_git_current_branch() +{ + local b + b=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || { + disp E "HEAD is detached: check out a branch first." + return 1 + } + printf "%s\n" "$b" +} +export -f _git_current_branch +# ------------------------------------------------------------------------------ + + +# ------------------------------------------------------------------------------ +# Internal helper: print "" for the remote branch tracked +# by local branch $1, read from its configuration (branch..remote/.merge). +# Unlike parsing the "origin/feature/x" form given by @{u}, this is exact for +# remote and branch names containing slashes. Fails when nothing is tracked or +# when the branch tracks another local branch (remote "."). +_git_tracking() +{ + local branch=$1 remote merge + remote=$(git config --get "branch.${branch}.remote") || return 1 + merge=$(git config --get "branch.${branch}.merge") || return 1 + [[ $remote == "." ]] && return 1 + printf "%s\t%s\n" "$remote" "${merge#refs/heads/}" +} +export -f _git_tracking # ------------------------------------------------------------------------------ @@ -95,9 +131,8 @@ gst() return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" while true; do case "$1" in -h|--help) @@ -144,9 +179,8 @@ ggraph() return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" local limit=30 while true; do case "$1" in @@ -172,7 +206,7 @@ ggraph() esac done - [[ $limit =~ ^[0-9]+$ ]] || { + [[ $limit =~ ^[1-9][0-9]*$ ]] || { disp E "Invalid limit: must be a positive integer." return 1 } @@ -197,9 +231,8 @@ gsync() return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" while true; do case "$1" in -h|--help) @@ -220,11 +253,22 @@ gsync() _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 + 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 @@ -254,9 +298,8 @@ gacp() return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" local msg="" auto_add=0 case "${GIT_GACP_AUTO_ADD,,}" in 1|true|yes|on) auto_add=1 ;; @@ -302,10 +345,15 @@ gacp() return 1 fi - local branch upstream remote tracking_branch 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 - + 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..." @@ -326,10 +374,7 @@ gacp() disp I "Creating commit..." git commit -m "$msg" || return 1 - if [[ -n $upstream ]]; then - remote="${upstream%%/*}" - tracking_branch="${upstream#*/}" - else + if [[ -z $upstream ]]; then remote="$GIT_DEFAULT_REMOTE" tracking_branch="$branch" fi @@ -342,7 +387,10 @@ gacp() 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" || return 1 + 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 @@ -374,9 +422,8 @@ greset() return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" local clean_ignored=0 while true; do case "$1" in @@ -384,7 +431,8 @@ greset() 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 "\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" @@ -409,14 +457,23 @@ greset() _git_require_repo || return 1 - local branch upstream target remote old_head stash_msg stash_out stash_created=0 dropped=0 + 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}" @@ -429,7 +486,25 @@ greset() 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 + # 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 @@ -441,21 +516,9 @@ greset() } 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). - [[ -n $stash_after && $stash_after != "$stash_before" ]] && stash_created=1 - fi - - # 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" >/dev/null; then - disp E "Target '$target' does not exist." - return 1 + 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") @@ -463,16 +526,20 @@ greset() 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 clean -fdx || return 1 + git -C "$root" clean -fdx || return 1 else - git clean -fd || return 1 + 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 @@ -492,9 +559,8 @@ gwip() return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" while true; do case "$1" in -h|--help) @@ -551,9 +617,8 @@ gprune() return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" while true; do case "$1" in -h|--help) @@ -584,51 +649,69 @@ gprune() _git_require_repo || return 1 - local base="${1:-$(_git_default_branch "$GIT_DEFAULT_REMOTE")}" current deleted=0 + 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 - [[ $b == "$current" ]] && continue - [[ $b == "$base" ]] && continue - [[ $b == "master" || $b == "main" || $b == "develop" || $b == "dev" ]] && continue - git branch -d "$b" >/dev/null 2>&1 && { + _gprune_is_protected "$b" && continue + if git branch -d "$b" >/dev/null 2>&1; then printf "Deleted (merged): %s\n" "$b" - deleted=$(( deleted+1 )) - } - done < <(git branch --merged "$base" | sed -E 's/^\*?\s*//') + 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 + git fetch --prune --quiet || disp W "Fetch failed: using the remote-tracking refs as they are." local upstream sha - while IFS= read -r b; do + # %(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 - [[ $b == "$current" ]] && continue - [[ $b == "$base" ]] && continue - [[ $b == "master" || $b == "main" || $b == "develop" || $b == "dev" ]] && continue + _gprune_is_protected "$b" && continue + [[ -z $upstream ]] && continue # no tracking branch at all — skip - # Detect a gone upstream without relying on localized "gone]" text: - # resolve the branch's upstream, then check its remote-tracking ref. - upstream=$(git rev-parse --abbrev-ref "${b}@{upstream}" 2>/dev/null) || true - [[ -z "$upstream" ]] && continue # no tracking branch at all — skip - - # If the remote-tracking ref still exists, the upstream is not gone. - git show-ref --verify --quiet "refs/remotes/$upstream" 2>/dev/null && continue + # 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) - git branch -D "$b" >/dev/null 2>&1 && { + if git branch -D "$b" >/dev/null 2>&1; then printf "Deleted (gone upstream): %s (%s)\n" "$b" "$sha" - deleted=$(( deleted+1)) - } - done < <(git for-each-ref --format='%(refname:short)' refs/heads/) + deleted=$(( deleted + 1 )) + fi + done < <(git for-each-ref --format='%(refname:short)%09%(upstream)' refs/heads/) fi - (( deleted == 0 )) && disp I "No branches to delete." + 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 # ------------------------------------------------------------------------------ @@ -647,9 +730,8 @@ groot() return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" local do_go=0 while true; do case "$1" in