清理macos系统DS_Store文件的sh脚本

清理macos系统DS_Store文件的sh脚本

MacOS的历史垃圾, .DS_Store 文件太讨厌了。

让AI写了个脚本, 亲测可用。

clean_DS_Store.sh

权限脚本为:

sh 复制代码
cd ~/
touch clean_DS_Store.sh
chmod +x clean_DS_Store.sh

脚本文件的内容为:

shell 复制代码
#!/bin/bash
# 清理目录树中的 .DS_Store 文件(macOS Finder 生成的垃圾文件)
# 用法: ./clean_DS_Store.sh [-n] [总目录]
#
# 遍历方向说明:
#   只从"指定的总目录"向下遍历其自身及所有子孙目录,
#   不会向上查找父目录。这是刻意的设计------避免误删用户主目录
#   或项目上级目录中不属于本次清理范围的 .DS_Store。
#
# 依赖:bash(非 POSIX sh)、find、stat。stat 用的是 macOS 的 -f%z 语法,
#       在 Linux 上需改为 stat -c%s 才能取到文件大小。
#
# 退出码:0 成功 / 1 参数或目录校验失败 / 2 有文件但全部删除失败

# -u: 引用未定义变量时报错退出,尽早暴露拼写错误。
# 刻意不加 -e 和 -o pipefail:
#   -e 会因为单次 rm 失败而中断整个清理流程,剩余的目录就扫不到了;
#   这里希望"个别文件失败不影响继续清理",最后再统一汇报,故手动处理错误。
set -u

show_help() {
    cat <<'EOF'
清理目录树中的 .DS_Store 垃圾文件

用法:
  ./clean_DS_Store.sh [选项] [总目录]

选项:
  -n, --dry-run   只列出将被删除的文件,不实际删除
  -h, --help      显示此帮助信息

参数:
  总目录          要清理的根目录,默认使用当前交互目录。
                  可用相对路径(如 ../foo)或绝对路径。

示例:
  ./clean_DS_Store.sh                       # 清理当前目录
  ./clean_DS_Store.sh ~/CODE_ALL            # 清理指定目录
  ./clean_DS_Store.sh -n /path/to/project   # 预览,不删除
EOF
}

# DRY_RUN=1 时只预览不删除,是防止手滑的关键开关,默认关闭。
DRY_RUN=0
TARGET_ARG=""

# 手写参数解析(不用 getopt):兼容 macOS 自带的 BSD getopt,
# 且不引入外部依赖,脚本可以直接拷到任何机器上跑。
while [ $# -gt 0 ]; do
    case "$1" in
        -h|--help)
            show_help
            exit 0
            ;;
        -n|--dry-run)
            DRY_RUN=1
            shift
            ;;
        -*)
            echo "错误: 未知选项 '$1'" >&2
            echo "使用 -h 查看帮助。" >&2
            exit 1
            ;;
        *)
            # 非选项参数即目标目录。只允许出现一个,
            # 出现第二个直接报错------多半是路径含空格又没加引号导致被拆成了两段,
            # 静默接受会去清理一个错误的目录。
            if [ -n "$TARGET_ARG" ]; then
                echo "错误: 只能指定一个总目录(多余参数: '$1')" >&2
                echo "提示: 路径含空格时请用引号括起来。" >&2
                exit 1
            fi
            TARGET_ARG="$1"
            shift
            ;;
    esac
done

# --- 1. 交互式获取总目录 -------------------------------------------------
# 命令行给了目录就用命令行的;没给才进入交互询问。
# 这样脚本既能手动跑(交互友好),也能被其他脚本调用(无需人工干预)。
DEFAULT_DIR="$PWD"
if [ -n "$TARGET_ARG" ]; then
    TARGET_DIR="$TARGET_ARG"
else
    echo "=============================================="
    echo "        .DS_Store 清理工具"
    echo "=============================================="
    echo "请输入需要清理的总目录。"
    echo "直接回车则使用默认目录: $DEFAULT_DIR"
    echo ""
    printf "总目录: "
    # read 返回值留给 || 兜底:C-c 或读到 EOF 时 USER_INPUT 会是空串,
    # 配合 set -u 避免"未定义变量"报错。
    read -r USER_INPUT || USER_INPUT=""
    # 清洗用户输入:
    #   1) 去掉首尾空白------从 Finder 复制路径、或手滑多敲空格都很常见;
    #   2) 去掉首尾引号------把路径拖进终端时,zsh 常会带上 '...' 或 "...",
    #      引号会被当成路径的一部分而导致"目录不存在"。
    USER_INPUT="$(printf '%s' "$USER_INPUT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e "s/^['\"]//" -e "s/['\"]$//")"
    if [ -z "$USER_INPUT" ]; then
        TARGET_DIR="$DEFAULT_DIR"
    else
        TARGET_DIR="$USER_INPUT"
    fi
fi

# --- 2. 校验目录 ---------------------------------------------------------
# 手动做 ~ 展开:
#   命令行传参时 shell 已展开过 ~,但交互输入的内容是纯字符串,
#   ~ 不会被展开,必须自己处理,否则会去找一个字面名为 "~" 的目录。
# 只处理 "~" 和 "~/" 两种写法;~user 形式极少用到,故意不支持。
case "$TARGET_DIR" in
    "~")    TARGET_DIR="$HOME" ;;
    "~/"*)  TARGET_DIR="$HOME/${TARGET_DIR#\~/}" ;;
esac

# 分三步校验并给出各自的错误信息,而不是笼统报"路径无效":
# 用户最需要知道的是"到底哪一步不对",尤其是 -e 和 -d 的区别
# (指向一个文件而非目录时很容易被忽略)。
if [ ! -e "$TARGET_DIR" ]; then
    echo "" >&2
    echo "错误: 目录不存在: $TARGET_DIR" >&2
    exit 1
fi

if [ ! -d "$TARGET_DIR" ]; then
    echo "" >&2
    echo "错误: 不是目录: $TARGET_DIR" >&2
    exit 1
fi

if [ ! -r "$TARGET_DIR" ]; then
    echo "" >&2
    echo "错误: 目录不可读: $TARGET_DIR" >&2
    exit 1
fi

# 规范化为绝对路径。两个作用:
#   1) 把相对路径(./foo、../foo)转成绝对路径,后续日志里看到的是完整路径,
#      不会因为脚本中途 cd 或用户在别处执行而产生歧义;
#   2) 解析掉 .. 和符号链接,保证 find 遍历的起点就是用户真正想要的目录。
# 注意:这一步必须在 pwd 之前用 subshell 完成,不能直接 cd 到目标目录,
# 否则会改变脚本自身的工作目录(默认目录取的是 $PWD,且后面 all 的路径都受影响)。
TARGET_DIR="$(cd "$TARGET_DIR" 2>/dev/null && pwd)" || {
    echo "错误: 无法进入目录: $TARGET_DIR" >&2
    exit 1
}

echo ""
echo "=============================================="
echo "开始清理: $TARGET_DIR"
if [ "$DRY_RUN" -eq 1 ]; then
    echo "模式: 预览(不会真的删除文件)"
else
    echo "模式: 实际删除"
fi
echo "=============================================="
echo ""

# --- 3. 逐个目录扫描并清理 ----------------------------------------------
# 统计项说明:
#   SCANNED_DIRS  实际遍历到的目录数(用于确认遍历范围是否符合预期)
#   FOUND_COUNT   发现的 .DS_Store 数量
#   DELETED_COUNT 成功删除数
#   FAILED_COUNT  删除失败数(通常是权限不足)
#   FREED_BYTES   累计释放字节数,最后换算成人类可读单位
SCANNED_DIRS=0
FOUND_COUNT=0
DELETED_COUNT=0
FAILED_COUNT=0
FREED_BYTES=0

# 记录失败明细,最后统一展示(避免刷屏时错过中间某行失败)
FAILED_LIST=""

# 为什么要这么写循环?逐条说明:
#   find -print0 + read -d '':用 NUL 分隔路径。若用默认的换行分隔,
#     含空格或换行的目录名会被拆成多条,导致 rm 删错目标。
#   IFS= :置空以保留路径首尾的空格(默认 IFS 会吃掉开头空白)。
#   -r :禁止反斜杠转义,否则含 \ 的路径会被曲解。
#   < <(...) 进程替换而非管道:管道会让 while 在子 shell 中运行,
#     循环里累加的统计变量出了循环就丢了,最后汇总全是 0。
#   只查目录(-type d)再拼 /DS_Store,而不是直接 find -name .DS_Store:
#     这样 SCANNED_DIRS 才能反映真实遍历范围,便于核对。
while IFS= read -r -d '' DIR; do
    SCANNED_DIRS=$((SCANNED_DIRS + 1))

    DS_FILE="$DIR/.DS_Store"
    # 用 [ -f ] 而非 [ -e ]:只处理普通文件。
    # -e 会把目录或悬空符号链接也算进来,rm -f 删不掉还会被计成失败。
    [ -f "$DS_FILE" ] || continue

    FOUND_COUNT=$((FOUND_COUNT + 1))

    # 取文件大小,失败时按 0 计(例如文件恰好被并行删除),
    # 不让 stat 的报错污染输出。
    SIZE=$(stat -f%z "$DS_FILE" 2>/dev/null || echo 0)

    if [ "$DRY_RUN" -eq 1 ]; then
        FREED_BYTES=$((FREED_BYTES + SIZE))
        echo "[待删除] $DS_FILE ($(printf '%s' "$SIZE") 字节)"
        continue
    fi

    # rm -f 静默掉报错,失败原因统一在下面提示,避免同一个错误打印两遍。
    if rm -f "$DS_FILE" 2>/dev/null; then
        DELETED_COUNT=$((DELETED_COUNT + 1))
        FREED_BYTES=$((FREED_BYTES + SIZE))
        echo "[已删除] $DS_FILE"
    else
        # 单次失败不中断整体清理,记下来最后汇总。
        FAILED_COUNT=$((FAILED_COUNT + 1))
        FAILED_LIST="${FAILED_LIST}  - ${DS_FILE}
"
        # 失败信息走 stderr,与正常流水区分开
        echo "[失败  ] $DS_FILE  (需要写权限?)" >&2
    fi
done < <(find "$TARGET_DIR" -type d -print0 2>/dev/null)

# --- 4. 汇总统计 ---------------------------------------------------------
# 字节数换算成人类可读单位。
# 用 awk 而不是 bash 算术:bash 只支持整数运算,做不了 1024 的反复除法和小数保留。
# 用 1024 进制(而非 1000)是磁盘大小的惯例,与 Finder 显示的口径一致。
human_size() {
    awk -v b="$1" 'BEGIN {
        split("B KB MB GB TB", u, " ");
        i = 1;
        while (b >= 1024 && i < 5) { b /= 1024; i++ };
        printf "%.2f %s", b, u[i];
    }'
}

echo ""
echo "=============================================="
echo "清理完成 - 汇总统计"
echo "=============================================="
# 预览模式与实际删除模式展示的统计项不同:
# 预览时 DELETED_COUNT 必然为 0,显示"成功删除: 0"会误导用户以为没扫到东西,
# 因此改显示"预计删除"。释放空间在两种模式下含义一致(都是这些文件的总大小)。
printf "扫描目录数    : %d\n" "$SCANNED_DIRS"
printf "发现文件数    : %d\n" "$FOUND_COUNT"
if [ "$DRY_RUN" -eq 1 ]; then
    printf "预计删除      : %d\n" "$FOUND_COUNT"
else
    printf "成功删除      : %d\n" "$DELETED_COUNT"
    printf "删除失败      : %d\n" "$FAILED_COUNT"
fi
printf "释放空间      : %s\n" "$(human_size "$FREED_BYTES")"

if [ -n "$FAILED_LIST" ]; then
    echo ""
    echo "以下文件删除失败:"
    printf '%s' "$FAILED_LIST"
fi

echo "=============================================="

if [ "$DRY_RUN" -eq 1 ]; then
    echo "提示: 这是预览模式,未删除任何文件。去掉 -n 即可实际执行。"
elif [ "$FOUND_COUNT" -eq 0 ]; then
    echo "目录很干净,没有发现 .DS_Store 文件。"
else
    echo "清理完毕。"
fi

# 退出码约定:
#   0 ------ 正常结束(包括"没有发现任何文件"和"部分失败但至少成功了一些")
#   2 ------ 发现了文件却一个都没删掉,几乎必定是权限问题,
#        这种情况值得让调用方感知到,故用非 0 退出。
# 部分失败仍返回 0:清理 .DS_Store 属于尽力而为的维护操作,
# 个别删不掉不应让整个脚本调用链(如 CI、批量循环)中断。
if [ "$FAILED_COUNT" -gt 0 ] && [ "$DELETED_COUNT" -eq 0 ]; then
    exit 2
fi
exit 0
相关推荐
Hello_Pyhx6 小时前
CrossPaste 跨系统剪贴板配置:按发现、配对、同步逐层排查
macos·电脑
king13598745501 天前
LFM2.5-24B-A2B Mac 本地部署:内存估算、GGUF/MLX 与 Python 预检脚本
人工智能·macos
骑着蜗牛撵大象3271 天前
链路层不只是 MAC:以太网帧、ARP 与 802.1Q VLAN 的逐跳拆解
计算机网络·macos·ar·vlan·mac地址·链路层
一条泥憨鱼1 天前
【计算机网络】| 从 MAC 到 NAT 的跨网段通信全链路
服务器·网络·学习·计算机网络·macos
老牛日常随笔2 天前
Meta 开源 8160 亿参数模型,官方给的方案是两台 Mac Studio
macos·开源
桌管小李3 天前
内网终端安全再升级|迪康端点安全 2609 版本四大新功能解读
大数据·windows·安全·macos·网络安全
情 九3 天前
Dify 本地环境搭建教程:macOS / Windows
windows·macos
2501_915918414 天前
在 Xcode 中运行和调试单元测试的完整指南
ide·vscode·macos·ios·单元测试·个人开发·xcode
Lalolander4 天前
灵基Lingee怎么下载安装?Mac 与 Win 详细指南
大数据·人工智能·科技·macos·金蝶灵基