tech.guitarrapc.cóm

Technical updates

git pull をサブディレクトリでまとめて実行するスクリプト

リポジトリが10個~ (あるいは100個でも) あるときに、そのすべてのローカルGitを更新したいことがまれにあります。 Windows、macOS、Linux各種OSでサブディレクトリにあるローカルGitでgit pullを一気に行う必要があったのでスクリプトをおいておきます。

tl;dr;

  • バッチファイル、PowerShellスクリプト、シェルスクリプトで同様にパラメーターを受け取り、動作するスクリプトが用意できる
  • OSに応じて手元にあると便利。めったに使わないけど

gistにおいておきます。

https://gist.github.com/guitarrapc/2623623a0e1bc7fe86b5cf56e0c70d88

やりたいこと

現在のフォルダ以下に次のように複数のGitリポジトリがクローンされた状態で、まとめてすべてgit pullしたいことがあります。

# たくさんフォルダがあるとして....
$ tree -L 1
.
├── Bar
├── Foo
├── Piyo
├── Hoge
├── ...
└── NanikaSugoiRepo

コミットしていない変更の存在するリポジトリもあり得るため、手でやるのは時間がいくらあっても足りません。 とりあえずリポジトリを今のrefに対して最新に更新したい。これを目指します。

# ↓を並べるのはやりたくない
cd ./Bar && git pull && cd - # stash や git reset --hard をしたいかもしれない
cd ./Foo && git pull && cd -
...

スクリプト

ワンライナーで書くのもいいですが各種OSでサクッとやりたいので、Windowsバッチファイル、PowerShellスクリプト、シェルスクリプトそれぞれで同じような引数を受け取って、同じように動作するようスクリプトを書きます。

やりたくなることを見越して、パラメーターでgit stashgit reset --hardもやるか選べるようにしましょう。git stashはデフォルト有効でパラメーターで無効を指定 (--no-stash)、git reset --hardはデフォルト無効でパラメーターで有効を指定できる (--force)、とします。

こんな感じの手触りをイメージして書きます。

# Windows バッチファイル
git-pull.bat --force --no-stash

# PowerShell スクリプト
./git-pull.ps1 -Force --NoStash

# シェルスクリプト
bash ./git-pull.sh --force --no-stash

さっそく見てみましょう。

Windowsバッチファイル

Git-pull.bat

:: Script to run `git pull` inside all subdirectories which are git repositories.
:: usage 1: keep local changes, then up to date.
::   > git-pull.bat
:: usage 2: abort local changes, reset if possible, then up to date.
::   > git-pull.bat --force --no-stash
:: usage 3: try keep local changes, reset if possible, then up to date.
::   > git-pull.bat --force

@echo off

setlocal
:parse
    IF "%~1"=="" GOTO endparse
    IF "%~1"=="--force" set _FORCE=true
    IF "%~1"=="--no-stash" set _NO_STASH=true
    SHIFT
    GOTO parse
:endparse

for /f "tokens=* delims=" %%i in ('dir /ad /b "."') do (
    echo --- Working on %cd%\%%i
    pushd "%cd%\%%i"
        if NOT EXIST ".git\" (
            echo   x: \"%cd%\%%i\" is not a git repository, continue next directory.
        ) else (
            if not "%_NO_STASH%"=="true" ( git stash --quiet )
            if "%_FORCE%"=="true" ( git reset --hard )
            git pull
            if not "%_NO_STASH%"=="true" ( git stash apply --quiet )
        )
    popd
)

endlocal

PowerShellスクリプト

Git-pull.ps1

# Script to run `git pull` inside all subdirectories which are git repositories.
# usage 1: keep local changes, then up to date.
#   PS> ./git-pull.ps1
# usage 2: abort local changes, reset if possible, then up to date.
#   PS> ./git-pull.ps1 -Force -NoStash
# usage 3: try keep local changes, reset if possible, then up to date.
#   PS> ./git-pull.ps1 -Force

param(
    [Switch]$Force,
    [Switch]$NoStash
)

foreach ($dir in Get-ChildItem -Directory) {
    try {
        pushd $dir.FullName
            echo "--- Working on ""$($dir.FullName)"""

            if ((Get-ChildItem -Force -Directory).Name -notcontains ".git") {
                echo "  x: ""$($dir.FullName)"" is not a git repository, continue next directory."
                continue
            }

            if (!$NoStash) { git stash --quiet }
            if ($Force) { git reset --hard }
            git pull
            if (!$NoStash) { git stash apply --quiet }
    } finally {
        popd
    }
}

シェルスクリプト

Git-pull.sh

#!/bin/bash
set -o pipefail

# Script to run `git pull` inside all subdirectories which are git repositories.
# usage 1: keep local changes, then up to date.
#   $ bash ./git-pull.sh
# usage 2: abort local changes, reset if possible, then up to date.
#   $ bash ./git-pull.sh --force --no-stash
# usage 3: try keep local changes, reset if possible, then up to date.
#   $ bash ./git-pull.sh --force

while [ $# -gt 0 ]; do
    case $1 in
        --force) _FORCE=true; shift 1; ;;
        --no-stash) _NO_STASH=true; shift 1; ;;
        *) shift ;;
    esac
done

## find is not a good way to control. Additionally, this include CURRENT directory and it is unexpected.
# find . -maxdepth 1 -type d -exec bash -c 'echo "Working on $(realpath $1)"; git reset --force; git pull' shell {} \;

for dir in ./*/; do
    echo "--- Working on \"$(realpath "${dir}")\""
    pushd "$(realpath "${dir}")" > /dev/null
        if [[ ! -d ".git" ]]; then
            echo "  x: \"$(realpath "${dir}")\" is not a git repository, continue next directory."
            popd > /dev/null
            continue
        fi
        if [[ "${_NO_STASH:=false}" != "true" ]]; then git stash --quiet; fi
        if [[ "${_FORCE:=false}" == "true" ]]; then git reset --hard; fi
        git pull
        if [[ "${_NO_STASH:=false}" != "true" ]]; then git stash apply --quiet; fi
    popd > /dev/null
done

書くときのメモ

どのスクリプトも、フォルダがGitフォルダじゃない場合は次に行きます。 また、エラーが出ても止まらないようにしています。もし止めたい場合は、set -e (シェルスクリプト) や$ErrorActionPreference = "Stop" (PowerShell)、あるいは%errorlevel%チェック (Windowsバッチファイル) でできますが、数が多いとエラー無視して進めたくなるんですよね。

Windowsバッチファイル

パラメーター引数を受け取るのはWindow バッチファイルでパラメーター入力を受け付けるで紹介したやり方です。 サブディレクトリで何かコマンドを実行する方法としてforを採用しました。ただ、このやり方だと実行中にCtrl+CでキャンセルしようとしてもCtl+C連打で止まらず、キャンセルプロンプトでYを選ばないといません。これは結構不便です。1

PowerShellスクリプト

try/finallyを使うことで、実行中にCtl+Cでキャンセルしてもフォルダ移動しっぱなしが防げます、便利。 パラメーター受け取りから制御処理まで、全体的に小細工がなく、素直に書けばそのまま動くのはPowerShellえらい。 あとは、PowerShell 7がOSのデフォルトに入ればいいのですが、なかなか先は見えません。

シェルスクリプト

パラメーター入力はいくつかやりかた2 がありますが、私はもっぱらwhile + case + shiftがパラメーターごとに一行で収まり、また任意のパラメーター名にできるのが好みです。 フォルダ一覧を取得するなら定番はfindですが、-execでコマンド並べるのは厳しいので雑にglobとしています。 シェルスクリプト定番なことだけ使って書いていますが、もう少しいい書き方ないですかね?

参考


  1. echoでクォートが使えないのは地味に不便です。
  2. よく見かけるのはwhile + case + shiftgetoptsfor arg in $@があります。