Add configuration for git

- Add .config/git/config
  + Define aliases
  + Configure default behavior in various situations
  + Add user information with signing key
- Add .config/git/commit_msg_template.txt
- Add .config/git/ignore
- Integrate `git` aliases into the shell
This commit is contained in:
Alexander Hess 2026-06-11 18:32:27 +02:00
commit 721c2fd905
Signed by: alexander
GPG key ID: B46EDB4DFC26805D
21 changed files with 1122 additions and 66 deletions

63
.bashrc
View file

@ -69,7 +69,6 @@ PS1='${debian_chroot:+($debian_chroot)}\u@\h:\w\$ '
# Enable tab completion # Enable tab completion
if ! shopt -oq posix; then if ! shopt -oq posix; then
if [ -f /usr/share/bash-completion/bash_completion ]; then if [ -f /usr/share/bash-completion/bash_completion ]; then
. /usr/share/bash-completion/bash_completion . /usr/share/bash-completion/bash_completion
@ -78,68 +77,6 @@ if ! shopt -oq posix; then
fi fi
fi fi
# Add tab completion for all aliases to commands with completion functions
# (must come after bash completions have been set up)
# Source: https://superuser.com/a/437508
_alias_completion() {
local namespace="alias_completion"
# parse function based completion definitions, where capture group 2 => function and 3 => trigger
local compl_regex='complete( +[^ ]+)* -F ([^ ]+) ("[^"]+"|[^ ]+)'
# parse alias definitions, where capture group 1 => trigger, 2 => command, 3 => command arguments
local alias_regex="alias ([^=]+)='(\"[^\"]+\"|[^ ]+)(( +[^ ]+)*)'"
# create array of function completion triggers, keeping multi-word triggers together
eval "local completions=($(complete -p | sed -Ene "/$compl_regex/s//'\3'/p"))"
(( ${#completions[@]} == 0 )) && return 0
# create temporary file for wrapper functions and completions
rm -f "/tmp/${namespace}-*.tmp" # preliminary cleanup
local tmp_file; tmp_file="$(mktemp "/tmp/${namespace}-${RANDOM}XXX.tmp")" || return 1
local completion_loader; completion_loader="$(complete -p -D 2>/dev/null | sed -Ene 's/.* -F ([^ ]*).*/\1/p')"
# read in "<alias> '<aliased command>' '<command args>'" lines from defined aliases
local line; while read line; do
eval "local alias_tokens; alias_tokens=($line)" 2>/dev/null || continue # some alias arg patterns cause an eval parse error
local alias_name="${alias_tokens[0]}" alias_cmd="${alias_tokens[1]}" alias_args="${alias_tokens[2]# }"
# skip aliases to pipes, boolean control structures and other command lists
# (leveraging that eval errs out if $alias_args contains unquoted shell metacharacters)
eval "local alias_arg_words; alias_arg_words=($alias_args)" 2>/dev/null || continue
# avoid expanding wildcards
read -a alias_arg_words <<< "$alias_args"
# skip alias if there is no completion function triggered by the aliased command
if [[ ! " ${completions[*]} " =~ " $alias_cmd " ]]; then
if [[ -n "$completion_loader" ]]; then
# force loading of completions for the aliased command
eval "$completion_loader $alias_cmd"
# 124 means completion loader was successful
[[ $? -eq 124 ]] || continue
completions+=($alias_cmd)
else
continue
fi
fi
local new_completion="$(complete -p "$alias_cmd")"
# create a wrapper inserting the alias arguments if any
if [[ -n $alias_args ]]; then
local compl_func="${new_completion/#* -F /}"; compl_func="${compl_func%% *}"
# avoid recursive call loops by ignoring our own functions
if [[ "${compl_func#_$namespace::}" == $compl_func ]]; then
local compl_wrapper="_${namespace}::${alias_name}"
echo "function $compl_wrapper {
(( COMP_CWORD += ${#alias_arg_words[@]} ))
COMP_WORDS=($alias_cmd $alias_args \${COMP_WORDS[@]:1})
(( COMP_POINT -= \${#COMP_LINE} ))
COMP_LINE=\${COMP_LINE/$alias_name/$alias_cmd $alias_args}
(( COMP_POINT += \${#COMP_LINE} ))
$compl_func
}" >> "$tmp_file"
new_completion="${new_completion/ -F $compl_func / -F $compl_wrapper }"
fi
fi
# replace completion trigger by alias
new_completion="${new_completion% *} $alias_name"
echo "$new_completion" >> "$tmp_file"
done < <(alias -p | sed -Ene "s/$alias_regex/\1 '\2' '\3'/p")
. "$tmp_file" && \rm -f "$tmp_file"
}; _alias_completion
# Jump to frequently used directories with `z <fragment>` # Jump to frequently used directories with `z <fragment>`
if _command_exists zoxide; then if _command_exists zoxide; then

10
.config/git/README.md Normal file
View file

@ -0,0 +1,10 @@
# `git`-related Configurations
This folder contains
the [config](./config),
[global ignores](./ignore), and
the [commit message template](./commit_msg_template.txt)
for [`git`](https://git-scm.com/).
Machine-local settings (e.g., signing keys)
go into an untracked `config_local`.

View file

@ -0,0 +1,17 @@
# SUBJECT -> What does the commit do?
# - Imperative mood, no "." at the end
# - Start with "Add", "Fix", "Make", "Remove", ...
#---------- 50 characters / 1 line ---------------|
# BODY -> The why and how
# - Use plain text without formatting
# + Use "-" and "+" as bullets
# - "See: URL" to link to external resources
#---------- 72 characters / multiple lines -----------------------------|
# ISSUE TRACKER -> Uncomment one of the lines below
# Closes #41
# Fixes #42
# Resolves #43

626
.config/git/config Normal file
View file

@ -0,0 +1,626 @@
# This config requires `git` v2.40+
# (e.g., Debian 12's v2.39 is too old)
#
# The most recent features used are:
#
# v2.40 help.autocorrect with "prompt"
# v2.38 rebase.updateRefs
# v2.37 push.autoSetupRemote
# v2.35 merge.conflictstyle with "zdiff3"
#
# Older `git` versions do NOT fail on all of these
# but may warn about some unknown values
[alias]
# `git` commands are defined in three places:
#
# 1) Plain abbreviations and short "!" one-liners
# are defined in this file
#
# 2) ~/.local/bin/ holds `git-*` utilities
# for everything with control flow or an error path,
# which `git` registers as sub-commands automatically
#
# 3) ~/.config/shell/aliases defines short form aliases for the shell,
# for example, `gap` for `git add --patch`
#
# Below, the aliases are grouped by task:
# Full-name aliases come first in each group,
# followed by short abbreviations
#
# Tab complete works out of the box in `bash`,
# whereas a `zstyle` line in ~/.zshrc makes that work for `zsh`
#
# Run:
# - `git aliases` for just 1)
# - `git aliases-n-sub-commands` for 1) and 2)
# - `git help -a` for ALL sub-commands, built-in and custom
# Inspect the repository
#
# See also this sub-command in ~/.local/bin:
#
# git-cat-all
current-branch = rev-parse --abbrev-ref HEAD
project-root = rev-parse --show-toplevel
# List all authors with their commit counts
contributors = shortlog --summary --numbered --email
# Ignore whitespace and follow lines moved or copied between files
blame-follow = blame -w -C -C -C
bi = bisect
bl = blame-follow
rl = reflog
sh = show
s = status
ss = status --short
# Stage changes & sync the working directory into the index
sync = !git sync-deleted && git add . --all
sync-deleted = !git ls-files -z --deleted | xargs -r -0 git rm
a = add
aa = add --all
ap = add --patch
# No-ops so that the `gmv` and `grm` aliases are created
mv = mv
rm = rm
# Commit changes
# Keep the commit message and skip the editor
amend-commit = commit --amend --no-edit
ci = commit
cia = commit --amend
cian = amend-commit
cif = commit --fixup
cim = commit --message
# Undo things, named as "specified" in the `git status` header
discard = checkout --
uncommit = reset --soft HEAD~1
unstage = reset HEAD --
rev = revert
rh = reset
rhh = reset --hard
rhs = reset --soft
rs = restore
rst = restore --staged
u = uncommit
# Manage tags
de = describe --tags
ta = tag
taa = tag --annotate
tad = tag --delete
tal = tag --list
# Make minimal diff the default
diff-minimal = diff --color-words=. --ws-error-highlight=all
diff-last-commit = diff --color-words=. --ws-error-highlight=all HEAD
diff-staged = diff --color-words=. --ws-error-highlight=all --staged
d = diff-minimal
dlc = diff-last-commit
ds = diff-staged
# Make the logs look nice by default
log-format1 = log --pretty='%C(auto)%h: %s%d%Creset%n%C(dim)%an @ %ad => %ar%n' --date=format:'%a %Y-%m-%d %H:%M:%S %z'
log-format2 = log --pretty='%C(auto)%h: %s%d%Creset%n%C(dim)%aN @ %ad => %ar%n%+b' --date=format:'%a %Y-%m-%d %H:%M:%S %z'
history = log-format2 --graph
summary = log-format1 --graph
oneline = log --pretty='%C(auto)%h: %s%d' --graph
last-commit = log-format2 -1 -p --stat
lg = log
lga = log --all
hi = history
hia = history --all
su = summary
sua = summary --all
ol = oneline
ola = oneline --all
lc = last-commit
# Search the history of specific files/paths
# Follow a single file through renames
history-file = log-format2 --graph --follow --
history-file-diff = log-format2 --graph --follow -p --stat --
# Show all commits touching a path (e.g., a folder)
history-path = log-format2 --graph --
history-path-diff = log-format2 --graph -p --stat --
hif = history-file
hifd = history-file-diff
hip = history-path
hipd = history-path-diff
# Search the repository
# Search the tracked files for source code,
# showing the enclosing function of each match
grep-code = grep --break --context 1 --full-name --heading --line-number --show-function
# Search the commit messages across all branches
grep-commit-msg = log --all --regexp-ignore-case --pretty='%C(auto)%h: %s%+D%Creset%n%C(reverse)%C(dim)%aN @ %ad = %ar%Creset%n%+b' --date=format:'%a %Y-%m-%d %H:%M:%S' --grep
# Find commits whose diff contains added or removed lines
# matching a regex, with or without the patches shown
grep-diff = log-format1 -p -G
grep-diff-list = log-format1 -G
# Find commits that change the NUMBER of occurrences of a regex,
# which ignores lines merely moved around (unlike grep-diff)
grep-pickaxe = log-format1 -p --pickaxe-regex -S
# Search the tracked files for plain text, ignoring case
grep-text = grep --break --context 1 --full-name --heading --ignore-case --line-number
repc = grep-code
repcim = grep-commit-msg
repd = grep-diff
repdl = grep-diff-list
repp = grep-pickaxe
rept = grep-text
# Manage the branches & move between them
#
# See also these sub-commands in ~/.local/bin:
#
# git-checkout-dev
# git-checkout-main
# git-checkout-first-existing-branch
#
# git-point-branch-at
#
# git-prune-delete
# git-prune-show
# `git branch` sorts by recency (see [branch] below)
# => This alias restores the alphabetical listing
branch-alpha = branch --sort=refname
rename-branch = branch --move
br = branch
bra = branch --all
brd = branch --delete
brdd = branch --delete --force
brm = rename-branch
brp = point-branch-at
# Which branches are (not) fully contained in `HEAD`
brme = branch --all --merged
brnome = branch --all --no-merged
co = checkout
cob = checkout -b
cod = checkout-dev
com = checkout-main
sw = switch
swc = switch --create
# Integrate changes from other branches
cp = cherry-pick
mb = merge-base
me = merge
mea = merge --abort
mec = merge --continue
meff = merge --ff-only
menoff = merge --no-ff
rb = rebase --committer-date-is-author-date
rba = rebase --abort
rbc = rebase --continue
rbi = rebase --interactive # --autosquash is added in [rebase] below
rbia = rebase --interactive --autosquash
rbq = rebase --quit
rbs = rebase --skip
# Exchange changes with the remotes
#
# See also these sub-commands in ~/.local/bin:
#
# git-ff-all
# git-match-remote
cl = clone
fe = fetch # --prune is added in
fea = fetch --all # [fetch] below
fep = fetch --prune
fepa = fetch --prune --all
ff = ff-all
fff = match-remote # ~ fast-forward with force
pl = pull
plrb = pull --rebase
ps = push
psf = push --force-with-lease
psff = push --force
re = remote --verbose
# Put changes aside temporarily
st = stash
sta = stash push --include-untracked
stam = stash push --include-untracked --message
stapp = stash apply
stl = stash list
stp = stash pop
stsh = stash show
# Work with several working directories
wt = worktree
wta = worktree add
wtl = worktree list
wtm = worktree move
wtp = worktree prune
wtr = worktree remove
# Clean the project folder with intuitive commands
#
# See also these sub-commands in ~/.local/bin:
#
# git-clean-all
# git-clean-ignored
#
# git-gc-everything
# Keep this "-e" list in sync with the other two copies:
# - `$KEEP` in ~/.local/bin/git-clean-ignored
# - the preview in ~/.local/bin/git-clean-all
clean-untracked = clean -x -d -e ".python-version" -e ".env" -f # because the "-e" flag does not work with "-X"
cln = clean --interactive
[branch]
# `git branch` lists the most recently used branches first
sort = -committerdate
[clean]
# Refuse to run `git clean` without --force or --dry-run
requireforce = true
[column]
# Print listings (e.g., branches) in columns
ui = auto
[color "branch"]
current = cyan dim bold reverse
local = green bold
remote = cyan bold
[color "decorate"]
HEAD = cyan dim bold reverse
branch = green bold
remoteBranch = cyan bold
stash = magenta dim bold reverse
tag = magenta bold
[color "diff"]
context = white
frag = blue dim bold reverse
meta = yellow dim bold reverse
new = green bold
old = red bold
whitespace = red dim bold reverse
# Lines that are merely moved around (see "diff.colorMoved" below)
newMoved = cyan bold
oldMoved = magenta bold
# Alternation shades to tell adjacent moved blocks apart (~ "zebra")
newMovedAlternative = cyan
oldMovedAlternative = magenta
[color "grep"]
context = white
filename = yellow dim bold reverse
function = white bold
linenumber = blue dim bold reverse
match = yellow bold
selected = white
separator = blue dim bold reverse
[color "interactive"]
error = red dim bold reverse
header = white
help = blue bold
prompt = white dim bold reverse
[color "status"]
added = green bold
branch = cyan dim bold reverse
changed = yellow bold
header = white
localBranch = green bold
nobranch = red dim bold reverse
remoteBranch = cyan bold
unmerged = red dim bold reverse
untracked = blue bold
[commit]
# Set this in ~/.config/git/config_local to enable signing
#
# gpgSign = true
# Strip comment lines and excess whitespace from the message
cleanup = strip
template = ~/.config/git/commit_msg_template.txt
# Show the staged diff below the commit message template in the editor
verbose = true
[core]
excludesfile = ~/.config/git/ignore
pager = less --chop-long-lines --ignore-case --LONG-PROMPT --no-init --quit-if-one-screen --RAW-CONTROL-CHARS --status-column
# Speed up `git status` by caching untracked files
untrackedCache = true
# Flag tabs in the indentation but allow spaces before tabs
whitespace = -space-before-tab,tab-in-indent
[diff]
# Better diff hunks for reordered code than the default "myers"
algorithm = histogram
# Color lines that are merely moved around differently
# from actually added/removed ones
# => "default" is a synonym for the "zebra" mode
colorMoved = default
# Show "i/" (index) and "w/" (working tree) etc. instead of "a/" and "b/"
mnemonicPrefix = true
# Detect renamed files instead of showing delete-plus-add
renames = true
# Show submodule changes as their list of commits
submodule = log
[fetch]
# Auto-remove remote-tracking refs deleted on the remote
prune = true
[help]
# Suggest a command on a typo and ask before running it
autocorrect = prompt
[init]
defaultBranch = main
[merge]
# Show the common ancestor's version inside conflict markers,
# minus the lines that both sides agree on
conflictstyle = zdiff3
# Refuse `git merge` if a merge commit would be needed
# => Use `git menoff` to create one on purpose
ff = only
[pull]
# Replay local commits on top of the fetched branch
# and do not create merge
rebase = true
# Guard: A merge-style pull must be a fast-forward
ff = only
[push]
# Auto-create the upstream on the first push of a new branch
autoSetupRemote = true
# Push the current branch to its upstream
default = upstream
# Refuse to push if a submodule's commits are unpushed
recursesubmodules = check
[rebase]
# Auto-apply "fixup!" commits in every interactive rebase
autosquash = true
# Stash away and restore local changes around a rebase
autostash = true
# Also move stacked branches that point into the rebased range
updateRefs = true
[rerere]
# Record conflict resolutions and reuse them when they reappear
enabled = true
[tag]
# Sort "v1.10" after "v1.9"
sort = version:refname
[url "ssh://git@bitbucket.org/"]
# Enables, for example, `git clone bb:atlassian/aui`
insteadOf = bb:
# Push always via SSH, even for HTTPS-based remotes
pushInsteadOf = bbh:
pushInsteadOf = https://bitbucket.org/
[url "https://bitbucket.org/"]
# The HTTPS fallback on machines without SSH keys
insteadOf = bbh:
[url "ssh://git@github.com/"]
# Enables, for example, `git clone gh:torvalds/linux`
insteadOf = gh:
# Push always via SSH, even for HTTPS-based remotes
pushInsteadOf = ghh:
pushInsteadOf = https://github.com/
[url "https://github.com/"]
# The HTTPS fallback on machines without SSH keys
insteadOf = ghh:
[url "ssh://git@gitlab.com/"]
# Enables, for example, `git clone gl:gitlab-org/gitlab`
insteadOf = gl:
# Push always via SSH, even for HTTPS-based remotes
pushInsteadOf = glh:
pushInsteadOf = https://gitlab.com/
[url "https://gitlab.com/"]
# The HTTPS fallback on machines without SSH keys
insteadOf = glh:
[url "ssh://git@git.webartifex.de/"]
# Enables, for example, `git clone wa:alexander/dotfiles`
insteadOf = wa:
# Push always via SSH, even for HTTPS-based remotes
pushInsteadOf = wah:
pushInsteadOf = https://code.webartifex.de/
[url "https://code.webartifex.de/"]
# The HTTPS fallback on machines without SSH keys
insteadOf = wah:
[user]
# Set this in ~/.config/git/config_local to enable committing and signing
#
# name = ...
# email = ...
# signingKey = ...
[include]
path = ~/.config/git/config_local

6
.config/git/ignore Normal file
View file

@ -0,0 +1,6 @@
# Generic temporary files
*.backup
*.bak
*.orig
*.temp
*.tmp

View file

@ -3,9 +3,6 @@
# Aliases used in all kinds of shells # Aliases used in all kinds of shells
# Manage the bare `git` repository in ~/ holding the dotfiles
alias dotfiles='git --git-dir=$DOTFILES_DIR --work-tree=$HOME'
forget() { # the current history and restore $HISTFILE from disk forget() { # the current history and restore $HISTFILE from disk
@ -72,3 +69,31 @@ alias la='ls --classify --format=vertical --almost-all'
alias lal='ls --classify --format=long --almost-all' alias lal='ls --classify --format=long --almost-all'
alias ll='ls --classify --format=long' alias ll='ls --classify --format=long'
alias lla='lal' alias lla='lal'
if _command_exists git; then
# Manage the bare `git` repository in ~/ holding the dotfiles
alias dotfiles='git --git-dir=$DOTFILES_DIR --work-tree=$HOME'
alias g='git'
# Create "g" prefixed aliases from the "real" aliases
# defined under "[alias]" in ~/.config/git/config,
# for example, `gap` for `git add --patch`
#
# Interactive shells only: This spawns a `git` process,
# which every non-interactive `bash` via `$BASH_ENV`
# (e.g., ":!cmd" in `vi`) would pay on each command
# => There, `g ap` works just as well as `gap`
case $- in
*i*)
for al in $(git aliases --real); do
eval "alias g$al='git $al'"
done
unset al
;;
esac
fi

35
.local/bin/git-aliases Executable file
View file

@ -0,0 +1,35 @@
#!/bin/sh
# List the names of all `git` aliases, one per line
#
# With the "--real" flag, only the "real" aliases are listed,
# which are those with less than 7 characters in their name
# as defined under "[alias]" in ~/.config/git/config
#
# Usage: git aliases [--real]
set -eu
_max_length=999
for arg in "$@"; do
case "$arg" in
--real)
_max_length=7
;;
*)
echo 'Usage: git aliases [--real]' >&2
exit 2
;;
esac
done
git --list-cmds=alias \
| awk -v max="$_max_length" 'length($0) < max' \
| sort

View file

@ -0,0 +1,15 @@
#!/bin/sh
# List the names of all `git` aliases and custom sub-commands, one per line
#
# Usage: git aliases-n-sub-commands
set -eu
{
git --list-cmds=alias
git --list-cmds=others
} | sort -u

93
.local/bin/git-cat-all Executable file
View file

@ -0,0 +1,93 @@
#!/bin/sh
# Print the contents of all tracked text files to stdout,
# each preceded by a header with its filename
#
# With "-a", binary files are included as well
#
# With "-m", only files with unstaged modifications are printed
#
# With "-n", the headers are omitted
#
# Usage: git cat-all [-a] [-m] [-n] [<pathspec> ...]
set -eu
# Error if we are not in a `git` repository
git rev-parse --git-dir >/dev/null
# Without any commit, there is nothing to print reliably
git rev-parse --verify --quiet HEAD >/dev/null || {
echo "fatal: no commits yet" >&2
exit 1
}
_textonly=yes
_lsflags=
_cmd='printf "\n\n===== %s =====\n\n" "{}"; cat "{}"'
_headers=yes
while [ $# -gt 0 ]; do
case $1 in
-a) _textonly=no
shift
;;
-m) _lsflags=-m
shift
;;
-n) _cmd='printf "\n\n"; cat "{}"'
_headers=no
shift
;;
--) shift
break
;;
-*) echo "Usage: git cat-all [-a] [-m] [-n] [<pathspec> ...]" >&2
exit 2
;;
*) break
;;
esac
done
# Because `git` runs scripts on the `$PATH` in the current directory
cd "$(git rev-parse --show-toplevel)"
# An empty file counts as text
# => `grep` alone would misjudge it
# because it never matches in a file without any lines
if [ "$_textonly" = "yes" ]; then
_cmd='if [ ! -s "{}" ] || grep -Iq "" "{}"; then '"$_cmd"'; fi'
fi
# `git ls-files -m` also lists files deleted from the working tree
# => Skip them because `cat` would fail
if [ -n "$_lsflags" ]; then
_cmd='if [ -f "{}" ]; then '"$_cmd"'; fi'
fi
if [ "$_headers" = "yes" ]; then
printf "Repository: %s\n" "$(basename "$PWD")"
printf "Branch: %s @ %s\n" \
"$(git rev-parse --abbrev-ref HEAD)" \
"$(git rev-parse --short HEAD)"
fi
git ls-files \
-z $_lsflags \
-- \
"$@" \
| xargs -0 -I{} sh -c "$_cmd"

8
.local/bin/git-checkout-dev Executable file
View file

@ -0,0 +1,8 @@
#!/bin/sh
# Check out the "develop" branch, whatever it is called
set -eu
exec git checkout-first-existing-branch dev develop development

View file

@ -0,0 +1,28 @@
#!/bin/sh
# Check out the first of the given branches that exists,
# looking at both local branches and the ones on `origin`
#
# Usage: git checkout-first-existing-branch <branch> [<branch> ...]
set -eu
if [ $# -eq 0 ]; then
echo "Usage: git checkout-first-existing-branch <branch> [<branch> ...]" >&2
exit 2
fi
for _branch in "$@"; do
if git show-ref --verify --quiet "refs/heads/$_branch" \
|| git show-ref --verify --quiet "refs/remotes/origin/$_branch"; then
exec git checkout "$_branch"
fi
done
echo "None of these branches exists: $*" >&2
exit 1

8
.local/bin/git-checkout-main Executable file
View file

@ -0,0 +1,8 @@
#!/bin/sh
# Check out the "main" branch, whatever it is called
set -eu
exec git checkout-first-existing-branch main master

54
.local/bin/git-clean-all Executable file
View file

@ -0,0 +1,54 @@
#!/bin/sh
# Reset the working directory to `HEAD` and remove everything not tracked,
# except the files listed in $KEEP in ~/.local/bin/git-clean-ignored
#
# This is irreversible, so the changes are shown first and confirmed,
# unless "--force" is passed
#
# Usage: git clean-all [--force]
set -eu
# `git` runs scripts on the `$PATH` in the current directory,
# whereas it runs "!" aliases at the top level of the repository
cd "$(git rev-parse --show-toplevel)"
case "${1:-}" in
--force) _forced=1 ;;
"") _forced=0 ;;
*) echo "Usage: git clean-all [--force]" >&2; exit 2 ;;
esac
if [ "$_forced" -ne 1 ]; then
echo "Tracked files to be reset to HEAD:"
git diff --stat HEAD || true
echo ""
echo "Untracked and ignored files to be removed:"
# Keep the "-e" flags in sync with the other two copies:
# - the "-e" flags of `clean-untracked` in ~/.config/git/config
# - `$KEEP` in ~/.local/bin/git-clean-ignored
git clean -x -d -n -e ".python-version" -e ".env"
echo ""
printf "This CANNOT be undone. Continue? [y/N] "
read -r _answer || _answer=""
case "$_answer" in
[yY] | [yY][eE][sS]) ;;
*) echo "Aborted" >&2; exit 1 ;;
esac
fi
git reset --hard
git clean-ignored
git clean-untracked

46
.local/bin/git-clean-ignored Executable file
View file

@ -0,0 +1,46 @@
#!/bin/sh
# Remove all files ignored by `git`, except the ones listed in $KEEP below
#
# `git clean` does not accept "-e" together with "-X",
# so the files to be kept are moved aside and then restored
#
# Usage: git clean-ignored [<git clean options>]
set -eu
# Keep this list in sync with the other two copies:
# - the "-e" flags of `clean-untracked` in ~/.config/git/config
# - the preview in ~/.local/bin/git-clean-all
KEEP=".env .python-version"
SUFFIX=".git-clean-ignored.bak"
# `git` runs scripts on the `$PATH` in the current directory,
# whereas it runs "!" aliases at the top level of the repository
cd "$(git rev-parse --show-toplevel)"
_restore() {
for _file in $KEEP; do
if [ -f "$_file$SUFFIX" ]; then
mv "$_file$SUFFIX" "$_file"
fi
done
}
# Restore the kept files even if `git clean` fails or the user aborts
trap _restore EXIT HUP INT TERM
for _file in $KEEP; do
if [ -f "$_file" ]; then
mv "$_file" "$_file$SUFFIX"
fi
done
git clean -X -d -f "$@"

32
.local/bin/git-ff-all Executable file
View file

@ -0,0 +1,32 @@
#!/bin/sh
# Fast-forward all local branches that track a remote branch
#
# Branches with local-only commits are skipped
#
# Usage: git ff-all
set -eu
git fetch --all --prune
current="$(git branch --show-current)"
git for-each-ref --format='%(refname:short) %(upstream:short)' refs/heads/ |
while read -r branch upstream; do
[ -n "$upstream" ] || continue
if [ "$branch" = "$current" ]; then
git merge --ff-only "$upstream" 2>/dev/null \
|| echo "Skipped $branch (not a fast forward)"
continue
fi
git fetch . "$upstream:$branch" 2>/dev/null \
|| echo "Skipped $branch (not a fast forward)"
done

18
.local/bin/git-gc-everything Executable file
View file

@ -0,0 +1,18 @@
#!/bin/sh
# Delete EVERYTHING not reachable from a branch from the repository
#
# This expires the reflog as well!
#
# Usage: git gc-everything [<git gc options>]
set -eu
exec git \
-c gc.pruneExpire=now \
-c gc.reflogExpire=0 \
-c gc.reflogExpireUnreachable=0 \
-c gc.rerereresolved=0 \
-c gc.rerereunresolved=0 \
gc "$@"

35
.local/bin/git-match-remote Executable file
View file

@ -0,0 +1,35 @@
#!/bin/sh
# Make all local branches match the remote branch they track,
# discarding local-only commits and, on the current branch,
# uncommitted changes as well
#
# Branches without an upstream are left alone
#
# Usage: git match-remote
set -eu
git fetch --all --prune
current="$(git branch --show-current)"
git for-each-ref --format='%(refname:short) %(upstream:short)' refs/heads/ |
while read -r branch upstream; do
[ -n "$upstream" ] || continue
git rev-parse --verify --quiet "$upstream" >/dev/null || continue
if [ "$branch" = "$current" ]; then
git reset --hard "$upstream"
continue
fi
git branch -f "$branch" "$upstream"
echo "Moved $branch to $upstream"
done

21
.local/bin/git-point-branch-at Executable file
View file

@ -0,0 +1,21 @@
#!/bin/sh
# Move a branch to a commit, whether or not it is checked out
#
# Usage: git point-branch-at [<branch>] <commit>
set -eu
case $# in
1) _branch=$(git rev-parse --abbrev-ref HEAD); _commit=$1 ;;
2) _branch=$1; _commit=$2 ;;
*) echo "Usage: git point-branch-at [<branch>] <commit>" >&2; exit 2 ;;
esac
if [ "$_branch" = "$(git rev-parse --abbrev-ref HEAD)" ]; then
exec git reset --keep "$_commit"
else
exec git branch --force "$_branch" "$_commit"
fi

23
.local/bin/git-prune-delete Executable file
View file

@ -0,0 +1,23 @@
#!/bin/sh
# Delete the local branches whose upstream branch was deleted on the remote
#
# `git branch --delete` without "--force" is used,
# so branches holding unmerged commits are kept
#
# Usage: git prune-delete
set -eu
_branches=$(git prune-show)
if [ -z "$_branches" ]; then
echo "No branches to prune"
exit 0
fi
# shellcheck disable=SC2086 # because branch names never contain whitespace
git branch --delete $_branches

15
.local/bin/git-prune-show Executable file
View file

@ -0,0 +1,15 @@
#!/bin/sh
# Show the local branches whose upstream branch was deleted on the remote
#
# Usage: git prune-show
set -eu
git fetch --prune
git branch \
--format='%(refname:short) %(upstream:track)' \
--list \
| awk '$2 == "[gone]" { print $1 }'

4
.zshrc
View file

@ -91,6 +91,10 @@ zstyle ':completion:*:messages' format '%d'
zstyle ':completion:*:warnings' format 'No matches for: %d' zstyle ':completion:*:warnings' format 'No matches for: %d'
zstyle ':completion:*' group-name '' zstyle ':completion:*' group-name ''
# Make `git` sub-commands in ~/.local/bin completable
zstyle ':completion:*:*:git:*' user-commands \
${${${(M)${(k)commands}:#git-*}#git-}:#(receive-pack|upload-pack|upload-archive|shell)}
# Jump to frequently used directories with `z <fragment>` # Jump to frequently used directories with `z <fragment>`
if _command_exists zoxide; then if _command_exists zoxide; then