#!/usr/bin/env bash
#
#
#
set -euo pipefail
RED=’\033[0;31m’ YELLOW=’\033[1;33m’ GREEN=’\033[0;32m’ CYAN=’\033[0;36m’ BOLD=’\033[1m’ RESET=’\033[0m’
find_project_root() { local dir=”$1” while [[ “$dir” != “/” ]]; do if [[ -d “$dir/.git” ]]; then echo “$dir” return 0 fi dir=”$(dirname “$dir”)” done return 1 }
is_within_project() { local target=”$1” local project_root=”$2”
# realpath でシンボリックリンクや ../ を解決
local resolved
if [[ -e "$target" ]]; then
resolved="$(realpath "$target")"
else
# 存在しないパスの場合、親ディレクトリから解決を試みる
local parent
parent="$(dirname "$target")"
if [[ -e "$parent" ]]; then
resolved="$(realpath "$parent")/$(basename "$target")"
else
resolved="$(realpath -m "$target" 2>/dev/null || echo "$target")"
fi
fi
# プロジェクトルート自体の削除は禁止
if [[ "$resolved" == "$project_root" ]]; then
return 1
fi
# プロジェクトルート配下であることを確認
if [[ "$resolved" == "$project_root/"* ]]; then
return 0
fi
return 1 }
show_help() { cat « ‘EOF’ safe-rm - プロジェクトルート配下のみ削除を許可する安全な rm ラッパー
使い方:
safe-rm [OPTIONS]
動作: デフォルトではドライランモードで動作し、削除対象を表示するだけで 実際には削除しません。–execute を付けると実際に削除します。
オプション: –execute 実際に削除を実行する -r, -R, –recursive 再帰削除 -f, –force 強制削除 -v, –verbose 詳細表示 -h, –help このヘルプを表示
プロジェクトルート判定: カレントディレクトリから親方向に .git ディレクトリを探し、 最も近い .git を含むディレクトリをプロジェクトルートとします。
安全機能:
Claude Code での使い方: CLAUDE.md に以下を記載: ファイル削除には /path/to/safe-rm を使うこと。rm を直接使わないこと。
例: safe-rm -rf build/ # ドライラン(何が消えるか表示) safe-rm -rf build/ –execute # 実際に削除 EOF exit 0 }
main() { local execute=false local rm_opts=() local targets=() local has_recursive=false
# 引数パース
while [[ $# -gt 0 ]]; do
case "$1" in
--execute)
execute=true
shift
;;
--help|-h)
show_help
;;
# 複合フラグを分解して処理
-rf|-fr|-rfv|-fvr|-vrf|-vrF|-Rf|-fR)
has_recursive=true
rm_opts+=("$1")
shift
;;
-r|-R|--recursive)
has_recursive=true
rm_opts+=("$1")
shift
;;
-f|--force|-v|--verbose|-d|--dir|-i|-I|--interactive|--one-file-system|--no-preserve-root|--preserve-root)
rm_opts+=("$1")
shift
;;
--)
shift
# -- 以降はすべてターゲット
targets+=("$@")
break
;;
-*)
# その他のフラグもそのまま渡す
rm_opts+=("$1")
shift
;;
*)
targets+=("$1")
shift
;;
esac
done
# ターゲットが指定されていない場合
if [[ ${#targets[@]} -eq 0 ]]; then
echo -e "${RED}エラー: 削除対象が指定されていません${RESET}" >&2
echo "使い方: safe-rm [OPTIONS] <paths...>" >&2
exit 1
fi
# プロジェクトルート検出
local project_root
if ! project_root="$(find_project_root "$(pwd)")"; then
echo -e "${RED}エラー: プロジェクトルートが見つかりません${RESET}" >&2
echo ".git ディレクトリが見つかるディレクトリ内で実行してください" >&2
exit 1
fi
echo -e "${CYAN}プロジェクトルート: ${project_root}${RESET}"
# 各ターゲットを検証
local blocked=()
local allowed=()
for target in "${targets[@]}"; do
# 危険なパスの即時ブロック
local resolved
if [[ -e "$target" ]]; then
resolved="$(realpath "$target")"
else
resolved="$(realpath -m "$target" 2>/dev/null || echo "$target")"
fi
if [[ "$resolved" == "/" ]]; then
echo -e "${RED}ブロック: / (ルートディレクトリ) の削除は禁止です${RESET}" >&2
blocked+=("$target")
continue
fi
local home_resolved
home_resolved="$(realpath "$HOME")"
if [[ "$resolved" == "$home_resolved" ]]; then
echo -e "${RED}ブロック: $target (ホームディレクトリ) の削除は禁止です${RESET}" >&2
blocked+=("$target")
continue
fi
if is_within_project "$target" "$project_root"; then
allowed+=("$target")
else
echo -e "${RED}ブロック: $target (プロジェクトルート外) の削除は禁止です${RESET}" >&2
echo -e " 解決先: $resolved" >&2
blocked+=("$target")
fi
done
# ブロックされたものがあれば警告
if [[ ${#blocked[@]} -gt 0 ]]; then
echo ""
echo -e "${RED}${BOLD}${#blocked[@]} 件の対象がブロックされました${RESET}"
fi
# 許可されたターゲットがなければ終了
if [[ ${#allowed[@]} -eq 0 ]]; then
echo -e "${YELLOW}削除可能な対象がありません${RESET}"
exit 1
fi
# ドライラン / 実行
echo ""
if [[ "$execute" == true ]]; then
echo -e "${GREEN}${BOLD}実行モード: 以下を削除します${RESET}"
for t in "${allowed[@]}"; do
echo -e " ${GREEN}削除: $t${RESET}"
done
echo ""
/bin/rm "${rm_opts[@]}" "${allowed[@]}"
echo -e "${GREEN}完了${RESET}"
else
echo -e "${YELLOW}${BOLD}ドライラン: 以下が削除対象です(実際には削除されません)${RESET}"
for t in "${allowed[@]}"; do
if [[ -d "$t" ]] && [[ "$has_recursive" == true ]]; then
echo -e " ${YELLOW}[ディレクトリ] $t${RESET}"
# ディレクトリの場合、中身の概要を表示
local count
count="$(find "$t" -mindepth 1 2>/dev/null | head -100 | wc -l)"
if [[ "$count" -ge 100 ]]; then
echo -e " (100+ ファイル/ディレクトリ)"
else
echo -e " ($count ファイル/ディレクトリ)"
fi
elif [[ -f "$t" ]]; then
local size
size="$(du -sh "$t" 2>/dev/null | cut -f1)"
echo -e " ${YELLOW}[ファイル] $t ($size)${RESET}"
else
echo -e " ${YELLOW}[不明/不在] $t${RESET}"
fi
done
echo ""
echo -e "${CYAN}実際に削除するには ${BOLD}--execute${RESET}${CYAN} を付けて再実行してください${RESET}"
echo -e " 例: safe-rm ${rm_opts[*]+"${rm_opts[*]} "}${allowed[*]} --execute"
fi }
main “$@”