朝日ネット 技術者ブログ

朝日ネットのエンジニアによるリレーブログ。今、自分が一番気になるテーマで書きます。

Git のサブコマンドに学ぶシェルスクリプトの書き方

はじめに

開発部の tasaki です。

シェルスクリプトはプログラムとプログラムとを繋ぐ存在であり、UNIX や Linux を使う上で最も重要なスクリプト言語であると言えます。 curl, diff, git, rsync, ssh 等の外部コマンドが複数絡むスクリプトや、テキストストリーム処理を多用するスクリプトを書きたい場合はシェルスクリプトとして書くのが最も手軽です。

一方、シェルスクリプトは書き方の自由度が高くコードスタイルについての合意が取りにくい言語であり、移植性の高いものを書くのが難しい言語であります。 大抵の Linux には bash が入っているためそれ向けに書いてしまってもよいのですが、Docker の流行により bash が標準で入っていない Alpine Linux 環境が使われる機会が世間的にも増えているため、少なくともデプロイ先で動くスクリプトは bash に依存しない形で書いておきたいです。

ところで、Git の bisect, filter-branch, stash, submodule, subtree などの一部のサブコマンドはシェルスクリプトとして書かれています。 これはあらゆる環境で安定して動作するシェルスクリプトであると言え、これを参考にすれば移植性の高いシェルスクリプトを書けるのではないでしょうか? 今回は私が Git に含まれるシェルスクリプトを読んで得られた知見を Tips としてまとめていきます。

方針

なお Git のライセンスは GPLv2 であり、ソースコードを丸ごとコピー&ペーストして使うとライセンス上問題になる可能性があります。 また、以下のコード片にはソースコードそのものではなく私が解釈・再構成したものも含まれています。あらかじめご了承ください。

言語機能

コマンド置換には常に $(...) を使う

コマンド置換を使う場合、常にバッククオートではなく $(...) を使うようにしましょう。 バッククオートでのコマンド置換は文字のエスケープが難しく、一方 $(...) は簡単に入れ子にできるという利点があります。

算術式展開を使う

$((...)) 形式の算術式展開は POSIX でも規定されているため外部コマンド呼び出しの expr よりこちらを使う方がよいでしょう。

x=1
x=$(( x + 1 ))

ただし、算術式展開にはシェル独自の拡張機能がある場合もあり 1 、それをうっかり使わないよう注意する必要があります。 整数同士の単純な四則演算に限定するのが無難でしょう。

関数名にハイフンやコロンを使わない

関数名にハイフン (-) やコロン (:) が入っていると bash の POSIX sh 互換モードや Busybox sh では動作しません(素の bash では動いてしまいます)。 アンダーバー (_) などで代用しましょう。

ビルトインコマンド

echo の代わりに printf を使う

任意の文字列を表示するために echo "$*" のように書くと $*-n-e という文字列だった際に意図しない挙動になります。 代わりに printf '%s\n' "$*" を使いましょう。 git-sh-setup に以下のような関数が定義されています。

GIT_QUIET=

say () {
  if test -z "$GIT_QUIET"
  then
    printf '%s\n' "$*"
  fi
}

また、メッセージを表示して終了する以下のような関数も定義されています。 このようにログを残すタイプの関数は独自にいくつか定義しておいた方がよいでしょう。

die () {
  die_with_status 1 "$@"
}

die_with_status () {
  status="$1"
  shift
  printf >&2 '%s\n' "$*"
  exit "$status"
}

test "X${foo}" = Xbar とせず test "$foo" = bar とする

古いシェルでは test "$foo" = bar と書くと $foo が空文字列や test の演算子として解釈できる文字列であった場合に意図しない挙動になることがあります。 この問題を回避するため伝統的なシェルスクリプトでは test "X${foo}" = Xbar のような書き方をすることがあります。 しかし、今のほとんどのシェルではこのような問題は起こらない、と少なくとも Git の開発者は判断しているようです。 私たちも分かりやすさのために test "$foo" = bar ([ "$foo" = bar ]) のように書いてしまってよいでしょう。

test "$foo" = bar  # test "X${foo}" = Xbar
test -z "$foo"     # test "X${foo}" = X
test -n "$foo"     # test "X${foo}" != X

環境変数・シェル変数

IFS の設定

Git サブコマンドは git --exec-pathPATH 環境変数の先頭に追加された状態で実行されるようになっており、 冒頭で git-sh-setup という共通のライブラリを .(bash で言う source)で読み込んでいます。

. git-sh-setup

git-sh-setup の共通処理として IFS が半角スペース (SP, 0x20)、タブ (HT, 0x09)、改行 (LF, 0x0a) に設定されています。 unset IFS では問題が生じる環境があるためこのようにしているようです。

IFS="$(printf '\t\n ')"

grep と環境変数

GREP_OPTIONS 環境変数やロケール (LC_ALL) が設定されていると grep を使う際に意図しない挙動になることがあります。 GREP_OPTIONS='' LC_ALL=C を付けて起動するようにしましょう。

sane_grep () {
  GREP_OPTIONS='' LC_ALL=C grep "$@"
}

sed と環境変数

sed にも grep と似たようなことが言え、こちらは常に LANG=C LC_ALL=C としておくのがよいようです。

sane_sed () {
  LANG=C LC_ALL=C sed "$@"
}

unset CDPATH

存在しないディレクトリに cd しようとした際、CDPATH 環境変数中のディレクトリ直下に同名のディレクトリがあるとそこに cd するという機能があります。

mkdir -p foo/bar/baz
CDPATH="${PWD}/foo/bar"
cd baz  # ${PWD}/foo/bar/baz に cd する

これが意図しない挙動を招くおそれがあるためシェルスクリプトの最初で unset しておくのがよいようです。

unset CDPATH

イディオム

変数が set されている場合にのみ処理を行う

変数展開の +test -n を組み合わせます。

if test -n "${X+set}"; then
  :  # X が set されている場合の処理
fi

変数の値が数値かどうかを判定する

git-submodule では値に 0 を足した結果が元の文字列に等しくなるかどうかで判定しています。

isnumber () {
  n=$(($1 + 0)) 2>/dev/null && test "$n" = "$1"
}

サブコマンドの定義方法

git-submodulegit-subtree ではサブコマンド(git のサブコマンドのサブコマンド)が cmd_* という名前の関数として定義されており、 最後に "cmd_$command" でそれを呼び出す、という形のスクリプトになっています。 サブコマンドを持つシェルスクリプトを定義したい場合はこの方式を参考にするとよいでしょう。

case "$1" in
  foo|bar)
    command="$1"
    ;;
  *)
    # 不正なサブコマンド(usage を表示して終了)
    usage
    ;;
esac
shift

"cmd_$command" "$@"

なお git-stash は愚直に case の中で関数を呼び出しているようです。

case "$1" in
  foo)
    cmd_foo "$@"
    ;;
  bar)
    cmd_bar "$@"
    ;;
  *)
    # 不正なサブコマンド(usage を表示して終了)
    usage
    ;;
esac

コマンドラインオプションのパース

コマンドラインオプションのパースは getopt (外部コマンド)や getopts (bash ビルトイン)を使わず以下のように自前で行っています。 以下が典型的な例です。

while test $# -ne 0; do
  case "$1" in
    -f|--force)
      # 無引数オプション
      force=1
      ;;
    -c|--config)
      # 引数を取るオプション
      config="$2"
      shift
      ;;
    --config=*)
      # = を含む形式
      config="${1#--config=}"
      ;;
    --)
      # 後続を位置引数として解釈する
      shift
      break
      ;;
    -*)
      # 不正なオプション(usage を表示して終了)
      usage
      ;;
    *)
      # 位置引数
      break
      ;;
  esac
  shift
done

備考

umask 0022

Git 内のシェルスクリプトでは見受けられませんでしたが、 内部でファイルを作成するタイプのシェルスクリプトでは異常なパーミッションでファイルを作られないよう冒頭で umask 0022 しておくのがよいと思われます。

umask 0022

set -uset -e

set -u しておくと未定義の変数を展開しようとした場合にエラーにすることができます。 特に問題なければ設定しておいた方がよいと思われます。

set -u
echo "${undefined_variable}"   # エラー
echo "${undefined_variable-}"  # エラーにしたくない場合は - を使う

set -e するとコマンドがエラーになった際にシェルスクリプト全体を異常終了させることができます。 こちらも問題なければ設定しておいた方がよいのですが、set -e を使う場合のシェルスクリプトの書き方と使わない場合の書き方は大きく異なります。 set -e するか適宜 exit させるかは状況に応じて決めましょう。 また、他のシェルスクリプトから . (source) されるスクリプトの中で set -e すると呼び出し元でもエラー時に全体が異常終了するようになるため注意が必要です。

シェルスクリプトのチェック

checkbashisms を使うとシェルスクリプトが bash の機能に依存していないかどうかをチェックすることができます。

より詳しいチェックを行う linter としては ShellCheck があります。

Google Style Guide

Google の Shell Style Guide を bash 向けから POSIX sh 向けに変更した場合の差分は以下の通りです。

  • Background
    • Which Shell to Use
      • bash 決め打ちにしない
  • Comments
    • File Header
      • shebang は #!/bin/sh とする
  • Features and Bugs
    • Test, [ and [[
      • [[ は使えないため [ を使う
    • Pipes to While
      • プロセス置換は使えないため一時ファイルを使うなどの代替手法を使う必要がある
  • Naming Conventions
    • Function Names
      • : は関数名に使えないため _ などの文字で代用する
    • Constants and Environment Variable Names / Read-only Variables / Local Variables
      • readonly, declare, local は使えないため諦める
      • getopts は使えないためコマンドラインオプションは自前でパースする
  • Calling Commands
    • Checking Return Values
      • PIPESTATUS は使えないため別の書き方をするか諦める

おわりに

今回はシェルスクリプトの書き方について、Git に含まれるシェルスクリプトを参考にして考察してみました。 ヘルプの表示処理や MinGW 環境で動かす場合の特別な処理などこの記事では触れなかったハックも沢山あるので是非一度実際に Git のシェルスクリプトを覗いてみてください。

個人的には Go でプログラムを書くようになって以来シェルスクリプトを書く頻度がかなり高くなっています。 Go で CLI ツールを書くとその CLI ツールを組み合わせて起動するスクリプトが必要になってくるのですが、 そのスクリプトを Python や Node.js で書いてしまうとシングルバイナリで動作する Go で書いた意味が薄れてしまいます。 よって簡単なシェルスクリプトを書いて実行することにしています。

いずれにせよ、シェルスクリプトは TPO を弁えて使うことが重要です。 複雑なエラー処理が必要になる場合や JSON などの構造を持ったデータを読み書きする場合は別の言語を使った方が無難です。 用法・用量を守って分かりやすいシェルスクリプトを書いていきましょう。

採用情報

朝日ネットでは新卒採用・キャリア採用を行っております。


  1. 例えば bash ではべき乗演算子 (**) を使うことができたり、zsh では浮動小数点数の演算ができたりします。