From 8ef5f29ebdce2b0c21c2b5de8b1ffdf995d994a8 Mon Sep 17 00:00:00 2001 From: Andrew Sichevoi Date: Sat, 1 Sep 2018 08:44:41 +0300 Subject: [PATCH 1/1] initial commit --- README.md | 76 ++++++++++++++ git-wtree.sh | 254 ++++++++++++++++++++++++++++++++++++++++++++++ test-git-wtree.sh | 103 +++++++++++++++++++ 3 files changed, 433 insertions(+) create mode 100644 README.md create mode 100755 git-wtree.sh create mode 100755 test-git-wtree.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca8505c --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# git-wtree + +Several naive `(ba)sh`-shortcuts for `git worktree` subcommand. + +## Overview + +Creation of `git` worktrees and switching between them is quite verbose thing to do for my regular use-cases. `git-wtree` is intended to simplify this routine. +This one is a simple shell script supposed to be sourced inside of a shell. It was intentionally developed in a shell-agnostic way (no bashishm's are used) but tested with `bash` only and `git` 2.18.0. + +## Installation + +Inside of `~/.bashrc`: + + # The comment for the line below could be removed to establish short aliases for provided commands + # GIT_WTREE_ALIAS_ENABLED=true + + . git-wtree/git-wtree.sh + +## Usage + +Inside of `git` repository where worktrees are supposed to be intensively used: + + git config --local worktree.root + +where `` is a directory where all worktrees are created and removed from. + +### Create worktree + + git_wtree_cmd_new --name --dir + + # alias: + git.wtree:ls --name --dir + +where: + +- `` is name of the branch to be created; +- `` is name of directory inside of `` + +vs native `git worktree add -b /`. + +### Delete worktree + + ```bash + git_wtree_cmd_drop --name + # alias: + git.wtree:drop --name + ``` + +where `` is a name of the branch to be deleted. + +vs native `git worktreee remove ` + +### List branches inside worktrees + + git_wtree_cmd_ls + # alias: + git.wtree:ls + +vs native `git worktree list` and futher grepping for a branch name. + +### Switch to a worktree dir + + git_wtree_cmd_tool_cd + # alias: + git.wtree:cd + + git_wtree_cmd_tool_pushd + # alias: + git.wtree:pushd + +vs native `git worktree list`, grepping and furhter cd/pushd execution. + +# License + +MIT + diff --git a/git-wtree.sh b/git-wtree.sh new file mode 100755 index 0000000..fe04aea --- /dev/null +++ b/git-wtree.sh @@ -0,0 +1,254 @@ +#!/bin/sh + +### git_wtree_new [dir:] +### git_wtree_drop +### git_wtree_{cd, pushd} +### git_wtree_root +### git_wtree_ls + +# TODO: use 'git check-ref-format --branch' to check branch's name validness + +### GIT_WTREE_ALIAS_ENABLED=true + +### For debugging purposes: +### GIT_WTREE_DRY_RUN=true +### GIT_WTREE_DEBUG=true + +_git_wtree_last_error_dir=$(mktemp --suffix=git.wtree. -d) +_git_wtree_last_error_msg=${_git_wtree_last_error_dir}/msg +_git_wtree_last_error_out=${_git_wtree_last_error_dir}/out +trap "test -d ${_git_wtree_last_error_dir} && rm -rf '${_git_wtree_last_error_dir}'" EXIT QUIT + +### Execute `git' subcommand with passed arguments +### $1 : routine description +### $2 : git subcommand, e.g.: worktree +### ${n,} : git subcomand's arguments +_git_wtree_exec() { + _git_wtree_set_last_error "$1 FAILED" + shift + + cmd="git $*" + if [ -n "${GIT_WTREE_DRY_RUN}" ]; then + echo "[git-wtree:DRY-RUN]: ${cmd}" + return 0 + fi + + [ -n "${GIT_WTREE_DEBUG}" ] && echo -e "git:{\n" + ${cmd} + rc=$? + [ -n "${GIT_WTREE_DEBUG}" ] && echo -e "\n}:git" + + return $rc +} + +### Return key's value from arguments +### $1 : arg's name +### ${2,n}: argv to search in +### NOTE: for the sake of simplicity no long values are supported +_git_wtree_arg() { + arg_name=$1 + shift + + while [ ! $# -eq 0 ]; do + case "$1" in + --${arg_name}) echo -n $2 && break ;; + *) shift ;; + esac + done +} + +_git_wtree_show_last_error() { + echo "[git-wtree:ERROR] $(cat ${_git_wtree_last_error_msg})" >&2 + [ -s "${_git_wtree_last_error_out}" ] && echo "[git-wtree:ERROR] $(cat ${_git_wtree_last_error_out})" >&2 + true +} + +_git_wtree_set_last_error() { + echo "$*" > ${_git_wtree_last_error_msg} +} + +_git_wtree_worktree_root() { + worktree_root=$(git config worktree.root 2>${_git_wtree_last_error_out} || echo -n) + [ -n "${worktree_root}" -a -d "${worktree_root}" ] && echo -n "${worktree_root}" && return 0 + _git_wtree_set_last_error "Non-defined or not accessible worktree root '${worktree_root}'. 'worktree.root' in .git/config should point to an existing dir." + return 1 +} + +_git_wtree_arg_dir_name() { + dir_name=$(_git_wtree_arg dir $*) + _git_wtree_set_last_error "Missing directory name. Shall be specified with --dir argument" + [ -n "${dir_name}" ] && echo -n "${dir_name}" && return 0 + return 1 +} + +_git_wtree_arg_branch_name() { + branch_name=$(_git_wtree_arg branch $*) + + _git_wtree_set_last_error "Missing branch name. Shall be specified with either --branch or --name argument" + [ -z "${branch_name}" ] && branch_name=$(_git_wtree_arg name $*) + [ -n "${branch_name}" ] && echo -n "${branch_name}" && return 0 + return 1 +} + +_git_wtree_dir_by_branch_name() { + branch_name="$1" + candidates_amount=$(git worktree list 2>${_git_wtree_last_error_out} | grep "${branch_name}" | wc -l) + + ### TODO: won't work for names with a common prefix + _git_wtree_set_last_error "No exact branch '${branch_name}' is available but ${candidates_amount} candidates" + [ 1 -eq "${candidates_amount}" ] || return 1 + + _git_wtree_set_last_error "No worktree dir is found for branch '${branch_name}'" + dir_name=$(git worktree list 2>/dev/null | grep "${branch_name}" | cut -d ' ' -f 1) + [ -n "${dir_name}" ] || return 1 + + _git_wtree_set_last_error "No worktree dir '${dir_name}' is available for branch '${branch_name}'" + [ -d "${dir_name}" ] || return 1 + + echo -n "${dir_name}" + return 0 +} + +### +### Locate a corresponding directory of specified branch. +### Arguments: +### -- +### +git_wtree_cmd_locate() { + current_top_level=$(git rev-parse --show-toplevel 2>${_git_wtree_last_error_out} || echo -n) + _git_wtree_set_last_error "'$(pwd)' is not a git directory" + [ -z "${current_top_level}" ] && _git_wtree_show_last_error && return 1 + + branch_name=$(_git_wtree_arg_branch_name $*) + [ -z "${branch_name}" ] && _git_wtree_show_last_error && return 1 + + branch_dir_name=$(_git_wtree_dir_by_branch_name ${branch_name}) + [ -z "${branch_dir_name}" ] && _git_wtree_show_last_error && return 1 + + echo -n "${branch_dir_name}" + return 0 +} + +#### +#### Same as `git_wtree_cmd_locate`. Suppresses an error output. A corresponding exit code is preserved. +#### +git_wtree_cmd_locate_noerror() { + branch_dir_name=$(git_wtree_cmd_locate $* 2>/dev/null) + [ 0 -eq $? ] || return 1 + + echo -n "${branch_dir_name}" + return 0; +} + +### +### Drop worktree's directory +### Arguments: +### -- +### Should be specified in a relative way +### +git_wtree_cmd_drop() { + branch_name=$(_git_wtree_arg_branch_name $*) + [ -z "${branch_name}" ] && _git_wtree_show_last_error && return 1 + + branch_dir_name=$(_git_wtree_dir_by_branch_name ${branch_name}) + [ -z "${branch_dir_name}" ] && _git_wtree_show_last_error && return 1 + + # echo "- '${branch_name}' to be dropped from '${branch_dir_name}'" + + cmd="git worktree remove ${branch_dir_name}" + _git_wtree_exec \ + "Drop directory of branch '${branch_name}'" \ + worktree remove ${branch_dir_name} + [ 0 -ne $? ] && _git_wtree_show_last_error && return 1 + return 0 +} + +### +### Create a new worktree branch +### Arguments: +### -- +### --dir +### Should be specified in a relative way +### +git_wtree_cmd_new() { + branch_name=$(_git_wtree_arg_branch_name $*) + [ -z "${branch_name}" ] && _git_wtree_show_last_error && return 1 + branch_dir_name=$(_git_wtree_arg_dir_name $*) + [ -z "${branch_dir_name}" ] && _git_wtree_show_last_error && return 1 + parent_branch_dir_name=$(_git_wtree_worktree_root) + [ -z "${parent_branch_dir_name}" ] && _git_wtree_show_last_error && return 1 + + branch_dir_name="${parent_branch_dir_name}/${branch_dir_name}" + _git_wtree_set_last_error "'${branch_dir_name}' already exists" + [ -e "${branch_dir_name}" ] && _git_wtree_show_last_error && return 1 + + # echo "- '${branch_name}' to be created in '${branch_dir_name}'" + + _git_wtree_exec \ + "Create branch '${branch_name}' in '${branch_dir_name}'" \ + worktree add -b ${branch_name} ${branch_dir_name} $(_git_wtree_arg commit $*) + [ 0 -ne $? ] && _git_wtree_show_last_error && return 1 + return 0 +} + +### +### List all available worktrees +### Arguments: none +### +git_wtree_cmd_ls() { + _git_wtree_exec \ + "List available worktrees" \ + worktree list | awk -F'[][]' '{print $2}' + [ 0 -ne $? ] && _git_wtree_show_last_error && return +} + +### +### Helper to change a current directory to a worktree's one using a specified change dir command. Nothing is executed in a case of any error. +### Arguments: +### $1 - change dir command. E.g.: cd, pushd etc. +### $2 - branch name +### +git_wtree_cmd_tool_chdir() { + chdir_cmd=$1 + shift + branch_dir_name=$(git_wtree_cmd_locate_noerror --name $1) + [ 0 -eq $? ] && eval "${chdir_cmd} ${branch_dir_name}" +} + +### +### Helper to change a current directory to a worktree's one using 'cd' +### Arguments: +### $1 - branch name +### +git_wtree_cmd_tool_cd() { + git_wtree_cmd_tool_chdir cd $1 +} + +### +### Helper to change a current directory to a worktree's one using 'pushd' +### Arguments: +### $1 - branch name +### +git_wtree_cmd_tool_pushd() { + git_wtree_cmd_tool_chdir pushd $1 +} + +git_wtree() { + [ 0 -eq $# ] && _git_wtree_set_last_error "Command expected: new, drop, locate" && _git_wtree_show_last_error && return + + cmd=git_wtree_cmd_$1 + shift + + ${cmd} $* + return $? +} + +if [ -n "${GIT_WTREE_ALIAS_ENABLED}" ]; then + alias git.wtree=git_wtree + alias git.wtree:ls=git_wtree_cmd_ls + alias git.wtree:new=git_wtree_cmd_new + alias git.wtree:drop=git_wtree_cmd_drop + alias git.wtree:cd=git_wtree_cmd_tool_cd + alias git.wtree:pushd=git_wtree_cmd_tool_pushd +fi + diff --git a/test-git-wtree.sh b/test-git-wtree.sh new file mode 100755 index 0000000..996f456 --- /dev/null +++ b/test-git-wtree.sh @@ -0,0 +1,103 @@ +#!/bin/sh + +. $(dirname $0)/git-wtree.sh + +#set -e + +SELF_PID=$$ +SELF_ROOT=$(readlink -e $(dirname $0)) +FIXED_FAKE_ROOT=/tmp/git-wtree-test.root +FAKE_ROOT=$([ -z "${GIT_WTREE_TEST_FIXED_ROOT}" ] && mktemp -d || (mkdir ${FIXED_FAKE_ROOT} && ${FIXED_FAKE_ROOT})) +trap cleanup KILL QUIT EXIT + +fail() { + echo -n "*** FAILED: " + [ $# -gt 0 ] && echo "$*" || echo "unknown reason" + kill -9 ${SELF_PID} +} + +cleanup() { + cd "${SELF_ROOT}" + [ -d "${FAKE_ROOT}" ] && (echo "- Clean the fake root ${FAKE_ROOT} up"; rm -rf "${FAKE_ROOT}") +} + +echo "- Fake root: ${FAKE_ROOT}" +[ -n "${FAKE_ROOT}" -a -d "${FAKE_ROOT}" ] || fail "fake root ${FAKE_ROOT} location is set" + +echo "- Prepare fake git repository" +mkdir ${FAKE_ROOT}/main.git || fail "fake root ${FAKE_ROOT} is created" + +cd ${FAKE_ROOT}/main.git +git init . || fail "fake git repository is initialized" + +touch dummy.file || fail "dummy file in the git repository is created" +git add dummy.file || fail "dummy file is added to the git repository" +git commit -m "initial auto commit" || fail "initial commit is made to the git repository" + +echo "= TEST: Master is a single available worktree" +worktrees=$(git_wtree_cmd_ls | wc -l) +[ 1 -eq ${worktrees} ] || fail "master is an only one workspace" + +echo "= TEST(new)" +echo "== requires --name argument" +git_wtree_cmd_new 2>&1 | grep -qi 'ERROR.*missing branch name' +[ 0 -eq $? ] || "error message about missing '--name' argument" + +echo "== requires --dir argument" +git_wtree_cmd_new --name branch-name 2>&1 | grep -qi 'ERROR.*missing directory name' +[ 0 -eq $? ] || fail "error message about missing '--dir' argument" + +echo "== requires 'worktree.root' in config" +git_wtree_cmd_new --name worktree --dir worktree.d 2>&1 | grep -qi "ERROR.*worktree.root.*should point" +[ 0 -eq $? ] || fail "error message about missing 'worktree.root' variable" + +echo "- Set worktree.root" +git config --local worktree.root ${FAKE_ROOT} || fail "'worktree.root' is set to a config" +git config --local worktree.root | grep -qi "${FAKE_ROOT}" +[ 0 -eq $? ] || fail "'worktree.root' is available through config" + +echo "== creates worktree" +git_wtree_cmd_new --name test-branch-name --dir test-branch.d || fail "new worktree is created" +git worktree list | grep -q 'test-branch.d.*test-branch-name' +[ 0 -eq $? ] || fail "newly created worktree is listed" + +echo "== fails on a duplicated name" +git_wtree_cmd_new --name test-branch-name --dir alternative-branch.d 2>&1 | grep -q FAILED +[ 0 -eq $? ] || fail "error message about failed creation of already existing branch" + +echo "= TEST(ls)" +echo "== Lists worktrees" +worktrees=$(git_wtree_cmd_ls | grep -E '^master|^test-branch-name' | wc -l) +[ 2 -eq ${worktrees} ] || fail "Master and the newly created worktree are listed: ${worktrees}" + +echo "= TEST(cd)" +echo "== PWD is changed to worktree" +pwd | grep -qi ${FAKE_ROOT}/main.git +[ 0 -eq $? ] || fail "initial directory is main fake root" +git_wtree_cmd_tool_cd test-branch-name 2>&1 +[ x"${FAKE_ROOT}/test-branch.d" = x"$(pwd)" ] || fail "current directory is test-branche's one" + +echo "== PWD is changed to master" +pwd | grep -qi ${FAKE_ROOT}/test-branch.d +[ 0 -eq $? ] || fail "initial directory is worktree's one" +git_wtree_cmd_tool_cd master 2>&1 +[ x"${FAKE_ROOT}/main.git" = x"$(pwd)" ] || fail "current directory is master's one" + +echo "= TEST(drop)" +cd ${FAKE_ROOT}/main.git + +echo "== requires --name argument" +git_wtree_cmd_drop 2>&1 | grep -qi "ERROR.*missing branch name" +[ 0 -eq $? ] || fail "error message about no candidates to drop" + +echo "== drops worktree directory" +git_wtree_cmd_drop --name test-branch-name || fail "drop command is succeeded" +worktrees=$(git_wtree_cmd_ls | grep master | wc -l) +[ 1 -eq ${worktrees} ] || fail "only master branch is left" + +echo "== fails on already dropped worktree directory" +for branch_name in test-branch-name never-existed-branch-name; do + git_wtree_cmd_drop --name ${branch_name} 2>&1 | grep -qi "ERROR.*0 candidates" + [ 0 -eq $? ] || fail "error message about no candidates to drop" +done + -- 2.20.1