Windows zip 解压丢失 Unix 权限 → fix-perms.sh 补回
现象
从 Windows 压缩(zip)再贴到 macOS/Linux 解压的项目,文件权限会错乱:
- 目录可能变成
555(dr-xr-xr-x,owner 都没写权限)→ 没法在里面新建 / 删除文件、git checkout / merge 失败 - 文件 / 目录大面积 world-writable(
666/777) .git树同样会被搞坏:目录 555 / 文件 666,导致 git 命令全部失败
原因:Windows 的 zip 格式不记录 Unix 权限位,解压时由当前 umask + 实现猜测填充,结果就是乱的。从 SMB/NFS 挂载点(如 NAS)拷出来也会有类似问题(挂载点 umask/ACL 与本地不一致)。
实际遇到的两个仓:
- 某 Java/Maven 项目:从 Windows zip 贴过来,
src/整树目录变 555,Dockerfile/pom.xml/certs/src/main/resources/*全 666/777 - 某 SQL 脚本仓:
.git/整树 555/666(git 命令全跑不通)、PROD/SIT/UAT/三个环境目录 777、.idea/555
解法:fix-perms.sh
规范化:目录 755 / 普通文件 644,脚本类(.sh/.bash/.command 或带 #! shebang)755,统一去掉 group/other 写位。幂等,可反复跑。
只跳 node_modules 和 target(构建产物体量大且不关心权限);不跳 .git——Windows zip 同样会搞坏 .git,跳过反而留 bug。脚本规则(dir 755 / file 644 / shebang 755)正好就是标准 git 仓权限,一起处理是正确的。
用法:
./fix-perms.sh # 修脚本所在目录 (默认: 仓根, 不是 cwd!)
./fix-perms.sh <路径> # 修指定目录
DRY_RUN=1 ./fix-perms.sh # 只预览不改动脚本
#!/usr/bin/env bash
#
# fix-perms.sh — 修复从 Windows zip 解压后丢失的 Unix 权限位。
#
# 规则:
# - 目录 -> 755 (rwxr-xr-x)
# - 普通文件 -> 644 (rw-r--r--)
# - 脚本/可执行 -> 755 (扩展名 .sh/.bash/.command 或文件头是 #! shebang)
# - 一律去掉 group/other 的写位
#
set -euo pipefail
# 默认目标 = 脚本自己所在的目录, 不是 cwd.
# 这样从任何地方双击 / 调用都只动该仓, 不会误伤 ~ 或别的工作目录.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="${1:-$SCRIPT_DIR}"
[[ -d "$TARGET" ]] || { echo "错误:目录不存在 -> $TARGET" >&2; exit 1; }
# 跳过构建产物. 不跳 .git —— Windows zip 解压会把 .git 也搞坏, 必须一起修.
PRUNE=( -name node_modules -o -name target )
DRY_RUN="${DRY_RUN:-0}"
run() {
if [[ "$DRY_RUN" == "1" ]]; then echo "chmod $*"; else chmod "$@"; fi
}
# 进度条: \033[K 清行尾残留, 走 stderr 跟 DRY_RUN 的 stdout 输出分流.
bar() {
local label="$1" cur="$2" total="$3"
local pct=0
(( total > 0 )) && pct=$(( cur * 100 / total ))
printf '\r [%s] %d/%d (%d%%)\033[K' "$label" "$cur" "$total" "$pct" >&2
}
bar_done() {
local label="$1" cur="$2" total="$3"
printf '\r [%s] %d/%d (完成)\033[K\n' "$label" "$cur" "$total" >&2
}
echo "==> 修复权限: $TARGET (DRY_RUN=$DRY_RUN)"
# 预扫: 拿到目录/文件总数, 后面进度条才有分母.
printf ' 扫描中...' >&2
DIR_TOTAL=$(find "$TARGET" \( "${PRUNE[@]}" \) -prune -o -type d -print | wc -l | tr -d ' ')
FILE_TOTAL=$(find "$TARGET" \( "${PRUNE[@]}" \) -prune -o -type f -print | wc -l | tr -d ' ')
printf '\r 扫描完成: 目录 %d, 文件 %d\033[K\n' "$DIR_TOTAL" "$FILE_TOTAL" >&2
# 刷新频率自适应: 总量大时稀疏刷新省 IO, 总量小时密集刷新.
TICK=$(( (DIR_TOTAL + FILE_TOTAL) / 200 ))
(( TICK < 1 )) && TICK=1
# 1. 目录 -> 755
i=0
while IFS= read -r -d '' p; do
i=$((i+1))
run 755 "$p"
(( i % TICK == 0 )) && bar "1/4 目录 755" "$i" "$DIR_TOTAL"
done < <(find "$TARGET" \( "${PRUNE[@]}" \) -prune -o -type d -print0)
bar_done "1/4 目录 755" "$i" "$DIR_TOTAL"
# 2. 普通文件 -> 644
i=0
while IFS= read -r -d '' p; do
i=$((i+1))
run 644 "$p"
(( i % TICK == 0 )) && bar "2/4 文件 644" "$i" "$FILE_TOTAL"
done < <(find "$TARGET" \( "${PRUNE[@]}" \) -prune -o -type f -print0)
bar_done "2/4 文件 644" "$i" "$FILE_TOTAL"
# 3. 脚本扩展名 -> 755 (通常稀少, 只显示运行计数)
i=0
while IFS= read -r -d '' p; do
i=$((i+1))
run 755 "$p"
printf '\r [3/4 脚本扩展 755] %d\033[K' "$i" >&2
done < <(find "$TARGET" \( "${PRUNE[@]}" \) -prune -o -type f \
\( -name '*.sh' -o -name '*.bash' -o -name '*.command' \) -print0)
printf '\r [3/4 脚本扩展 755] %d (完成)\033[K\n' "$i" >&2
# 4. shebang -> 755 (要逐个读首行, 分母用 FILE_TOTAL)
scanned=0; matched=0
while IFS= read -r -d '' p; do
scanned=$((scanned+1))
if IFS= read -r line < "$p" 2>/dev/null && [[ "$line" == '#!'* ]]; then
matched=$((matched+1))
run 755 "$p"
fi
if (( scanned % TICK == 0 )); then
pct=0; (( FILE_TOTAL > 0 )) && pct=$(( scanned * 100 / FILE_TOTAL ))
printf '\r [4/4 shebang 755] 扫 %d/%d (%d%%) / 改 %d\033[K' \
"$scanned" "$FILE_TOTAL" "$pct" "$matched" >&2
fi
done < <(find "$TARGET" \( "${PRUNE[@]}" \) -prune -o -type f -print0)
printf '\r [4/4 shebang 755] 扫 %d/%d / 改 %d (完成)\033[K\n' \
"$scanned" "$FILE_TOTAL" "$matched" >&2
echo "==> 完成"三个改对了才稳的设计点
1. .git 不能 prune
最初版 PRUNE=( -name .git -o -name node_modules -o -name target ) 把 .git 整个跳过。但 Windows zip 同样会搞坏 .git/(目录 555 / 文件 666),跳过等于留 bug,导致脚本跑完 git 命令仍然全部失败。
改法:从 PRUNE 去掉 .git,只保留 node_modules 和 target。脚本规则(dir 755 / file 644 / shebang 755)正好就是标准 git 仓权限,一起处理是正确的。.git/hooks/*.sample 自带 shebang,也会被 pass 4 正确识别为 755。
2. 默认 TARGET 是 cwd 太危险
最初版 TARGET="${1:-.}"。从 Finder 双击或在 ~ 直接调用绝对路径(/path/to/fix-perms.sh; exit),target 会解析成 ~,脚本会开始刷整个家目录——~/.ssh/id_ed25519 这种 600 的私钥会被错改成 644,SSH 直接拒登。
改法:默认值改成脚本自己所在目录:
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="${1:-$SCRIPT_DIR}"从任何地方调用都只动脚本所在的仓,显式传路径还能覆盖。
3. 加进度条
最初版从头到尾只打两行(开头 ==> 修复权限、结尾 ==> 完成)。大仓跑起来看不见动静,容易以为卡死。
改法:
- 预扫:开跑前一次
find数目录、一次find数文件,先打印总数,后面进度条有分母 i/N (PCT%)原地刷新:\r回行首 +\033[K清行尾,避免短文本覆盖长文本时留尾巴- 自适应 TICK:
TICK = (目录+文件) / 200,不管仓多大都大概看到 200 次刷新——小仓密刷、大仓稀刷 find | while改成while ... done < <(find):原版 pipe 形式 while 跑在 subshell,循环外拿不到$i;process substitution 让 while 跑在主 shell,结尾才能打印真实计数- stderr / stdout 分流:进度条走 stderr,DRY_RUN 的
chmod ...列表走 stdout,DRY_RUN 时两者不互相覆盖
代价是多两次 find 用来数数(从 4 次变成 6 次),仓库规模(< 几万文件)感知不到。
快速诊断命令
# 找 world-writable 或 owner 不可写的目录(排除 target)
find . -path ./target -prune -o \( -perm -o+w -o -type d ! -perm -u+w \) -print
# 看某个 .git 树是不是被搞坏了
ls -ld .git && ls -l .git/HEAD
# 健康: drwxr-xr-x .git, -rw-r--r-- HEAD
# 坏: dr-xr-xr-x .git, -rw-rw-rw- HEAD预防
- 优先用能保留权限的归档格式:
tar(.tar.gz)、或 Windows 端用 7-Zip 但同样不保 Unix 位 → 还是 tar 最稳 - 跨 Windows 传纯文本 / 源码,走 git 而不是 zip
- 从 SMB/NFS 挂载拷代码到本地后,跑一遍
fix-perms.sh - 脚本放仓根并提交进版本控制,以后任何人 clone 下来都能直接
./fix-perms.sh自修