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 $@ があります。