diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6bcec82..86179cb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,6 +24,12 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
- gh release create "${GITHUB_REF_NAME}" nfd2nfc-quick-action.zip \
- --title "${GITHUB_REF_NAME}" --generate-notes \
- || gh release upload "${GITHUB_REF_NAME}" nfd2nfc-quick-action.zip --clobber
+ set -euo pipefail
+ # 릴리스 존재 여부로 분기. `create || upload`는 create의 진짜 실패(권한·네트워크)를
+ # upload 실패로 가려 디버깅을 어렵게 하므로, 존재할 때만(재실행/재태그) 자산을 갱신한다.
+ if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then
+ gh release upload "${GITHUB_REF_NAME}" nfd2nfc-quick-action.zip --clobber
+ else
+ gh release create "${GITHUB_REF_NAME}" nfd2nfc-quick-action.zip \
+ --title "${GITHUB_REF_NAME}" --generate-notes
+ fi
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3271400..4b5444b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,35 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/),
버전은 [Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다.
+## [Unreleased]
+
+### Added
+- `-q`/`--quiet` — 요약 출력을 생략하는 조용한 모드(경고·에러는 stderr 유지). cron·스크립트용.
+- `-f`/`--force` — 이름 충돌 시 덮어쓰기(기본은 안전하게 건너뜀). `--skip`은 기본값의 명시적 별칭.
+- Quick Action에 `--notify` 추가 — 우클릭 정리 후 완료 토스트(변경/0변경 모두 피드백) + Finder reveal 병행.
+- Homebrew formula `caveats` — CLI만 설치되며 Finder 우클릭 메뉴는 Releases에서 별도 설치임을 안내.
+- 단문자 옵션 묶음 지원(`-nv` == `-n -v`) — `Getopt::Long`의 `bundling`.
+- 존재하지 않는 입력 경로에 대한 경고(오타 등 조용한 무시 방지).
+- `NFD2NFC_NO_GUI=1` 환경변수 — `--notify`/`--reveal`의 osascript 호출을 건너뜀(테스트·CI용).
+- 통합 테스트: 종료 코드, inode 자기오인 방지(1.0.0 회귀 가드), 옵션 묶음, 중복 입력 dedup, `--quiet`, `--force` 케이스.
+
+### Changed
+- 변환 실패·충돌 스킵·존재하지 않는 입력이 있으면 **종료 코드 1**로 종료(이전엔 항상 0이라 자동화가 실패를 감지 못함). Quick Action은 영향 없음.
+- `install.sh`: CLI 설치 위치를 **이미 PATH에 있는 쓰기 가능 표준 위치**(Apple Silicon `/opt/homebrew/bin`, Intel `/usr/local/bin`) 우선 선택 — Apple Silicon에서 설치 직후 바로 실행되도록. PATH 미포함 시 안내 강화.
+- `install.sh`: Finder 새로고침을 `killall`(강제 종료) 대신 graceful quit으로 — 진행 중인 Finder 작업 보호.
+- `release.yml`: 릴리스 생성 폴백을 '이미 존재' 케이스로 한정(`create`의 진짜 실패가 `upload`로 가려지지 않게).
+
+### Fixed
+- 같은 경로를 중복 지정하면 변경 카운트가 부풀려지고 같은 파일을 두 번 rename 시도하던 문제(경로 중복 제거).
+- `test.sh`가 `--reveal` 포함 Quick Action 명령을 실행해 **실제 Finder를 띄우던 부작용** 제거(헤드리스 CI AppleEvent 타임아웃 위험 포함). `NFD2NFC_NO_GUI`로 차단.
+- `test.sh` `--no-recurse` 단언이 약해(트리 전체 `count>=1`) 옵션이 깨져도 통과하던 false pass — 폴더 자신 NFC/내부 미처리를 분리 단언.
+- `test.sh`가 리포 루트의 빌드 산출물(`.workflow.zip`)을 매 실행 덮어쓰던 문제 — 함수 모드로 임시 디렉토리에 빌드.
+- `build-workflow.sh`: heredoc 종료 sentinel이 스크립트 본문에 출현하면 임베드가 조용히 깨지던 취약점 — 사전 검사(`grep -qxF`)로 차단.
+- `build-workflow.sh`: zsh에서 `. ./build-workflow.sh`로 source 시 직접실행 가드가 오발해 의도치 않게 zip 빌드+번들 삭제하던 문제 — `BASH_SOURCE`/`ZSH_EVAL_CONTEXT` 기반 판별.
+- Quick Action 명령에 빈 선택 가드(`[ "$#" -gt 0 ] || exit 0`)와 종료코드 0 래핑 — Automator가 빈 입력/부분 실패를 오류로 표시하지 않게.
+- `install.sh` BIN_DIR 선택의 도달 불가능한 `elif` 죽은 코드 정리.
+- README 영문 사용법 한 줄에 누락됐던 `--reveal`/`-h` 추가.
+
## [1.0.1] - 2026-06-01
### Changed
diff --git a/README.md b/README.md
index b182b9e..799bb44 100644
--- a/README.md
+++ b/README.md
@@ -108,6 +108,9 @@ nfd2nfc -v 보고서.pdf 자료.xlsx # 여러 파일 + 변경 내역 출
| `--no-recurse` | 지정한 항목만 처리 (하위 폴더 안 들어감) |
| `--notify` | 완료 후 macOS 알림 |
| `--reveal` | 완료 후 Finder에서 결과 보여주기 |
+| `-q`, `--quiet` | 조용히 실행 (요약 출력 생략, 경고·에러만) |
+| `-f`, `--force` | 이름 충돌 시 덮어쓰기 (기본은 건너뜀) |
+| `--skip` | 이름 충돌 시 건너뛰기 (기본) |
| `-v`, `--verbose` | 변경 내역을 한 줄씩 출력 |
| `-V`, `--version` | 버전 출력 |
| `-h`, `--help` | 도움말 |
@@ -218,7 +221,7 @@ characters stay the same; only the underlying Unicode form is normalized.
|
CLI + Finder Quick Action | `git clone … && cd nfd2nfc && ./install.sh` |
```
-nfd2nfc [--dry-run] [--no-recurse] [--notify] [-v] [-V]
+nfd2nfc [--dry-run] [--no-recurse] [--notify] [--reveal] [-q] [-f] [-v] [-V] [-h]
```
Safe by design: already-NFC files are left untouched, clashes with genuinely different
diff --git a/build-workflow.sh b/build-workflow.sh
index e65886f..1f7e401 100755
--- a/build-workflow.sh
+++ b/build-workflow.sh
@@ -19,6 +19,14 @@ WORKFLOW_NAME="NFC로 이름 정리"
# 주어진 경로에 완전한 .workflow 번들을 만든다(스크립트 본문 임베드).
build_workflow_bundle() {
bundle="$1"
+
+ # 안전장치: heredoc 종료 sentinel(NFD2NFC_EOF)이 스크립트 본문에 한 줄로 출현하면
+ # 임베드된 heredoc이 거기서 조기 종료돼 Quick Action이 조용히 깨진다(plutil lint는 통과).
+ if grep -qxF 'NFD2NFC_EOF' "$SCRIPT_SRC"; then
+ echo "오류: $SCRIPT_SRC 에 'NFD2NFC_EOF' 줄이 있어 heredoc 임베드가 깨집니다. sentinel을 바꾸세요." >&2
+ return 1
+ fi
+
rm -rf "$bundle"
mkdir -p "$bundle/Contents"
@@ -32,9 +40,14 @@ build_workflow_bundle() {
# Quick Action이 실행할 셸 명령:
# perl이 스크립트를 stdin(heredoc)으로 읽고, 선택된 파일들을 인자("$@")로 받는다.
# 프로그램이 셸 명령줄이 아니라 heredoc 본문에 있으므로 셸 따옴표/이스케이프가 불필요.
- my $cmd = qq{/usr/bin/perl - --reveal -- "\$\@" <<'NFD2NFC_EOF'\n}
+ # - --notify: 완료 토스트(변경/0변경 모두 피드백), --reveal: Finder에 결과 표시.
+ # - 선택이 비면 정상 종료(빈 인자에 nfd2nfc가 exit 1 → Automator 실패 표시 방지).
+ # - heredoc 뒤 'exit 0': perl이 부분 실패로 비0을 내도 GUI 피드백을 쓰는
+ # Quick Action은 에러 다이얼로그를 띄우지 않도록 셸을 0으로 마친다.
+ my $cmd = qq{[ "\$#" -gt 0 ] || exit 0\n}
+ . qq{/usr/bin/perl - --reveal --notify -- "\$\@" <<'NFD2NFC_EOF'\n}
. $body
- . qq{NFD2NFC_EOF\n};
+ . qq{NFD2NFC_EOF\nexit 0\n};
# plist 안에 들어가므로 XML 특수문자 이스케이프
my $esc = $cmd;
@@ -228,8 +241,20 @@ PLIST
GENPLIST
}
-# 직접 실행되면: 저장소에 .workflow 빌드 후 zip 재생성
-if [ "${0##*/}" = "build-workflow.sh" ]; then
+# 직접 실행될 때만 저장소에 .workflow 빌드 후 zip 재생성한다.
+# source되면(install.sh가 함수 재사용 / 대화형 셸에서 함수만 사용) 이 블록을 건너뛴다.
+# sourced 판별: bash·sh는 BASH_SOURCE≠$0, zsh는 ZSH_EVAL_CONTEXT가 ':file'로 끝남.
+# (이전엔 $0 basename만 봐서 zsh `. ./build-workflow.sh` 시 오발 → 의도치 않은 빌드+삭제가 일어났다.)
+bw_sourced=1
+# shellcheck disable=SC3028,SC2128
+if [ -n "${BASH_SOURCE:-}" ]; then
+ [ "${BASH_SOURCE}" = "$0" ] && bw_sourced=0
+elif [ -n "${ZSH_EVAL_CONTEXT:-}" ]; then
+ case "$ZSH_EVAL_CONTEXT" in *:file) bw_sourced=1 ;; *) bw_sourced=0 ;; esac
+else
+ [ "${0##*/}" = "build-workflow.sh" ] && bw_sourced=0
+fi
+if [ "$bw_sourced" = 0 ]; then
BUNDLE="$HERE/$WORKFLOW_NAME.workflow"
build_workflow_bundle "$BUNDLE"
# plist 유효성 검사
diff --git a/install.sh b/install.sh
index 209e5af..84ee8b2 100755
--- a/install.sh
+++ b/install.sh
@@ -30,32 +30,47 @@ mkdir -p "$SERVICES_DIR"
build_workflow_bundle "$SERVICES_DIR/$WORKFLOW_NAME.workflow"
echo " ✓ Finder 우클릭 메뉴 설치: $SERVICES_DIR/$WORKFLOW_NAME.workflow"
-# Quick Action이 메뉴에 즉시 보이도록 서비스 캐시 갱신 + Finder 재시작(실패해도 무방).
-# Finder를 재시작하지 않으면 새 빠른 동작이 우클릭 메뉴에 바로 안 뜬다.
+# Quick Action이 메뉴에 즉시 보이도록 서비스 캐시 갱신 + Finder 새로고침(실패해도 무방).
+# Finder를 새로고침하지 않으면 새 빠른 동작이 우클릭 메뉴에 바로 안 뜬다.
/System/Library/CoreServices/pbs -update >/dev/null 2>&1 || true
/System/Library/CoreServices/pbs -flush >/dev/null 2>&1 || true
-killall Finder >/dev/null 2>&1 || true # 메뉴 갱신용 (Finder가 즉시 다시 뜸)
+# killall(강제 종료)은 진행 중인 Finder 복사/이동/이름변경을 끊을 수 있어, AppleEvent로
+# graceful하게 재시작을 요청한다(진행 중 작업이 있으면 Finder가 거부하므로 더 안전).
+osascript -e 'tell application "Finder" to quit' >/dev/null 2>&1 || true
+sleep 1
+open -a Finder >/dev/null 2>&1 || true
# ── 2) CLI 설치 ────────────────────────────────────────────────────────────
-# /usr/local/bin 이 쓰기 가능하면 거기에, 아니면 ~/.local/bin 에 설치(둘 다 sudo 불필요).
-if [ -w /usr/local/bin ] 2>/dev/null; then
- BIN_DIR=/usr/local/bin
-elif [ -d /usr/local/bin ] && mkdir -p /usr/local/bin 2>/dev/null && [ -w /usr/local/bin ]; then
- BIN_DIR=/usr/local/bin
-else
- BIN_DIR="$HOME/.local/bin"
+# CLI를 sudo 없이 설치할 bin 디렉토리 선택.
+# 1순위: 이미 PATH에 있으면서 쓰기 가능한 표준 위치 → 그래야 설치 직후 바로 'nfd2nfc'가 잡힌다.
+# (Apple Silicon Homebrew=/opt/homebrew/bin, Intel Homebrew=/usr/local/bin)
+# 2순위: 쓰기 가능한 표준 위치(아직 PATH에 없을 수 있음).
+# 3순위: ~/.local/bin (항상 쓰기 가능하나 기본 PATH엔 없을 수 있어 아래에서 안내).
+BIN_DIR=""
+for d in /opt/homebrew/bin /usr/local/bin; do
+ [ -d "$d" ] && [ -w "$d" ] || continue
+ case ":$PATH:" in *":$d:"*) BIN_DIR="$d"; break ;; esac
+done
+if [ -z "$BIN_DIR" ]; then
+ for d in /opt/homebrew/bin /usr/local/bin; do
+ if [ -d "$d" ] && [ -w "$d" ]; then BIN_DIR="$d"; break; fi
+ done
fi
+[ -z "$BIN_DIR" ] && BIN_DIR="$HOME/.local/bin"
mkdir -p "$BIN_DIR"
install -m 0755 "$SCRIPT_SRC" "$BIN_DIR/nfd2nfc"
echo " ✓ CLI 설치: $BIN_DIR/nfd2nfc"
# PATH 안내
case ":$PATH:" in
- *":$BIN_DIR:"*) ;; # 이미 PATH에 있음
+ *":$BIN_DIR:"*) ;; # 이미 PATH에 있음 → 새 터미널에서 바로 실행 가능
*)
echo
- echo " ⚠ $BIN_DIR 가 PATH에 없습니다. 아래 줄을 ~/.zshrc 에 추가하세요:"
- echo " export PATH=\"$BIN_DIR:\$PATH\""
+ echo " ⚠ $BIN_DIR 가 PATH에 없어, 'nfd2nfc' 명령이 바로 안 잡힙니다."
+ echo " • 지금 이 터미널에서 바로 쓰려면:"
+ echo " export PATH=\"$BIN_DIR:\$PATH\""
+ echo " • 새 터미널에도 적용하려면 위 줄을 ~/.zshrc(쓰는 셸의 rc)에 추가."
+ echo " • Finder 우클릭 '빠른 동작'만 쓸 거면 이 경고는 무시해도 됩니다."
;;
esac
diff --git a/nfd2nfc b/nfd2nfc
index 72bb25c..cb1eb6f 100755
--- a/nfd2nfc
+++ b/nfd2nfc
@@ -15,9 +15,19 @@
# -n, --dry-run 실제로 바꾸지 않고 바뀔 내용만 미리보기
# --no-recurse 폴더 안쪽까지 들어가지 않고 지정한 항목만 처리
# --notify 완료 후 macOS 알림 표시
+# --reveal 완료 후 Finder에서 대상 보여주기
+# -q, --quiet 조용히 (요약 출력 생략, 경고·에러만)
+# -f, --force 이름 충돌 시 덮어쓰기 (기본은 건너뜀)
+# --skip 이름 충돌 시 건너뛰기 (기본)
# -v, --verbose 변경 내역을 한 줄씩 출력
# -V, --version 버전 출력
# -h, --help 도움말
+# (단문자 옵션은 묶어서 쓸 수 있음: 예) -nv == -n -v)
+#
+# 환경변수:
+# NFD2NFC_NO_GUI=1 --notify/--reveal의 osascript 호출을 건너뜀(테스트·CI용)
+# 종료 코드:
+# 0 정상, 1 인자 오류 또는 변환 실패/충돌 스킵/존재하지 않는 입력이 있을 때
use strict;
use warnings;
@@ -27,15 +37,20 @@ use Getopt::Long qw(GetOptions);
our $VERSION = "1.0.1";
-my ($dry, $recurse, $notify, $verbose, $help, $version, $reveal) = (0, 1, 0, 0, 0, 0, 0);
+my ($dry, $recurse, $notify, $verbose, $help, $version, $reveal, $quiet, $force, $skip)
+ = (0, 1, 0, 0, 0, 0, 0, 0, 0, 0);
# 대소문자 구분: -v(verbose)와 -V(version)가 섞이지 않도록.
# (Getopt::Long은 기본적으로 대소문자를 무시해 -v/-V를 같은 옵션으로 취급한다)
-Getopt::Long::Configure('no_ignore_case');
+# bundling: 단문자 옵션을 묶어서 쓸 수 있게(-nv == -n -v). 긴 옵션은 --로 시작하므로 안전.
+Getopt::Long::Configure('no_ignore_case', 'bundling');
GetOptions(
'dry-run|n' => \$dry,
'recurse!' => \$recurse,
'notify' => \$notify,
'reveal' => \$reveal,
+ 'quiet|q' => \$quiet,
+ 'force|f' => \$force, # 이름 충돌 시 덮어쓰기(데이터 손실 가능)
+ 'skip' => \$skip, # 이름 충돌 시 건너뛰기(기본) — 명시용 별칭
'verbose|v' => \$verbose,
'version|V' => \$version,
'help|h' => \$help,
@@ -45,6 +60,9 @@ if ($version) { print "nfd2nfc $VERSION\n"; exit 0; }
usage(0) if $help;
usage(1) unless @ARGV;
+# quiet는 verbose보다 우선(둘 다 주면 조용히). --force가 --skip(기본)을 이긴다.
+$verbose = 0 if $quiet;
+
# 사용자가 선택한 최상위 항목(옵션 제거 후 남은 인자) — 완료 후 Finder에 보여줄 대상
my @inputs = @ARGV;
@@ -60,6 +78,9 @@ nfd2nfc — macOS 한글 파일명 NFD→NFC 변환기
--no-recurse 지정한 항목만 처리 (하위 폴더 제외)
--notify 완료 후 macOS 알림
--reveal 완료 후 Finder에서 대상 보여주기
+ -q, --quiet 조용히 (요약 출력 생략, 경고·에러만 표시)
+ -f, --force 이름 충돌 시 덮어쓰기 (기본은 건너뜀, 데이터 손실 주의)
+ --skip 이름 충돌 시 건너뛰기 (기본)
-v, --verbose 변경 내역 출력
-V, --version 버전 출력
-h, --help 이 도움말
@@ -69,10 +90,20 @@ TXT
# 1) 대상 경로 수집 (바이트 문자열 그대로 다룸 = 파일시스템 안전)
my @targets;
+my %seen; # 같은 경로 중복 수집 방지(카운트 부풀림·이중 rename 방지)
+my $missing = 0; # 존재하지 않는 최상위 입력 개수(종료 코드에 반영)
sub collect {
- my ($path) = @_;
+ my ($path, $toplevel) = @_;
$path =~ s{/+$}{}; # 끝 슬래시 제거
return if $path eq '';
+ # 사용자가 직접 지정한 최상위 경로가 없으면 알린다(오타 등 조용한 무시 방지).
+ # APFS는 정규화 비구분이라 NFD로 준 경로도 -e가 잡지만, 정말 없는 경로는 걸러진다.
+ if ($toplevel && !-e $path && !-l $path) {
+ warn "경고: 경로가 존재하지 않음: $path\n";
+ $missing++;
+ return;
+ }
+ return if $seen{$path}++; # 이미 수집한 경로는 건너뜀
push @targets, $path;
if ($recurse && -d $path && !-l $path) {
if (opendir(my $dh, $path)) {
@@ -87,7 +118,7 @@ sub collect {
}
}
}
-collect($_) for @ARGV;
+collect($_, 1) for @ARGV;
# 2) 깊은 경로부터 처리(자식 → 부모) 해야 폴더 이름을 바꿔도 경로가 안 깨짐
my @sorted = sort { ($b =~ tr{/}{}) <=> ($a =~ tr{/}{}) } @targets;
@@ -113,16 +144,17 @@ for my $p (@sorted) {
# NFD로 저장된 파일도 NFC 이름으로 -e/lstat 검사하면 "존재"로 잡힌다.
# 즉 단순 -e 검사는 자기 자신을 충돌로 오인해 전부 건너뛴다.
# 따라서 '진짜 다른 파일'(다른 inode)이 그 이름을 차지한 경우에만 건너뛴다.
+ # --force면 충돌 검사를 우회하고 rename을 강행(기존 파일을 덮어씀).
my @cur = lstat($p);
my @tgt = lstat($new);
- if (@tgt && (!@cur || $tgt[0] != $cur[0] || $tgt[1] != $cur[1])) {
+ if (!$force && @tgt && (!@cur || $tgt[0] != $cur[0] || $tgt[1] != $cur[1])) {
warn "건너뜀(같은 이름의 다른 항목 존재): $p\n";
$skipped++;
next;
}
if ($dry) {
- print "[미리보기] $p → $new\n";
+ print "[미리보기] $p → $new\n" unless $quiet;
$changed++;
} elsif (rename($p, $new)) {
print "변경: $p → $new\n" if $verbose;
@@ -138,9 +170,12 @@ my $summary = $dry
? "미리보기: ${changed}개 변경 예정"
: "완료: ${changed}개 변경"
. ($skipped ? ", ${skipped}개 건너뜀" : "");
-print "$summary\n";
+print "$summary\n" unless $quiet;
-if ($notify && !$dry) {
+# NFD2NFC_NO_GUI=1이면 osascript(알림·Finder) 호출을 건너뛴다.
+# 테스트·CI(헤드리스)에서 Finder/알림 부작용·AppleEvent 타임아웃을 피하기 위함.
+my $no_gui = $ENV{NFD2NFC_NO_GUI};
+if ($notify && !$dry && !$no_gui) {
my $msg = $summary;
$msg =~ s/"/\\"/g;
system('/usr/bin/osascript', '-e',
@@ -151,7 +186,7 @@ if ($notify && !$dry) {
# 선택한 최상위 항목들의 '최종(NFC) 경로'를 Finder에서 보여준다.
# - 폴더 하나만 선택했으면 그 폴더를 열어 정리된 내용을 바로 보여줌
# - 그 외엔 해당 항목들을 선택 상태로 표시(reveal)
-if ($reveal && !$dry) {
+if ($reveal && !$dry && !$no_gui) {
my @final;
for my $arg (@inputs) {
(my $a = $arg) =~ s{/+$}{};
@@ -169,6 +204,11 @@ if ($reveal && !$dry) {
reveal_in_finder(@final) if @final;
}
+# 변환 실패·충돌 스킵·존재하지 않는 입력이 하나라도 있으면 비0으로 종료한다.
+# Quick Action은 exit code를 무시(build-workflow.sh가 exit 0으로 감쌈)하므로 GUI엔 영향 없고,
+# CLI/CI 호출 측은 $?로 부분 실패를 감지할 수 있다.
+exit(($skipped || $missing) ? 1 : 0);
+
# 주어진 절대경로들을 Finder에서 보여준다(폴더 1개면 열기, 그 외엔 선택).
sub reveal_in_finder {
my @paths = @_;
diff --git a/nfd2nfc.rb b/nfd2nfc.rb
index a6fe6e5..da90905 100644
--- a/nfd2nfc.rb
+++ b/nfd2nfc.rb
@@ -9,6 +9,15 @@ def install
bin.install "nfd2nfc"
end
+ def caveats
+ <<~EOS
+ CLI(`nfd2nfc`)만 설치됩니다.
+ Finder 우클릭 메뉴(빠른 동작 "NFC로 이름 정리")가 필요하면
+ Releases에서 nfd2nfc-quick-action.zip을 받아 설치하세요:
+ https://github.com/wonjun-lab/nfd2nfc/releases/latest
+ EOS
+ end
+
test do
assert_match "nfd2nfc", shell_output("#{bin}/nfd2nfc --version")
end
diff --git a/test.sh b/test.sh
index 06a796f..2195116 100755
--- a/test.sh
+++ b/test.sh
@@ -8,10 +8,15 @@ NFD2NFC="$HERE/nfd2nfc"
PASS=0
FAIL=0
+# 테스트 중 osascript(Finder/알림) 부작용·AppleEvent 타임아웃을 차단한다.
+# (Quick Action 명령은 항상 --reveal을 포함하므로 이 가드가 없으면 Finder가 튀어나오고
+# 헤드리스 CI에서는 AppleEvent가 120초 타임아웃 날 수 있다.)
+export NFD2NFC_NO_GUI=1
+
ok() { PASS=$((PASS + 1)); printf ' \033[32m✓\033[0m %s\n' "$1"; }
ng() { FAIL=$((FAIL + 1)); printf ' \033[31m✗\033[0m %s\n' "$1"; }
-# 디렉토리 아래 NFD로 남은 항목 수 출력
+# 디렉토리 아래 NFD로 남은 항목 수 출력(시작 디렉토리 자신은 제외)
count_nfd() {
/usr/bin/perl -CSDA -MFile::Find -e '
use Unicode::Normalize qw(NFC); use Encode qw(decode_utf8);
@@ -26,6 +31,30 @@ count_nfd() {
' "$1"
}
+# 단일 경로의 basename이 NFC면 exit 0, 아니면 exit 1 (디렉토리 비재귀)
+is_nfc_base() {
+ /usr/bin/perl -e '
+ use Unicode::Normalize qw(NFC); use Encode qw(decode_utf8);
+ my $p = $ARGV[0]; $p =~ s{/+$}{};
+ my $i = rindex($p, "/"); my $b = $i < 0 ? $p : substr($p, $i + 1);
+ my $u = eval { my $c = $b; decode_utf8($c, Encode::FB_CROAK) };
+ exit((defined $u && NFC($u) eq $u) ? 0 : 1);
+ ' "$1"
+}
+
+# t10 등에서 최상위 첫 (디렉토리|파일) 항목 경로를 출력
+first_entry() { # first_entry
+ /usr/bin/perl -e '
+ opendir(my $d, $ARGV[0]) or exit 0;
+ for (sort readdir $d) {
+ next if /^\.\.?$/;
+ my $p = "$ARGV[0]/$_";
+ if ($ARGV[1] eq "d") { next unless -d $p } else { next unless -f $p }
+ print $p; last;
+ }
+ ' "$1" "$2"
+}
+
# 표준 NFD 픽스처: /보고서.hwp, /하위폴더/사진.jpg
make_fixture() {
base="$1"
@@ -58,11 +87,24 @@ make_fixture "$TMP/t3"
/usr/bin/perl "$NFD2NFC" --dry-run "$TMP/t3" >/dev/null 2>&1
if [ "$(count_nfd "$TMP/t3")" -eq 3 ]; then ok "dry-run은 실제 변경 안 함"; else ng "dry-run이 파일을 변경함"; fi
-# [4] --no-recurse: 지정 폴더만, 하위는 유지
+# [4] --no-recurse: 지정한 폴더 자신은 NFC로, 그 내부(사진.jpg)는 미처리로 남는다.
+# 약한 단언(트리 전체 count>=1)은 옵션이 깨져도 통과하므로, 두 측면을 분리 단언한다.
make_fixture "$TMP/t4"
-sub=$(/usr/bin/perl -e 'opendir(my$d,$ARGV[0]);for(readdir$d){next if/^\.\.?$/;next unless -d "$ARGV[0]/$_";print "$ARGV[0]/$_";last}' "$TMP/t4")
-/usr/bin/perl "$NFD2NFC" --no-recurse "$sub" >/dev/null 2>&1
-if [ "$(count_nfd "$TMP/t4")" -ge 1 ]; then ok "--no-recurse는 하위 미처리"; else ng "--no-recurse가 하위까지 처리함"; fi
+sub=$(first_entry "$TMP/t4" d)
+if [ -z "$sub" ] || [ ! -d "$sub" ]; then
+ ng "[4] 픽스처 하위폴더 탐색 실패(빈 경로)"
+else
+ /usr/bin/perl "$NFD2NFC" --no-recurse "$sub" >/dev/null 2>&1
+ # 변환 후 하위폴더는 NFC명으로 바뀌므로 부모에서 다시 찾는다.
+ subnfc=$(first_entry "$TMP/t4" d)
+ inside_nfd=$(count_nfd "$subnfc")
+ if is_nfc_base "$subnfc"; then base_ok=1; else base_ok=0; fi
+ if [ "$base_ok" -eq 1 ] && [ "$inside_nfd" -eq 1 ]; then
+ ok "--no-recurse: 폴더 자신만 NFC, 내부(사진.jpg) 미처리"
+ else
+ ng "--no-recurse 이상: 폴더basename NFC=$base_ok, 내부 NFD수=$inside_nfd (기대 1,1)"
+ fi
+fi
# [5] 심볼릭 링크 미추적
mkdir -p "$TMP/t5"
@@ -76,17 +118,60 @@ mkdir -p "$TMP/t5"
link_count=$(/usr/bin/perl -e 'my$n=0;opendir(my$d,$ARGV[0]);for(readdir$d){next if/^\.\.?$/;$n++ if -l "$ARGV[0]/$_"}print$n' "$TMP/t5")
if [ "$link_count" -eq 1 ] && [ "$(count_nfd "$TMP/t5")" -eq 0 ]; then ok "심볼릭 링크 미추적 + 이름만 정규화"; else ng "심볼릭 링크 처리 이상"; fi
-# [6] 생성된 Quick Action 명령 실행 (Automator 임시파일 방식)
-"$HERE/build-workflow.sh" >/dev/null 2>&1
-unzip -oq "$HERE/NFC로 이름 정리.workflow.zip" -d "$TMP/wf"
-DOC=$(find "$TMP/wf" -name document.wflow)
-/usr/bin/plutil -extract "actions.0.action.ActionParameters.COMMAND_STRING" raw -o "$TMP/qa_cmd.sh" "$DOC"
-make_fixture "$TMP/t6"
-/usr/bin/perl -e '
- my @a; opendir(my $d, $ARGV[1]); for (readdir $d) { next if /^\.\.?$/; push @a, "$ARGV[1]/$_" } closedir $d;
- system("/bin/zsh", $ARGV[0], @a);
-' "$TMP/qa_cmd.sh" "$TMP/t6"
-if [ "$(count_nfd "$TMP/t6")" -eq 0 ]; then ok "Quick Action 명령 실행 변환 성공"; else ng "Quick Action 명령 변환 실패"; fi
+# [6] 생성된 Quick Action 명령 실행. 리포 루트 산출물을 건드리지 않게 함수 모드로 $TMP에 빌드한다.
+# shellcheck source=build-workflow.sh
+( . "$HERE/build-workflow.sh"; build_workflow_bundle "$TMP/qa.workflow" ) >/dev/null 2>&1
+DOC="$TMP/qa.workflow/Contents/document.wflow"
+if [ ! -f "$DOC" ]; then
+ ng "[6] Quick Action 빌드 실패(document.wflow 없음)"
+else
+ /usr/bin/plutil -extract "actions.0.action.ActionParameters.COMMAND_STRING" raw -o "$TMP/qa_cmd.sh" "$DOC"
+ make_fixture "$TMP/t6"
+ /usr/bin/perl -e '
+ my @a; opendir(my $d, $ARGV[1]); for (readdir $d) { next if /^\.\.?$/; push @a, "$ARGV[1]/$_" } closedir $d;
+ system("/bin/zsh", $ARGV[0], @a);
+ ' "$TMP/qa_cmd.sh" "$TMP/t6"
+ if [ "$(count_nfd "$TMP/t6")" -eq 0 ]; then ok "Quick Action 명령 실행 변환 성공"; else ng "Quick Action 명령 변환 실패"; fi
+fi
+
+# [7] 종료 코드: 정상 변환은 0, 존재하지 않는 입력은 비0(자동화가 부분 실패를 감지 가능)
+make_fixture "$TMP/t7"
+/usr/bin/perl "$NFD2NFC" "$TMP/t7" >/dev/null 2>&1; rc_ok=$?
+/usr/bin/perl "$NFD2NFC" "$TMP/없는경로_xyz" >/dev/null 2>&1; rc_missing=$?
+if [ "$rc_ok" -eq 0 ] && [ "$rc_missing" -ne 0 ]; then ok "종료 코드: 정상 0 / 실패 비0"; else ng "종료 코드 이상: 정상=$rc_ok 실패=$rc_missing"; fi
+
+# [8] inode 비교가 자기 자신을 충돌로 오인하지 않음 (1.0.0 핵심 버그의 회귀 가드).
+# '진짜 다른 inode가 NFC명을 점유'한 충돌은 APFS 정규화 비구분 특성상 단일 볼륨에서
+# 재현 불가하므로, 그 반대(자기 자신 오인으로 전부 건너뛰는 회귀)를 막는다.
+make_fixture "$TMP/t8"
+err=$(/usr/bin/perl "$NFD2NFC" "$TMP/t8" 2>&1 >/dev/null)
+if [ "$(count_nfd "$TMP/t8")" -eq 0 ] && ! printf '%s' "$err" | grep -q "건너뜀"; then
+ ok "inode 자기오인 없음(전부 변환, 건너뜀 경고 0)"
+else
+ ng "inode 자기오인 의심(건너뜀 경고: $err)"
+fi
+
+# [9] 단문자 옵션 묶음(-nv == -n -v): dry-run+verbose로 동작하되 실제 변경은 없어야 함
+make_fixture "$TMP/t9"
+out=$(/usr/bin/perl "$NFD2NFC" -nv "$TMP/t9" 2>&1)
+if echo "$out" | grep -q "미리보기" && [ "$(count_nfd "$TMP/t9")" -eq 3 ]; then ok "-nv 묶음(dry-run+verbose)"; else ng "-nv 묶음 이상: $out"; fi
+
+# [10] 중복 입력은 한 번만 처리(카운트 부풀림·이중 rename 없음)
+make_fixture "$TMP/t10"
+f=$(first_entry "$TMP/t10" f)
+out=$(/usr/bin/perl "$NFD2NFC" --no-recurse "$f" "$f" 2>&1)
+if echo "$out" | grep -q "1개 변경"; then ok "중복 입력 dedup(1개 변경)"; else ng "중복 입력 dedup 실패: $out"; fi
+
+# [11] --quiet: 요약 출력을 억제(stdout 비움)하되 변환은 정상 수행
+make_fixture "$TMP/t11"
+qout=$(/usr/bin/perl "$NFD2NFC" --quiet "$TMP/t11" 2>/dev/null)
+if [ -z "$qout" ] && [ "$(count_nfd "$TMP/t11")" -eq 0 ]; then ok "--quiet: 무출력 + 변환 수행"; else ng "--quiet 이상(out=[$qout])"; fi
+
+# [12] --force: 충돌 검사를 우회해도 일반 변환을 정상 수행하고 건너뜀 경고가 없어야 함.
+# (진짜 다른 inode가 NFC명을 점유한 충돌은 APFS 정규화 비구분 특성상 단일 볼륨에서 재현 불가)
+make_fixture "$TMP/t12"
+ferr=$(/usr/bin/perl "$NFD2NFC" --force "$TMP/t12" 2>&1 >/dev/null)
+if [ "$(count_nfd "$TMP/t12")" -eq 0 ] && [ -z "$ferr" ]; then ok "--force: 정상 변환 + 경고 없음"; else ng "--force 이상(err=[$ferr])"; fi
# [버전] --version 출력 형식
ver_out=$(/usr/bin/perl "$NFD2NFC" --version 2>&1)