diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 693ddde..5dd9711 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -15,4 +15,4 @@ jobs: - name: Run ShellCheck uses: ludeeus/action-shellcheck@master with: - ignore_paths: ./completion.* \ No newline at end of file + ignore_paths: ./completion.* ./t/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..199843a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +git-fixup.1 +git-fixup.html diff --git a/Makefile b/Makefile index d54129f..b7a3aae 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,14 @@ PREFIX?=/usr/local INSTALLDIR?=$(PREFIX) +MANDIR?=$(INSTALLDIR)/usr/share/man/man1 INSTALL=install -install: +all: git-fixup.1 git-fixup.html + +install: git-fixup.1 install-fish install-zsh ${INSTALL} -d ${DESTDIR}${INSTALLDIR}/bin ${INSTALL} -m755 git-fixup ${DESTDIR}${INSTALLDIR}/bin/git-fixup + ${INSTALL} -m644 git-fixup.1 ${DESTDIR}${MANDIR}/man1/git-fixup.1 install-fish: ${INSTALL} -d ${DESTDIR}${INSTALLDIR}/share/fish/vendor_completions.d/ @@ -13,3 +17,14 @@ install-fish: install-zsh: ${INSTALL} -d ${DESTDIR}${INSTALLDIR}/share/zsh/site-functions ${INSTALL} -m644 completion.zsh ${DESTDIR}${INSTALLDIR}/share/zsh/site-functions/_git-fixup + +git-fixup.1: git-fixup.txt + asciidoctor -b manpage -d manpage -o $@ $< + +git-fixup.html: git-fixup.txt + asciidoctor -d manpage -o $@ $< + +clean: + rm -f git-fixup.html git-fixup.1 *~ + +.PHONY: install-fish install-zsh diff --git a/git-fixup b/git-fixup index f0919e9..07e5a9c 100755 --- a/git-fixup +++ b/git-fixup @@ -1,4 +1,5 @@ -#!/usr/bin/env bash +#!/bin/sh +# shellcheck disable=SC2059 # git-fixup (https://github.com/keis/git-fixup) # We cannot set -u, because included git libraries don't support it. set -e @@ -29,10 +30,13 @@ SUBDIRECTORY_OK=yes grok_diff='/^--- .*/p ; s/^@@ -\([0-9]*\),\([0-9]*\).*/\1 \2/p' +# EOL in the POSIX compatible way +NL="$(printf '\nx')"; NL="${NL%x}" + # Produce suggestion of commits by finding the sections of files with changes # staged (U1 to diff is used to give some context for when adding items to # lists etc) and looking up the previous commits touching those sections. -function fixup_candidates_lines () { +fixup_candidates_lines () { git diff --cached -U1 --no-prefix | sed -n "$grok_diff" | ( file='' while read -r offs len ; do @@ -51,7 +55,7 @@ function fixup_candidates_lines () { # Produce suggestion of commits by taking the latest commit to each file with # staged changes -function fixup_candidates_files () { +fixup_candidates_files () { git diff --cached --name-only | ( while read -r file; do git rev-list -n 1 -E --invert-grep --grep='^(fixup|squash)' "$rev_range" -- "$file" @@ -60,22 +64,23 @@ function fixup_candidates_files () { } # Produce suggestion of all commits in $rev_range -function fixup_candidates_all_commits () { +fixup_candidates_all_commits () { git rev-list "$rev_range" | sed 's/^/F /g' } # Pretty print details of a commit -function print_sha () { - local sha=$1 - local type=$2 +print_sha () { + sha=$1 + type=$2 git --no-pager log --format="%H [$type] %s <%ae>" -n 1 "$sha" } # Call git commit -function call_commit() { - local flag=$op - local target=$1 +call_commit() { + set -x + flag=$op + target=$1 if [ "$op" = "amend" ] ; then flag=fixup @@ -83,12 +88,12 @@ function call_commit() { fi # shellcheck disable=SC2086 - git commit "${git_commit_args[@]}" "--$flag=$target" || die + git commit ${git_commit_args} "--$flag=$target" || die } # Call git rebase -function call_rebase() { - local target=$1 +call_rebase() { + target=$1 # If our target-commit has a parent, we call a rebase with that # shellcheck disable=SC1083 @@ -102,7 +107,7 @@ function call_rebase() { } # Print list of fixup/squash candidates -function print_candidates() { +print_candidates() { ( if [ "$show_all" = "false" ] ; then fixup_candidates_lines @@ -110,34 +115,45 @@ function print_candidates() { else fixup_candidates_all_commits fi - ) | sort -uk2 | while read -r type sha; do + ) | sort -uk2 | while read -r type sha ; do if [ -n "$sha" ] ; then print_sha "$sha" "$type" fi done } -function fallback_menu() { +fallback_menu() { ( - IFS=$'\n' - read -d '' -ra options - PS3="Which commit should I $op? " - select line in "${options[@]}"; do - if [ -z "$line" ] ; then - declare -a args=("$REPLY") - case ${args[0]} in + IFS="$NL" + counter=0 + TMPINPUT="$(mktemp --tmpdir gitfixup-menu-XXXX)" + trap 'rm -f "$TMPINPUT"' EXIT + cat >"$TMPINPUT" + + while read -r line ; do + counter=$((counter + 1)) + printf "%d) %s\n" $counter "$line" >&2 + done < "$TMPINPUT" + printf "Which commit should I fixup? " >&2 + while read -r REPLY ; do + if ( printf "%s\n" "$REPLY" | grep -q "^[0-9]\+$" ) && [ "$REPLY" -le "$counter" ] ; then + printf "%s\n" "$(sed -n -e "${REPLY}p" "$TMPINPUT")" + break + else + args="${REPLY%% *}" + case ${args} in quit|q) echo "Alright, no action taken." >&2 break ;; show|s) - idx=$((args[1] - 1)) + idx="${REPLY#"$args" *}" if [ "$idx" -ge 0 ] ; then - git show "${options[$idx]%% *}" >&2 + git show "$(awk 'FNR == '"${idx}"' {print $1}' "$TMPINPUT")" >&2 fi ;; help|h) - local fmt="%s\n %s\n" + fmt="%s\n %s\n" # shellcheck disable=SC2059 printf "$fmt" "" "$op the -th commit from the list" >&2 # shellcheck disable=SC2059 @@ -148,9 +164,6 @@ function fallback_menu() { printf "$fmt" "h[elp]" "show this help message" >&2 ;; esac - else - echo "$line" - break fi done < /dev/tty ) @@ -164,7 +177,7 @@ show_menu () { fi } -git_commit_args=() +git_commit_args="" target= op=${GITFIXUPACTION:-$(git config --default=fixup fixup.action)} rebase=${GITFIXUPREBASE:-$(git config --default=false fixup.rebase)} @@ -197,7 +210,7 @@ while [ $# -gt 0 ] ; do rebase=false ;; -n|--no-verify) - git_commit_args+=("$1") + git_commit_args="$git_commit_args $1" ;; -b|--base) shift diff --git a/git-fixup.txt b/git-fixup.txt new file mode 100644 index 0000000..525b518 --- /dev/null +++ b/git-fixup.txt @@ -0,0 +1,248 @@ +git-fixup(1) +============ + +NAME +---- +git-fixup - Fighting the copy-paste element of your rebase workflow + +SYNOPSIS +-------- +[verse] +'git-fixup' [-s|--squash] [-f|--fixup] [-a|--amend] [-c|--commit] [--no-verify] + [--rebase] [-b|--base ] [] + + +DESCRIPTION +----------- +Fighting the copy-paste element of your rebase workflow. + +`git fixup ` is simply an alias for `git commit --fixup `. That's +just a convenience feature that can be also be used to trigger tab completion. + +The magic is in plain `git fixup` without any arguments. It finds which +lines/files you have changed, uses git blame/log to find the most recent commits +that touched those lines/files, and displays a list for you to pick from. This +is a convenient alternative to manually searching through the commit log and +copy-pasting the commit hash. + +For this tool to make any sense you should enable the `rebase.autosquash` +setting in the git config, or use the `--rebase` option. + +OPTIONS +------- + +-s:: +--squash:: + Instruct `git-fixup` to create a `squash!` commit instead of a `fixup!` commit. + + Squashing gives you the opportunity to edit the commit message before + the commits are squashed together. + + Default action can be configured by setting <> + +-f:: +--fixup:: + Instruct `git-fixup` to create `fixup!` commit (This is the default). + + Default action can be configured by setting <> + +-a:: +--amend:: + Instruct `git-fixup` to create an `amend!` commit. + + Default action can be configured by setting <> + +-c:: +--commit:: + Instead of listing the suggested commits show a menu to pick a commit to + create a fixup/squash commit of. + + A <> is provided that is intentionally very + simple and with no advanced features. Instead of using it you can tell `git + fixup` to use an external tool for the menu by defining a command line via + either the <> setting in the git config or the `GITFIXUPMENU` + environment variable (the latter overrides the former). + + ```bash + # Use fzf as a menu program + $ GITFIXUPMENU=fzf git fixup -c + ``` + + This option can be enabled by default by setting <> + in the git config. + +--no-commit:: + Don't show the commit menu even if previously instructed to do so. + +--rebase:: + Call an interactive rebase right after the commit is created, to automatically apply the + fix-up into the target commit. This is merely to avoid doing two commands one after the + other (`git fixup && git rebase`). + + This simply calls `git rebase --interactive --autosquash target~1`, with the target being the + commit to fix-up. + + Default rebase/no-rebase can be configured by setting <> + +--no-rebase:: + Don't do a rebase even if previously instructed to do so (useful to bypass <>) + +--no-verify:: + Bypass the pre-commit and commit-msg hooks. (see `git help commit`) + + +--base :: + This option receives as argument the revision to be used as base commit for + the search of fixup/squash candidates. You can use anything that resolves to a + commit. The special value `closest` resolves to the closest ancestor branch of + the current head. + + If omitted, the default base commit is resolved in the following order: + + 1. The value of the environment variable `GITFIXUPBASE` if present; + 2. The value of the configuration key `fixup.base` if present; + 3. The branch configured as upstream of the current one (i.e. `@{upstream}`) + if existing; + 4. Finally, the root commit (i.e. full history) if nothing of the above is + satisfied. + + +Configuration +------------- + +`git-fixup` uses configuration from the ENVIRONMENT or from `git config` + +fixup.base +~~~~~~~~~~ + +Or `GITFIXUPBASE` + +The default argument for `--base`. You can set the value `closest` to make +`git-fixup` use the closest ancestor branch by default, for example. + +fixup.action +~~~~~~~~~~~~ +[[fixupaction]] + +Or `GITFIXUPACTION` + +Decides if the default actions will be `fixup` or `squash`. + +fixup.commit +~~~~~~~~~~~~ +[[fixupcommit]] + +Or `GITFIXUPCOMMIT` + +Decides if the commit menu should be displayed instead of the commit list by +default. + +```bash +# Enable --commit for all my projects +$ git config --global fixup.commit true +``` + +fixup.rebase +~~~~~~~~~~~~ +[[fixuprebase]] + +Or `GITFIXUPREBASE` + +Decides if `git rebase` should be called right after the `git commit` call. + +```bash +# Enable --rebase for all my projects +$ git config --global fixup.rebase true +``` + +fixup.menu +~~~~~~~~~~ +[[fixupmenu]] + +Or `GITFIXUPMENU` + +Sets the command that will be used to display the commit menu. If not set +a simple [default menu](the-default-menu) will be used. + +See <> for more details and a more advanced +example. + +Tab completion +-------------- + +Tab completion for zsh/fish is implemented. The suggestions for the tab completion +are the suggested fixup bases as generated by running the tool without any +arguments. + +To be able to tab complete the command itself add a line like this to your zsh +configuration:: + + zstyle ':completion:*:*:git:*' user-commands fixup:'Create a fixup commit' + + +External menu +------------- +[[external-menu]] + +In order to use an external tool for display the commit menu, you need to +either define the <> setting in the git config or set the +`GITFIXUPMENU` environment variable with the command for the menu. The menu +command must receive as input the lines as the options for the user and return +the selected line to the standard output. + +The following example is a fragment of a git config that makes `git fixup +--commit` display a nice menu with https://github.com/junegunn/fzf[fzf]: + +```ini +[fixup] + menu = fzf --height '60%' \ + --bind 'tab:toggle-preview' \ + --preview 'git show --color {+1}' \ + --preview-window=up:80% \ + --prompt 'Select commit: ' +``` + +The default menu +---------------- +[[the-default-menu]] + +If you have not configured an external menu, the default menu is used. See the +example below: + +```bash +$ git fixup -c +1) 500be603c66040dd8a9ca18832d6221c00e96184 [F] Add README.md +2) ddab3b03da529af5303531a3d4127e3663063e08 [F] Add index.js +Which commit should I fixup? +``` + +Here `` should be the number of the desired commit in the list. +You can use `q` to abort the operation and `h` to see a help message for the +menu. + +If the commit title alone is not enough for you to decide, you can use `show +` to call `git show` on the ``-th commit of the menu. + +Changelog +--------- + +See link:CHANGELOG.md[CHANGELOG.md] + +Authors +------- + +The fine people who have contributed to this script in ASCIIbetical order. + +- Cristiano Giuffrida (https://github.com/cgiuffr[cgiuffr]) +- David Keijser (https://github.com/keis[keis]) +- Elan Ruusamäe (https://github.com/glensc[glensc]) +- Federico del Mazo (https://github.com/FdelMazo[FdelMazo]) +- Gustavo Sousa (https://github.com/guludo[guludo]) +- Joe Shaw (https://github.com/joeshaw[joeshaw]) +- Philippe (https://github.com/pe[pe]) +- Rickard Dybeck (https://github.com/alde[alde]) +- Tiago Ribeiro (https://github.com/fixe[fixe]) + +GIT +--- +Part of the linkgit:git[1] suite diff --git a/t/t7600-fixup.sh b/t/t7600-fixup.sh new file mode 100755 index 0000000..429f64b --- /dev/null +++ b/t/t7600-fixup.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +test_description='.' + +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME + +TEST_PASSES_SANITIZE_LEAK=true +. ./test-lib.sh + +test_expect_success 'setup' ' + git init almost-complete + cd almost-complete/ + + echo "Testing file 1" >test_01.txt + git add test_01.txt + git commit -m "Commiting the first file." + echo "Testing file 2" >test_02.txt + git add test_02.txt + git commit -m "Commiting the second file." + echo "Modifying the first file." >>test_01.txt + git add test_01.txt + # git fixup --no-commit |grep -s "something" + /bin/true +' + +test_done