このスクリプトを、ぶっつけ本番で大事なファイルに使用しないことをオススメします。動作を完全には保証できず、何かの間違いでファイルを盛大に消し飛ばすかもわかりません。
背景
ハードディスクのファイルを、別のハードディスクにクローンしてバックアップしたい、なんてことがあって発案。
最も簡単なのは、ファイルブラウザ (Windows で言うエクスプローラー) でディレクトリを丸々コピーすること。しかし全ファイル上書きでは効率が悪いし、「すでに存在するファイルはスキップ」だと消したファイルが残る上に更新したファイルが操作されない・・・という問題が。
それを解決すべく、開発に着手したのでした。
作るもの
バックアップ元のディレクトリ dir_src
、バックアップ先のディレクトリdir_dst
があるとして、
./backup.sh <dir_src> <dir_dst>
って感じにコマンドを実行したら、dir_src
内の各ファイルについて、
状況 | 操作 |
---|---|
dir_src だけにある |
dir_dst にコピー |
dir_src に新しい版がある (更新された) |
dir_dst に上書きコピー |
dir_src に無い (消された) |
dir_dst から削除 |
という操作をしてくれるシェルスクリプトを作ります。
src
: source - 源
dst
: destination - 宛先
シェルスクリプトの基礎的な文法や実行方法は知っている前提で進めます。
最小構成のを作る
dir_dst
にコピー
dir_src
だけにある ->dir_dst
にコピー
dir_src
に新しい版がある (更新された) ->dir_dst
に上書きコピー
cp
1つで楽々です。
cp -ruv $dir_src/* $dir_dst
-
cp
-
-r
: 再帰的にディレクトリをコピー (ディレクトリをコピー可能にすると考えて良い) -
-u
: コピー元に対し、コピー先のファイルが古い、または存在しないときだけコピー -
-v
: 操作内容を表示 (不要なら無くて良い)
-
dir_src="src/"
のように末尾に /
があった場合、src//*
と渡されることになってしまいますが、cp は問題なく解釈してくれたのでこのままいきます。(環境変わっても大丈夫かは保証できませんが・・・)
bck
から削除
dir_src
に無い (消された) ->dir_dst
から削除
こちらは少し工夫が要ります。
-
dir_src
dir_dst
のファイル一覧を得る - 各ファイル一覧 (文字列) について、
dir_dst
にだけあるファイル名を抽出 - 上で抽出されたファイルを削除
cd $dir_src; find > ../list_src.txt; cd ..
cd $dir_dst; find > ../list_dst.txt; cd ..
list_del=`diff -q --old-line-format="" --unchanged-line-format="" --new-line-format="%L" list_src.txt list_dst.txt`
cd "$dir_dst"; echo -n "$list_del" | xargs -r -d "\n" rm -rvf; cd ..
-
find
- 指定ディレクトリ (未指定でカレントディレクトリ) からファイルを検索
- フィルタ条件無しで全ファイル一覧を出力する
- あえて
cd
経由にしたのは、文字列処理の手間を減らすため (※)
-
diff
- 2ファイルの差分を出す
-
--xxx-line-format
: 前のファイルのみに / 両方に / 後のファイルのみにある行の出力形式の指定 - これを
""
とすれば出力しないことにできる - これにより
dir_dst
のみにあるファイルを得る
-
xargs
- 標準入力を引数として指定のコマンドを実行する
-
-r
: 標準入力が無いとき、指定コマンド (ここではrm
) を実行しない -
-d "\n"
: 標準入力を\n
のみで分割する (スペースを含むファイル名対策)
-
rm
-
-r
: 再帰的にディレクトリを削除 (ディレクトリを削除可能にすると考えて良い) -
-f
: 存在しないファイルでもエラーを出さない -
-v
: 操作内容を表示 (不要なら無くて良い)
-
(※) 文字列処理について
例えば find dst
というようにディレクトリ名を指定すると、
find dst
# dst/
# dst/file1.txt
# dst/file2.txt
ファイル名の先頭にディレクトリ名が付いてしまいます。
後の処理でこれが邪魔になるため、cd
でこれを無くしています。
cd dst/; find; cd ..
# .
# ./file1.txt
# ./file2.txt
※ もちろん sed
で処理しても問題ない
find dst/ | sed -e "s/^dst\///g"
※ ただ、cd
なら sed
の文字列処理に負荷がかからないという、気持ち程度の効率化にはなる
完成品 (最低限の実装)
#!/bin/sh
# Arguments check
if ! [ -d "$1" ]; then
echo "Source is unspecified or not a directory" >> /dev/stderr
exit 1
fi
dir_src=$1
if ! [ -d "$2" ]; then
echo "Distruction is unspecified or not a directory" >> /dev/stderr
exit 1
fi
dir_dst=$2
# Get files list
cd "$dir_src"; find > ../list_src.txt; cd ..
cd "$dir_dst"; find > ../list_dst.txt; cd ..
# Deleted files extract
list_del=`diff --old-line-format="" --unchanged-line-format="" --new-line-format="%L" list_src.txt list_dst.txt`
rm list_src.txt list_dst.txt
# Execute
cp -ruv ${dir_src%\/}/* "$dir_dst"
cd "$dir_dst"; echo -n "$list_del" | xargs -r -d "\n" rm -rvf; cd ..
機能追加: 操作ファイル数表示
引数のミスなどにより、変な所にコピーしてしまった!なんてことがあるかもしれません。そこで、操作ファイル数を先に表示して、そこから続行するかを問う、なんて機能を実装することにします。
新規コピーファイル数取得
list_add=`diff --old-line-format="%L" --unchanged-line-format="" --new-line-format="" list_src.txt list_dst.txt`
count_add=`echo "$list_add" | sed -e '/^$/d' | wc -l`
削除されたファイルの抽出を応用して、dir_src
のみにあるファイルを得ます。そして wc
コマンドでテキストの行数 (=ファイル数) を得ます。
-
wc
-
-l
: 行数のみ出力する
-
wc の sed 処理について
wc -l
は改行コードの数を数えるという仕組みです。そして echo
は最後に改行が 1つ入れられます (オプション無しの標準動作の場合)。
echo "any file detected" | wc -l
# 1
echo "" | wc -l
# 1
該当ファイルが 0個のとき 1個のとき、いずれでも改行 1つが入り、結果が 1個となってしまいます。
そこで、0個のときは空き行 1つになると捉えて、sed
で空行を削除する処理 /^$/d
を入れることで、正しい結果を得ることにしました。
正規表現で ^
は行の先頭、$
は行の末尾の意味。^$
は、行の先頭と末尾の間に文字がなければ、つまり空行にマッチするということになります。
d
は、sed
で当該行を削除するコマンド。/.../
は、行指定に正規表現を適用するコマンドです。
なお、同様の処理を if
で実装することも可能です。これは行数が少し多いのが難点。
if [ "$list_add" = "" ]; then
count_add=0
else
count_add=`echo "$list_add" | wc -l`
fi
上書きファイル数取得
list_mod=`diff "$dir_src" "$dir_dst" | grep '異なります$'`
# 英語出力なら
# list_mod=`diff "$dir_src" "$dir_dst" | grep 'differ$'`
count_mod=`echo "$list_mod" | sed -e '/^$/d' | wc -l`
diff
は、2ディレクトリの中身の差分取得にも使えます。その出力から、内容の異なる (更新された) ファイル数を得ます。
grep
の 異なります
は、環境 (特に言語) によって変わることがあります。英語表示の場合 differ
になります。
削除ファイル数取得
新規コピーとだいたい同じなので省略。
完成品 (操作ファイル数表示)
#!/bin/sh
# Argument check
if ! [ -d "$1" ]; then
echo "Source is unspecified or not a directory" >> /dev/stderr
exit 1
fi
dir_src=$1
if ! [ -d "$2" ]; then
echo "Distruction is unspecified or not a directory" >> /dev/stderr
exit 1
fi
dir_dst=$2
# Get files list
cd "$dir_src"; find > ../list_src.txt; cd ..
cd "$dir_dst"; find > ../list_dst.txt; cd ..
# Added/Updated/Deleted files extract
list_add=`diff --old-line-format="%L" --unchanged-line-format="" --new-line-format="" list_src.txt list_dst.txt`
list_mod=`diff "$dir_src" "$dir_dst" | grep '異なります$'`
# 英語出力なら
# list_mod=`diff "$dir_src" "$dir_dst" | grep 'differ$'`
list_del=`diff --old-line-format="" --unchanged-line-format="" --new-line-format="%L" list_src.txt list_dst.txt`
rm list_src.txt list_dst.txt
# File count view
count_add=`echo "$list_add" | sed -e '/^$/d' | wc -l`
count_mod=`echo "$list_mod" | sed -e '/^$/d' | wc -l`
count_del=`echo "$list_del" | sed -e '/^$/d' | wc -l`
echo "Added : $count_add"
echo "Modified: $count_mod"
echo "Deleted : $count_del"
# User confirm check
echo -n "Continue? [y/n] "
read user_in
if [ "$user_in" != "y" ] && [ "$user_in" != "Y" ]; then
echo "Aborting"
exit 1
fi
# Execute
cp -ruv ${dir_src%\/}/* "$dir_dst"
cd "$dir_dst"; echo -n "$list_del" | xargs -r -d "\n" rm -rvf; cd ..
ファイル移動の対応は・・・
このスクリプトだと、根本のディレクトリを移動や名前変更すると、多くのファイルのコピーと削除を行うことになってしまうのが予想されます。理想は、移動や名前変更を検出して mv
操作をしてくれることですが、そこまでやろうとするとだいぶ規模が大きくなってきます。
ここでは簡易型まででとどめます。
あとがき
お馴染みの標準コマンドも、オプションをよく調べてみると割と発見があるんですよね。それがあるときメッチャ役に立ったり。
何か無いかなって、とりあえずマニュアルやヘルプを見てみる習慣はあるといいです。マジで。