いかに素早く目的のディレクトリにcdするか、そのためのTips

  • 97
    いいね
  • 2
    コメント
この記事は最終更新日から1年以上が経過しています。

我々がシェルを操作している時間のうち、たぶん8割くらいはcdlsを打っているんじゃないかとすら、私は思っています。

世の中には様々なシェルハックが溢れている昨今ですが、lsは置いといて、cdのコストを減らせれば、そこそこの効率化が図れるのではないでしょうか。そんなことを考えて色々な改善を考えてみたことをまとめた記事です。

私はBashユーザなのでBashの話ですが、Zshでも多少の改変で応用が効くのではないかと思います。

ここでの試行錯誤は2014年3月現在のものです。興味深い改造があれば都度書き足していきます。

cdのオプション

まず man bash から cd の説明を観てみましょう。

   cd [-L|-P] [dir]
          Change the current directory to dir.  The variable HOME  is  the
          default  dir.   The  variable CDPATH defines the search path for
          the directory containing dir.  Alternative  directory  names  in
          CDPATH  are  separated by a colon (:).  A null directory name in
          CDPATH is the same as the current directory,  i.e.,  ``.''.   If
          dir  begins  with  a  slash (/), then CDPATH is not used. The -P
          option says to use the physical directory structure  instead  of
          following  symbolic  links  (see  also  the -P option to the set
          builtin command); the -L option forces symbolic links to be fol-
          lowed.   An  argument  of - is equivalent to $OLDPWD.  If a non-
          empty directory name from CDPATH is used, or if - is  the  first
          argument,  and  the directory change is successful, the absolute
          pathname of the new working directory is written to the standard
          output.   The return value is true if the directory was success-
          fully changed; false otherwise.

cdコマンド、世界中のユーザに日々相当使われているけど、オプションは相当貧弱だなと思います。-L-P も使いどころがそれほどない。これらのオプション、日々使っている人いるんでしょうか。

せめてウェブブラウザでいうような「進む」「戻る」「ブックマーク」「検索」といったインターフェースがあればいいのにと思って、色々作ってみました。

ちなみに、cd - とすれば直前にいたディレクトリに戻ることができますが、履歴をたどって戻り続けるといったようなことはできません。cd -を連続で打っても、2つのディレクトリ間を行き来するだけです。

そこで私なりのcdハックを以下でご紹介してみようと思います。面白そうな機能があれば取り入れていただければ幸いです。

pushdとpopd、そしてdirs

まずはpushdpopdについて解説しておかなければなりません。

Bashにはpushdpopdという組み込みコマンドがあるのですが、あまり使われている場面に出くわしたことがありません。pushdは、カレントディレクトリをスタックに保存して与えれたディレクトリに移動する、もう一つのcdコマンドといったものです。pushd 移動したいディレクトリ といった感じで、cd コマンドと同様に使えます。

スタックに積まれたディレクトリで一番新しいディレクトリに移動したい場合にはpopdコマンドを使います。これは引数無し。スタックに積まれた一番新しいディレクトリに移動して、スタックからその情報を削除します。いわゆるLIFOですね。

スタック一覧を見たい場合にはdirsコマンドを使います。スタックを削除したり表示方法を替えたりといったオプションもありますが、詳しくは man bash の中から dirs の項目を参照下さい。

dirs [-clpv] [+n] [-n]

cdをpushdで再定義する

pushdcd の代わりとして使えるとはいえ、cd の方が打つのに慣れているし、pushd って5文字もあるしで、恒常的に pushd を使うのは面倒です。であれば cd の定義を pushd を使ったもので置き換えてしまおうと考えました。

普段から cdpushd を使い分けている人は、この設定はスタックの履歴が混ざってしまうので問題となりますが、「普段のシェル作業で pushd なんて使わないよ」という人は、この設定にしてしまって問題無いと思います。私は数年間この設定で作業していますが、特段パフォーマンス的だったりといった問題が出たことはありません。

# 履歴を記録する cd の再定義
function cd {
    if [ -z "$1" ] ; then
        # cd 連打で余計な $DIRSTACK を増やさない
        test "$PWD" != "$HOME" && pushd $HOME > /dev/null
    elif ( echo "$1" | egrep "^\.\.\.+$" > /dev/null ) ; then
        cd $( echo "$1" | perl -ne 'print "../" x ( tr/\./\./ - 1 )' )
    else
        pushd "$1" > /dev/null
    fi
}

これらの関数は ~/.bashrc に書くか、~/.bashrc から source (.) コマンドで読まれるファイルに書く必要があります。

定義の説明ですが、まず cd コマンドを引数無しで打ったときにホームディレクトリに戻ることができる機能があって、ここでもpushdでこれを再現していますが、これをホームディレクトリで打ったときに pushd が余計なスタックを増やさないような配慮をしています。

またオマケ機能として、cd .. で1つ上のディレクトリに戻れる機能の拡張として、cd ... と打つと2つ上のディレクトリに戻れ、$n$ 個のドットで $n-1$ つ上のディレクトリに戻れる機能を書いてあります。シェル力が足りなかったので、egrep で検査した上で必要があればPerlを起動するように書いていますが、ここのパフォーマンスが特段気になる方は削ってもよいと思います。ここの then 節で表れる cd はこの関数の再起呼び出しとして解釈されます(無限ループには注意して書いたつもりです)。

そうでなければシンプルに pushd に引数を渡してカレントディレクトリを変更しています。pushd はいちいちスタックに入れたディレクトリについての報告を標準出力にしてくるので、>/dev/null しておきます。

この定義は、一部の人が使い慣れた cd - を潰したりはしません。

事情があって組み込みのcdコマンドを使いたい場合は builtin cd 移動したいディレクトリ というコマンドを使います。

事前指定したディレクトリへ素早く移動する

ブラウザでいう「ブックマーク」のような機能が欲しいと思って、色々考えました。

cd コマンドには CDPATH という環境変数があって、PATH のように、ここにコロン区切りでパス群を入れておくと、カレントディレクトリがどこであろうと、このパス群からディレクトリを探すという機能があるのですが、私には使い勝手が悪く感じました。込み入った設定や状況にしておくと、意図しないディレクトリに飛んでしまったりとか。

pushdCDPATH を見るのかとか調べる以前に、意図しないことが起こらないように、これは別コマンドにしてしまったほうがいいなと感じました。最初は cdjump という名前だったのですが、長いので cdj という名前にしました。

# ショートカットキーで移動するcd
function cdj {
    ### cdjはCDJ_DIR_MAPという環境変数の配列の定義が必要です
    # CDJ_DIR_MAP配列の例は以下です。ディレクトリのエイリアスと実ディレクトリのパスを空白区切りでペアで書いていきます
#     export CDJ_DIR_MAP=(
#         dbox ~/Dropbox
#         cvs  ~/cvs
#         etc  /etc
#         );
    test -n "$DEBUG" && echo "DEBUG: dir arg=$arg #CDJ_DIR_MAP=${#CDJ_DIR_MAP[*]}" 
    declare arg=$1 \
            subarg=$2 \
            dir i key value warn
    if [ -z "$arg" -o "$arg" = "-h" ] || [ "$arg" = "-v" -a -z "$subarg" ] ; then
        ### help and usage mode
        echo "Usage: $FUNCNAME <directory_alias>"
        echo "       $FUNCNAME [-h|-v|-l <directory_alias>]"
        echo "-h: help"
        echo "-l: list defined lists"
        echo "-v <directory_alias>: view path specify alias."
        return
    elif [ "$arg" = "-v" -o "$arg" = "-l" ] ; then 
        ### view detail mode
        for (( i=0; $i<${#CDJ_DIR_MAP[*]}; i=$((i+2)) )) ; do
            key="${CDJ_DIR_MAP[$i]}"
            value="${CDJ_DIR_MAP[$((i+1))]}"
            if [ "$arg" = "-l" ] ; then
                if [ ! -d "$value" ] ; then
                    warn=" ***NOT_FOUND***"
                else
                    warn=""
                fi
                printf "%8s => %s%s\n" "$key" "$value" "$warn"
            elif [ "$arg" = "-v" ] ; then
                if [ "$key" = "$subarg" ] ; then
                    echo $value
                    return
                fi
            fi
        done
        return
    fi
    ### change directory mode
    for (( i=0; $i<${#CDJ_DIR_MAP[*]}; i=$((i+2)) )) ; do
        key="${CDJ_DIR_MAP[$i]}"
        value="${CDJ_DIR_MAP[$((i+1))]}"
        test -n "$DEBUG" && echo "$key => $value"
        if [ "$key" = "$arg" ] ; then
            if [ -n "$subarg" ] ; then
                dir="$value/$subarg"
            else
                dir="$value"
            fi
            cd "$dir"
            return
        fi
    done
    echo "directory alias \"$arg\" is not found"
    return 1
}

大昔に書いた自分も忘れてしまった部分もあるのですが、以下のような仕掛けです。

まず CDJ_DIR_MAP という配列を定義します。これがいわゆる cdj コマンドでの「ブックマーク」に相当します。上のコメントで書いたような例で書きます。いくらでも書けます。

最近のBashでは連想配列がサポートされたという話も聞いたり聞かなかったりするのですが、世間の新しくないBashに合わせて配列にキーバリューのペアで記録する形になっています。

前述の例であれば、cdj dbox と打てば cd ~/Dropbox と同じ意味になります。長く深いディレクトリであればあるほど、この恩恵は大きいです。

ヘルプを表示する -h、現在の設定を確認する -l、与えられたキーに対する実ディレクトリを表示する -v といったオプションも付けました。

cd コマンドは標準でディレクトリのタブ補完が効きます。cdj コマンドもタブ補完ができるようにしましょう。function cdjcdj コマンドを定義した後で以下のような記述を書きます。

type cdj >/dev/null 2>&1 &&
_cdj()
{
    local cur prev opts i
    COMPREPLY=()
    cur="${COMP_WORDS[COMP_CWORD]}"
    prev="${COMP_WORDS[COMP_CWORD-1]}"
    opts=$(for i in $(seq 0 2 $((${#CDJ_DIR_MAP[*]}-2)) ) ; do echo ${CDJ_DIR_MAP[$i]} ; done ; echo "-h -v -l")
    #echo ""
    #echo "> cur=$cur prev=$prev COMP_CWORD=$COMP_CWORD"
    #echo "> opts=$opts"
    #echo "> COMPWORDS=${COMP_WORDS[*]}"

    case ${prev} in
        -l)
            opts=$(for i in $(seq 0 2 $((${#CDJ_DIR_MAP[*]}-2)) ) ; do echo ${CDJ_DIR_MAP[$i]} ; done)
            COMPREPLY=( $(compgen -W "$opts" -- ${cur} ))
            return 0
            ;;
        *)
            ;;
    esac

    COMPREPLY=( $(compgen -W "$opts" -- ${cur} ))
    return 0
} &&
complete -F _cdj cdj

bash_completion の色々な書き方を真似て CDJ_DIR_MAP を見るようにしてみただけです。

私はbash_completionを導入していますが、もしかしたら事前にbash_completionを導入しておかないといけないかもしれません。それぞれのOS/ディストリビューション事に導入方法が違いますので、検索してみてください。

取得したディレクトリ移動履歴を利用する

前述のように cd コマンドを再定義したことで、pushd のおかげでディレクトリ履歴が取られるようになりました。

であれば、その履歴を番号選択して移動ができる cdhistcd - とは違い、打ち続けると履歴をたどっていける popd を利用した cdback コマンドを定義してみましょう。

# 最近の cd によって移動したディレクトリを選択
function cdhist {
    local dirnum
    #dirs -v | head -n $(( LINES - 3 ))
    dirs -v | sort -k 2 | uniq -f 1 | sort -n -k 1 | head -n $(( LINES - 3 ))
    read -p "select number: " dirnum
    if [ -z "$dirnum" ] ; then
        echo "$FUNCNAME: Abort." 1>&2
    elif ( echo $dirnum | egrep '^[[:digit:]]+$' > /dev/null ) ; then
        cd "$( echo ${DIRSTACK[$dirnum]} | sed -e "s;^~;$HOME;" )"
    else
        echo "$FUNCNAME: Wrong." 1>&2
    fi
}

function cdback {
    #popd $1 >/dev/null
    local num=$1 i
    if [ -z "$num" -o "$num" = 1 ] ; then
        popd >/dev/null
        return
    elif [[ "$num" =~ ^[0-9]+$ ]] ; then
        for (( i=0 ; i<num ; i++ )) ; do
            popd >/dev/null
        done
        return
    else
        echo "cdback: argument is invalid." >&2
    fi
}

カレントディレクトリにある、打つのが面倒なディレクトリへ移動

特に最近では日本語ディレクトリが多くなってきている印象です。日本語のファイル名やディレクトリ名は利用すれば便利なのですが、それはGUIでの話であって、CUIではcdのときに打つのが面倒だったりといった問題があります。

そこで日本語変換をオンにせずとも、カレントディレクトリの一覧を番号付きで出して、番号を打つだけで移動ができる cdlist コマンドを定義してみましょう。

# 現在のディレクトリの中にあるディレクトリを番号指定で移動
function cdlist {
    local -a dirlist opt_f=false
    local i d num=0 dirnum opt opt_f
    while getopts ":f" opt ; do
        case $opt in
            f ) opt_f=true ;;
        esac
    done
    shift $(( OPTIND -1 ))
    dirlist[0]=..
    # external pipe scope. array is established.
    for d in * ; do test -d "$d" && dirlist[$((++num))]="$d" ; done
    # TODO: Is seq installed?
    for i in $( seq 0 $num ) ; do printf "%3d %s%b\n" $i "$( $opt_f && echo -n "$PWD/" )${dirlist[$i]}" ; done
    read -p "select number: " dirnum
    if [ -z "$dirnum" ] ; then
        echo "$FUNCNAME: Abort." 1>&2
    elif ( echo $dirnum | egrep '^[[:digit:]]+$' > /dev/null ) ; then
        cd "${dirlist[$dirnum]}"
    else
        echo "$FUNCNAME: Something wrong." 1>&2
    fi
}

名前でディレクトリ名を検索して移動する

例えば "qiita" という文字列が入ったディレクトリを大文字小文字区別せず検索して、その検索結果から移動したい場合。よくありそうですね。

find / -iname \*qiita\* -type d

これで出てきた結果を目視して、コピーアンドペーストをして移動…。面倒な感じがします。そもそもfindコマンド自体が検索に時間がかかります。

Linuxにはlocate、Macにはmdfindという、事前に作成したインデックスファイルを利用して高速検索ができるコマンドがあるので、find / するくらいなら、そちらを利用したほうがよさそうです。

大体の場合、移動したいディレクトリが、カレントディレクトリ以下にあるのか、ホームディレクトリ以下にあるのか、それとも / 以下にあるのか、いちいち区別するのは不便です。

そこで書いてみたのが mdfind を利用した cdmdfind です。

# cd shortcut by mdfind (Mac OS X Spotlight CLI)
type mdfind >/dev/null 2>&1 && \
function cdmdfind {
    local arg="$1" path i=0 j selnum selpath OUTPUT
    declare -a pathes
    if [ -z "$arg" ] || [ "$arg" = "-h" ] ; then
        echo "Usage:"
        echo "  $FUNCNAME STRING"
        return
    fi
    # mdfind search is case insensitive
    for path in $(mdfind -name "$arg" | sed -e 's/ /+/g') ; do
        path=$(echo "$path" | sed -e 's/\+/ /g')
        test -d "$path" || continue
        i=$((i+1))
        pathes[$i]="$path"
    done
    if [ -z "${pathes[1]}" ] ; then
        # Nothing search result.
        return
    fi
    if [ $i -ge $LINES ] ; then
        OUTPUT=$PAGER
        test -z "$OUTPUT" && OUTPUT=cat
    else
        OUTPUT=cat
    fi
    for j in $(seq 1 $i) ; do
        printf "%2d: %s\n" $j "${pathes[$j]}"
    done | $OUTPUT
    read -p "select number: " selnum
    selpath="${pathes[$selnum]}"
    if [ -z "$selpath" ] ; then
        echo "$FUNCNAME: select is wrong." 1>&2
        return 1
    fi
    cd "$selpath"
}

検索を実行すると、メニューが出て…といったところは前述の cdlist をそのまま真似ました。

さらに cdmdfind を真似て cdlocate を書きました。

# cd shortcut by locate
type locate >/dev/null 2>&1 && \
function cdlocate {
    local arg="$1" path i=0 j selnum selpath OUTPUT
    declare -a pathes
    if [ -z "$arg" ] || [ "$arg" = "-h" ] ; then
        echo "Usage:"
        echo "  $FUNCNAME STRING"
        return
    fi
    # mdfind search is case insensitive
    for path in $(locate "$arg" | grep -i -E "/[^/]*$arg[^/]*$" | sed -e 's/ /+/g') ; do
        path=$(echo "$path" | sed -e 's/\+/ /g')
        test -d "$path" || continue
        i=$((i+1))
        pathes[$i]="$path"
    done
    if [ -z "${pathes[1]}" ] ; then
        # Nothing search result.
        return
    fi
    if [ $i -ge $LINES ] ; then
        OUTPUT=$PAGER
        test -z "$OUTPUT" && OUTPUT=cat
    else
        OUTPUT=cat
    fi
    for j in $(seq 1 $i) ; do
        printf "%2d: %s\n" $j "${pathes[$j]}"
    done | $OUTPUT
    read -p "select number: " selnum
    selpath="${pathes[$selnum]}"
    if [ -z "$selpath" ] ; then
        echo "$FUNCNAME: select is wrong." 1>&2
        return 1
    fi
    cd "$selpath"
}

ほぼ cdmdfind そのままです。ただ、locate の出力が、mdfind -name とは違い、パス全体の中からの一致で非常に多くの検索が出てくるので grep -i -E "/[^/]*$arg[^/]*$" で検索結果を適切なように絞っています。

他にもアイデアが…

この記事を書くにあたって、他にもcdを効率化することができるか考えたのですが、いくつか考えついたものがあります。指定ディレクトリ以下に .git/ があるディレクトリを一覧してGitワークディレクトリを選択させるコマンドとか…。完成したら随時この記事に追記していこうとおもいます。

もしあなたにもアイデアがあれば、ぜひこの記事へのコメントや Twitter @xtetsuji へ教えていただければ幸いです。

参考

私の GitHub リポジトリの dotfiles にはこれらの設定例が書かれています。ただし、~/.bash_secret だけは秘密のファイルとして公開はしていません。