清理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