ディレクトリを差分だけコピー/削除する (バックアップする) 簡易シェルスクリプトを作る

:::note alert
このスクリプトを、ぶっつけ本番で大事なファイルに**使用しないことを**オススメします。動作を完全には保証できず、何かの間違いでファイルを盛大に消し飛ばすかもわかりません。
:::

## 背景

ハードディスクのファイルを、別のハードディスクにクローンしてバックアップしたい、なんてことがあって発案。

最も簡単なのは、ファイルブラウザ (Windows で言うエクスプローラー) でディレクトリを丸々コピーすること。しかし全ファイル上書きでは効率が悪いし、「すでに存在するファイルはスキップ」だと消したファイルが残る上に更新したファイルが操作されない･･･という問題が。

それを解決すべく、開発に着手したのでした。

## 作るもの

バックアップ元のディレクトリ `dir_src`、バックアップ先のディレクトリ`dir_dst` があるとして、

```sh
./backup.sh <dir_src> <dir_dst>
```

って感じにコマンドを実行したら、`dir_src` 内の各ファイルについて、

| 状況 | 操作 |
| --- | --- |
| `dir_src` だけにある | `dir_dst` にコピー |
| `dir_src` に新しい版がある (更新された) | `dir_dst` に上書きコピー |
| `dir_src` に無い (消された) | `dir_dst` から削除 |

という操作をしてくれるシェルスクリプトを作ります。

:::note
`src`: source - 源
`dst`: destination - 宛先
:::

:::note warn
シェルスクリプトの基礎的な文法や実行方法は知っている前提で進めます。
:::

## 最小構成のを作る

### `dir_dst` にコピー

> `dir_src` だけにある -> `dir_dst` にコピー
> `dir_src` に新しい版がある (更新された) -> `dir_dst` に上書きコピー

`cp` 1つで楽々です。

```sh
cp -ruv $dir_src/* $dir_dst
```

* `cp`
  * `-r`: 再帰的にディレクトリをコピー (ディレクトリをコピー可能にすると考えて良い)
  * `-u`: コピー元に対し、コピー先のファイルが古い、または存在しないときだけコピー
  * `-v`: 操作内容を表示 (不要なら無くて良い)

:::note
`dir_src="src/"` のように末尾に `/` があった場合、`src//*` と渡されることになってしまいますが、cp は問題なく解釈してくれたのでこのままいきます。(環境変わっても大丈夫かは保証できませんが･･･)
:::

### `bck` から削除

> `dir_src` に無い (消された) -> `dir_dst` から削除

こちらは少し工夫が要ります。

1. `dir_src` `dir_dst` のファイル一覧を得る
2. 各ファイル一覧 (文字列) について、`dir_dst` にだけあるファイル名を抽出
3. 上で抽出されたファイルを削除

```sh
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`: 操作内容を表示 (不要なら無くて良い)

<details>
<summary>(※) 文字列処理について</summary>

例えば `find dst` というようにディレクトリ名を指定すると、

```sh
find dst
# dst/
# dst/file1.txt
# dst/file2.txt
```

ファイル名の先頭にディレクトリ名が付いてしまいます。

後の処理でこれが邪魔になるため、`cd` でこれを無くしています。

```sh
cd dst/; find; cd ..
# .
# ./file1.txt
# ./file2.txt
```

※ もちろん `sed` で処理しても問題ない

```sh
find dst/ | sed -e "s/^dst\///g"
```

※ ただ、`cd` なら `sed` の文字列処理に負荷がかからないという、気持ち程度の効率化にはなる

---
 
</details>

### 完成品 (最低限の実装)

```backup.sh
#!/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 ..
```

## 機能追加: 操作ファイル数表示

引数のミスなどにより、変な所にコピーしてしまった！なんてことがあるかもしれません。そこで、操作ファイル数を先に表示して、そこから続行するかを問う、なんて機能を実装することにします。

### 新規コピーファイル数取得

```sh
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`: 行数のみ出力する

<details>
<summary>wc の sed 処理について</summary>

`wc -l` は改行コードの数を数えるという仕組みです。そして `echo` は最後に改行が 1つ入れられます (オプション無しの標準動作の場合)。

```sh
echo "any file detected" | wc -l
# 1

echo "" | wc -l
# 1
```

該当ファイルが 0個のとき 1個のとき、いずれでも改行 1つが入り、結果が 1個となってしまいます。

そこで、0個のときは空き行 1つになると捉えて、`sed` で空行を削除する処理 `/^$/d` を入れることで、正しい結果を得ることにしました。

:::note
正規表現で `^` は行の先頭、`$` は行の末尾の意味。`^$` は、行の先頭と末尾の間に文字がなければ、つまり空行にマッチするということになります。
:::

:::note
`d` は、`sed` で当該行を削除するコマンド。`/.../` は、行指定に正規表現を適用するコマンドです。
:::

なお、同様の処理を `if` で実装することも可能です。これは行数が少し多いのが難点。

```sh
if [ "$list_add" = "" ]; then
  count_add=0
else
  count_add=`echo "$list_add" | wc -l`
fi
```

---

</details>

### 上書きファイル数取得

```sh
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ディレクトリの中身の差分取得にも使えます。その出力から、内容の異なる (更新された) ファイル数を得ます。

:::note warn
`grep` の `異なります` は、環境 (特に言語) によって変わることがあります。英語表示の場合 `differ` になります。
:::

### 削除ファイル数取得

新規コピーとだいたい同じなので省略。

### 完成品 (操作ファイル数表示)

```sh
#!/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` 操作をしてくれることですが、そこまでやろうとするとだいぶ規模が大きくなってきます。

ここでは簡易型まででとどめます。

## あとがき

お馴染みの標準コマンドも、オプションをよく調べてみると割と発見があるんですよね。それがあるときメッチャ役に立ったり。

何か無いかなって、とりあえずマニュアルやヘルプを見てみる習慣はあるといいです。マジで。