#!/usr/bin/env bash # # DJB 커스텀 소스 납품본 수집 스크립트 (macOS / Linux) # # 기준 커밋(컷오프 직전 마지막 커밋) 이후 "신규로 추가된" 소스 파일만 모아 # 모듈별 디렉터리 구조를 유지한 채 출력 디렉터리에 복사하고, # MANIFEST.csv / SUMMARY.md / (선택) zip 아카이브를 생성한다. # # 사용법: # ./export-custom.sh # 기본값으로 실행 # ./export-custom.sh -c 2026-05-01 --zip # ./export-custom.sh --dry-run # ./export-custom.sh -m eapim-portal -m eapim-admin # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONF_FILE="$SCRIPT_DIR/modules.conf" RULES_FILE="$SCRIPT_DIR/export-filter.rules" # ---- 기본값 --------------------------------------------------------------- CUTOFF="2026-05-01" REPO_ROOT="" OUT_DIR="" MAKE_ZIP=0 DRY_RUN=0 INCLUDE_UNTRACKED=0 ONLY_MODULES="" SHOW_EXCLUDED=0 usage() { cat <<'EOF' DJB 커스텀 소스 납품본 수집 옵션: -c, --cutoff 기준 날짜. 이 날짜 직전 마지막 커밋을 base로 삼는다. (기본: 2026-05-01) -r, --root 저장소 루트(djb-eapim). 미지정 시 스크립트 위치에서 자동 탐지. -o, --out 출력 디렉터리. (기본: /build/djb-custom-export) -m, --module 특정 모듈만 수집. 여러 번 지정 가능. --zip 수집 후 zip 아카이브 생성. --include-untracked git에 아직 커밋되지 않은 신규 파일도 포함. --dry-run 복사하지 않고 대상 목록만 stdout 으로 출력(로그는 stderr). --show-excluded --dry-run 과 함께. 규칙에 걸려 제외된 목록을 대신 출력. -h, --help 도움말. 포함/제외 규칙은 export-filter.rules (.gitignore 유사 문법) 에서 관리한다. EOF } while [ $# -gt 0 ]; do case "$1" in -c|--cutoff) CUTOFF="$2"; shift 2 ;; -r|--root) REPO_ROOT="$2"; shift 2 ;; -o|--out) OUT_DIR="$2"; shift 2 ;; -m|--module) ONLY_MODULES="$ONLY_MODULES $2"; shift 2 ;; --zip) MAKE_ZIP=1; shift ;; --include-untracked) INCLUDE_UNTRACKED=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --show-excluded) SHOW_EXCLUDED=1; DRY_RUN=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "알 수 없는 옵션: $1" >&2; usage >&2; exit 1 ;; esac done # ---- 저장소 루트 탐지 ----------------------------------------------------- # 스크립트는 /eapim-portal/script/djb-custom-export/ 에 있다. if [ -z "$REPO_ROOT" ]; then REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" fi if [ ! -d "$REPO_ROOT" ]; then echo "저장소 루트를 찾을 수 없다: $REPO_ROOT" >&2 exit 1 fi [ -z "$OUT_DIR" ] && OUT_DIR="$REPO_ROOT/build/djb-custom-export" if [ ! -f "$CONF_FILE" ]; then echo "설정 파일 없음: $CONF_FILE" >&2 exit 1 fi # ---- 설정 로드 ------------------------------------------------------------ ALLOW_EXT="" EXCLUDE_PAT="" MODULE_LINES="" while IFS= read -r line || [ -n "$line" ]; do case "$line" in ''|'#'*) continue ;; esac key="${line%%|*}" rest="${line#*|}" case "$key" in MODULE) MODULE_LINES="$MODULE_LINES $rest" ;; EXT) ALLOW_EXT="$rest" ;; EXCLUDE) EXCLUDE_PAT="$rest" ;; esac done < "$CONF_FILE" # 빈 트리 해시 — base 커밋이 없을 때(= 모든 이력이 컷오프 이후) 사용한다. EMPTY_TREE="4b825dc642cb6eb9a060e54bf8d69288fbee4904" is_allowed_ext() { _f="$1" _base="${_f##*/}" case "$_base" in *.*) _ext="${_base##*.}" ;; *) return 1 ;; # 확장자 없는 파일 제외 esac # 소문자 변환 (bash 3.2 호환) _ext="$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]')" case ",$ALLOW_EXT," in *",$_ext,"*) return 0 ;; *) return 1 ;; esac } # ---- 포함/제외 규칙 (.gitignore 유사) -------------------------------------- RULES=() if [ -f "$RULES_FILE" ]; then while IFS= read -r r || [ -n "$r" ]; do # 앞뒤 공백 제거 r="${r#"${r%%[![:space:]]*}"}" r="${r%"${r##*[![:space:]]}"}" case "$r" in ''|'#'*) continue ;; esac RULES[${#RULES[@]}]="$r" done < "$RULES_FILE" fi # 경로 하나에 규칙을 순서대로 적용한다. 마지막으로 매칭된 규칙이 이긴다. # 기본은 포함(0), '!' 규칙은 다시 포함, 일반 규칙은 제외(1). # 반환: 0 = 포함, 1 = 제외. 제외시킨 규칙은 MATCHED_RULE 에 담긴다. MATCHED_RULE="" rule_keep() { _path="$1" _leaf="${_path##*/}" _keep=0 MATCHED_RULE="" _i=0 while [ $_i -lt ${#RULES[@]} ]; do _rule="${RULES[$_i]}" _i=$((_i + 1)) _neg=0 case "$_rule" in '!'*) _neg=1; _rule="${_rule#!}" ;; esac # 디렉터리 규칙: 하위 전체 case "$_rule" in */) _rule="${_rule}*" ;; esac # '/' 가 있으면 전체 경로, 없으면 파일명과 매칭 case "$_rule" in */*) _target="$_path" ;; *) _target="$_leaf" ;; esac # shellcheck disable=SC2254 # 의도적으로 unquoted — glob 패턴으로 쓴다 case "$_target" in $_rule) if [ $_neg -eq 1 ]; then _keep=0; MATCHED_RULE="" else _keep=1; MATCHED_RULE="$_rule" fi ;; esac done return $_keep } csv_escape() { printf '"%s"' "$(printf '%s' "$1" | sed 's/"/""/g')" } # ---- 준비 ----------------------------------------------------------------- if [ "$DRY_RUN" -eq 0 ]; then rm -rf "$OUT_DIR" mkdir -p "$OUT_DIR/src" fi MANIFEST="$OUT_DIR/MANIFEST.csv" SUMMARY="$OUT_DIR/SUMMARY.md" TMP_SUMMARY="$(mktemp)" trap 'rm -f "$TMP_SUMMARY"' EXIT if [ "$DRY_RUN" -eq 0 ]; then printf '\357\273\277' > "$MANIFEST" # Excel 한글용 UTF-8 BOM echo 'module,path,ext,added_commit,added_date,author' >> "$MANIFEST" fi # dry-run(목록 뽑기) 모드에서는 파일 목록만 stdout으로 내보내고 # 진행 로그는 전부 stderr로 보낸다. 그래야 파이프/리다이렉트로 목록을 바로 가공할 수 있다. if [ "$DRY_RUN" -eq 1 ]; then LOG=2; else LOG=1; fi { echo "저장소 루트 : $REPO_ROOT" echo "기준 날짜 : $CUTOFF (이 날짜 직전 마지막 커밋이 base)" echo "출력 경로 : $OUT_DIR" [ "$DRY_RUN" -eq 1 ] && echo "모드 : DRY-RUN (목록만 출력, 복사 안 함)" echo } >&$LOG TOTAL=0 MISSING=0 EXCLUDED=0 # ---- 모듈 순회 ------------------------------------------------------------ # 파이프 대신 process substitution 을 써서 집계 변수가 서브셸에 갇히지 않게 한다. while IFS= read -r mline; do [ -z "$mline" ] && continue MOD="${mline%%|*}" PATHS="${mline#*|}" if [ -n "$ONLY_MODULES" ]; then case " $ONLY_MODULES " in *" $MOD "*) : ;; *) continue ;; esac fi MOD_DIR="$REPO_ROOT/$MOD" if [ ! -d "$MOD_DIR/.git" ]; then echo "[건너뜀] $MOD — git 저장소 아님 ($MOD_DIR)" >&$LOG continue fi # base 커밋 결정 BASE="$(git -C "$MOD_DIR" -c core.quotepath=false rev-list -1 --before="$CUTOFF" HEAD 2>/dev/null || true)" if [ -z "$BASE" ]; then BASE="$EMPTY_TREE" BASE_DESC="(컷오프 이전 커밋 없음 → 전체를 신규로 간주)" else BASE_DESC="$(git -C "$MOD_DIR" -c core.quotepath=false log -1 --format='%h %ad %s' --date=short "$BASE" | cut -c1-80)" fi # 실제 존재하는 소스 경로만 pathspec으로 사용 SPEC="" for p in $PATHS; do [ -d "$MOD_DIR/$p" ] && SPEC="$SPEC $p" done if [ -z "$SPEC" ]; then echo "[건너뜀] $MOD — 설정된 소스 경로가 존재하지 않음" >&$LOG continue fi # 신규(Added) 파일만. -M 으로 rename은 신규에서 제외한다. FILES="$(git -C "$MOD_DIR" -c core.quotepath=false diff --name-only --diff-filter=A -M "$BASE" HEAD -- $SPEC 2>/dev/null || true)" if [ "$INCLUDE_UNTRACKED" -eq 1 ]; then UNTRACKED="$(git -C "$MOD_DIR" -c core.quotepath=false ls-files --others --exclude-standard -- $SPEC 2>/dev/null || true)" FILES="$FILES $UNTRACKED" fi COUNT=0 MISS=0 while IFS= read -r f; do [ -z "$f" ] && continue is_allowed_ext "$f" || continue # 규칙 매칭은 <모듈>/<경로> 전체 문자열 기준 if ! rule_keep "$MOD/$f"; then EXCLUDED=$((EXCLUDED + 1)) [ "$SHOW_EXCLUDED" -eq 1 ] && printf '%s\t# %s\n' "$MOD/$f" "$MATCHED_RULE" continue fi [ "$SHOW_EXCLUDED" -eq 1 ] && continue SRC="$MOD_DIR/$f" if [ ! -f "$SRC" ]; then # 추가된 뒤 삭제/이동된 파일 — 납품 대상 아님 echo " [없음] $MOD/$f" >&2 MISS=$((MISS + 1)) continue fi COUNT=$((COUNT + 1)) if [ "$DRY_RUN" -eq 1 ]; then echo "$MOD/$f" # 목록은 stdout으로 (가공하기 쉽게 접두 공백 없음) continue fi DST="$OUT_DIR/src/$MOD/$f" mkdir -p "$(dirname "$DST")" cp -p "$SRC" "$DST" META="$(git -C "$MOD_DIR" -c core.quotepath=false log -1 --diff-filter=A --format='%h|%ad|%an' --date=short -- "$f" 2>/dev/null || true)" C_HASH="${META%%|*}"; _r="${META#*|}" C_DATE="${_r%%|*}" C_AUTH="${_r#*|}" BASE_F="${f##*/}" case "$BASE_F" in *.*) EXT="${BASE_F##*.}" ;; *) EXT="" ;; esac { csv_escape "$MOD"; printf ',' csv_escape "$f"; printf ',' csv_escape "$EXT"; printf ',' csv_escape "$C_HASH"; printf ',' csv_escape "$C_DATE"; printf ',' csv_escape "$C_AUTH"; printf '\n' } >> "$MANIFEST" done < <(printf '%s\n' "$FILES" | sort -u) TOTAL=$((TOTAL + COUNT)) MISSING=$((MISSING + MISS)) printf '%s|%s|%s\n' "$MOD" "$COUNT" "$BASE_DESC" >> "$TMP_SUMMARY" printf '[수집] %-22s %5s 개 base: %s\n' "$MOD" "$COUNT" "$BASE_DESC" >&$LOG done < <(printf '%s\n' "$MODULE_LINES") # ---- 요약 ------------------------------------------------------------------ if [ "$DRY_RUN" -eq 0 ]; then { echo "# DJB 커스텀 소스 납품본" echo echo "- 생성 일시: $(date '+%Y-%m-%d %H:%M:%S')" echo "- 기준 날짜: \`$CUTOFF\` (이 날짜 직전 마지막 커밋을 base로 삼아, 이후 **신규 추가된** 파일만 수집)" echo "- 수집 규칙: \`git diff --diff-filter=A -M HEAD\` / 확장자 allowlist / \`export-filter.rules\` 필터" echo "- 대상 확장자: \`$ALLOW_EXT\`" echo "- 규칙 파일: \`$(basename "$RULES_FILE")\` (${#RULES[@]} 개 규칙, 제외 ${EXCLUDED} 건)" echo echo "## 모듈별 수집 결과" echo echo "| 모듈 | 파일 수 | base 커밋 |" echo "|---|---:|---|" while IFS='|' read -r m n d; do [ -z "$m" ] && continue echo "| $m | $n | $d |" done < "$TMP_SUMMARY" echo echo "**합계: ${TOTAL} 개**" [ "$MISSING" -gt 0 ] && echo "> 추가 후 삭제/이동되어 현재 트리에 없는 파일 ${MISSING} 개는 제외됨." echo echo "## 파일 목록" echo echo "\`MANIFEST.csv\` 참조 (module, path, ext, 최초 추가 커밋/일자/작성자)." } > "$SUMMARY" echo echo "MANIFEST : $MANIFEST" echo "SUMMARY : $SUMMARY" if [ "$MAKE_ZIP" -eq 1 ]; then ZIP_NAME="djb-custom-export-$(date '+%Y%m%d').zip" ZIP_PATH="$(dirname "$OUT_DIR")/$ZIP_NAME" rm -f "$ZIP_PATH" ( cd "$OUT_DIR" && zip -qr "$ZIP_PATH" . ) echo "ZIP : $ZIP_PATH" fi fi