gists

#!/usr/bin/env bash

safe-rm: プロジェクトルート配下のみ削除を許可する rm ラッパー

デフォルトはドライランモード(–execute で実際に削除)

#

使い方:

safe-rm [OPTIONS]

#

オプション:

–execute 実際に削除を実行する(デフォルトはドライラン)

-r, -R, –recursive, -rf, -fr 再帰削除(rm に渡すフラグ)

-f, –force 強制削除(rm に渡すフラグ)

-v, –verbose 詳細表示

-h, –help ヘルプ表示

#

その他の rm オプションもそのまま rm に渡されます。

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’

— プロジェクトルート検出 (.git を探す) —

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 “$@”