<# .SYNOPSIS DJB 커스텀 소스 납품본 수집 스크립트 (Windows / PowerShell) .DESCRIPTION 기준 커밋(컷오프 직전 마지막 커밋) 이후 "신규로 추가된" 소스 파일만 모아 모듈별 디렉터리 구조를 유지한 채 출력 디렉터리에 복사하고, MANIFEST.csv / SUMMARY.md / (선택) zip 아카이브를 생성한다. .EXAMPLE .\export-custom.ps1 .\export-custom.ps1 -Cutoff 2026-05-01 -Zip .\export-custom.ps1 -DryRun .\export-custom.ps1 -Module eapim-portal,eapim-admin #> [CmdletBinding()] param( [string] $Cutoff = '2026-05-01', [string] $Root, [string] $Out, [string[]] $Module, [switch] $Zip, [switch] $IncludeUntracked, [switch] $DryRun, [switch] $ShowExcluded ) if ($ShowExcluded) { $DryRun = $true } $ErrorActionPreference = 'Stop' # git 출력의 한글이 깨지지 않도록 콘솔 인코딩을 UTF-8로 고정한다. try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch { } $OutputEncoding = [Text.Encoding]::UTF8 $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $ConfFile = Join-Path $ScriptDir 'modules.conf' $RulesFile = Join-Path $ScriptDir 'export-filter.rules' # 저장소 루트: 스크립트는 \eapim-portal\script\djb-custom-export\ 에 있다. if (-not $Root) { $Root = (Resolve-Path (Join-Path $ScriptDir '..\..\..')).Path } if (-not (Test-Path -LiteralPath $Root -PathType Container)) { throw "저장소 루트를 찾을 수 없다: $Root" } if (-not $Out) { $Out = Join-Path $Root 'build\djb-custom-export' } if (-not (Test-Path -LiteralPath $ConfFile)) { throw "설정 파일 없음: $ConfFile" } # 빈 트리 해시 — base 커밋이 없을 때(= 모든 이력이 컷오프 이후) 사용한다. $EmptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' # ---- 설정 로드 ------------------------------------------------------------ $Modules = @() $AllowExt = @() foreach ($line in (Get-Content -LiteralPath $ConfFile -Encoding UTF8)) { $t = $line.Trim() if ($t -eq '' -or $t.StartsWith('#')) { continue } $idx = $t.IndexOf('|') if ($idx -lt 0) { continue } $key = $t.Substring(0, $idx) $rest = $t.Substring($idx + 1) switch ($key) { 'MODULE' { $i2 = $rest.IndexOf('|') if ($i2 -lt 0) { continue } $Modules += [pscustomobject]@{ Name = $rest.Substring(0, $i2) Paths = $rest.Substring($i2 + 1).Split(' ') | Where-Object { $_ -ne '' } } } 'EXT' { $AllowExt = $rest.Split(',') | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ -ne '' } } } } # ---- 포함/제외 규칙 로드 (.gitignore 유사) -------------------------------- $Rules = @() if (Test-Path -LiteralPath $RulesFile) { foreach ($line in (Get-Content -LiteralPath $RulesFile -Encoding UTF8)) { $t = $line.Trim() if ($t -eq '' -or $t.StartsWith('#')) { continue } $Rules += $t } } # 마지막으로 매칭된 규칙이 이긴다. 기본 포함, '!' 는 예외(다시 포함). # 반환: @{ Keep = $true/$false; Rule = '<제외시킨 규칙>' } function Test-RuleKeep([string]$RelPath) { $leaf = Split-Path $RelPath -Leaf $keep = $true $matched = '' foreach ($raw in $Rules) { $rule = $raw $neg = $false if ($rule.StartsWith('!')) { $neg = $true; $rule = $rule.Substring(1) } if ($rule.EndsWith('/')) { $rule = $rule + '*' } # 디렉터리 규칙 $target = if ($rule.Contains('/')) { $RelPath } else { $leaf } if ($target -like $rule) { if ($neg) { $keep = $true; $matched = '' } else { $keep = $false; $matched = $rule } } } return @{ Keep = $keep; Rule = $matched } } function Test-AllowedExt([string]$RelPath) { $leaf = Split-Path $RelPath -Leaf if ($leaf -notmatch '\.') { return $false } # 확장자 없는 파일 제외 $ext = $leaf.Substring($leaf.LastIndexOf('.') + 1).ToLower() return $AllowExt -contains $ext } function Invoke-Git([string]$Dir, [string[]]$GitArgs) { $all = @('-C', $Dir, '-c', 'core.quotepath=false') + $GitArgs $res = & git @all 2>$null if ($LASTEXITCODE -ne 0) { return @() } return @($res) } # ---- 준비 ----------------------------------------------------------------- $Manifest = Join-Path $Out 'MANIFEST.csv' $Summary = Join-Path $Out 'SUMMARY.md' if (-not $DryRun) { if (Test-Path -LiteralPath $Out) { Remove-Item -LiteralPath $Out -Recurse -Force } New-Item -ItemType Directory -Path (Join-Path $Out 'src') -Force | Out-Null # Excel 한글용 UTF-8 BOM $utf8Bom = New-Object System.Text.UTF8Encoding($true) [IO.File]::WriteAllText($Manifest, "module,path,ext,added_commit,added_date,author`r`n", $utf8Bom) } Write-Host "저장소 루트 : $Root" Write-Host "기준 날짜 : $Cutoff (이 날짜 직전 마지막 커밋이 base)" Write-Host "출력 경로 : $Out" if ($DryRun) { Write-Host '모드 : DRY-RUN (복사 안 함)' } Write-Host '' $Rows = New-Object System.Collections.Generic.List[string] $SummaryRs = New-Object System.Collections.Generic.List[object] $Total = 0 $Missing = 0 $Excluded = 0 # ---- 모듈 순회 ------------------------------------------------------------ foreach ($m in $Modules) { if ($Module -and ($Module -notcontains $m.Name)) { continue } $modDir = Join-Path $Root $m.Name if (-not (Test-Path -LiteralPath (Join-Path $modDir '.git'))) { Write-Host "[건너뜀] $($m.Name) — git 저장소 아님 ($modDir)" continue } # base 커밋 결정 $base = (Invoke-Git $modDir @('rev-list', '-1', "--before=$Cutoff", 'HEAD') | Select-Object -First 1) if ([string]::IsNullOrWhiteSpace($base)) { $base = $EmptyTree $baseDesc = '(컷오프 이전 커밋 없음 → 전체를 신규로 간주)' } else { $d = (Invoke-Git $modDir @('log', '-1', '--format=%h %ad %s', '--date=short', $base) | Select-Object -First 1) $baseDesc = if ($d.Length -gt 80) { $d.Substring(0, 80) } else { $d } } # 실제 존재하는 소스 경로만 pathspec 으로 사용 $spec = @() foreach ($p in $m.Paths) { if (Test-Path -LiteralPath (Join-Path $modDir ($p -replace '/', '\')) -PathType Container) { $spec += $p } } if ($spec.Count -eq 0) { Write-Host "[건너뜀] $($m.Name) — 설정된 소스 경로가 존재하지 않음" continue } # 신규(Added) 파일만. -M 으로 rename 은 신규에서 제외한다. $files = @(Invoke-Git $modDir (@('diff', '--name-only', '--diff-filter=A', '-M', $base, 'HEAD', '--') + $spec)) if ($IncludeUntracked) { $files += @(Invoke-Git $modDir (@('ls-files', '--others', '--exclude-standard', '--') + $spec)) } $files = $files | Where-Object { $_ -ne '' } | Sort-Object -Unique $count = 0 $miss = 0 foreach ($f in $files) { if (-not (Test-AllowedExt $f)) { continue } # 규칙 매칭은 <모듈>/<경로> 전체 문자열 기준 $rk = Test-RuleKeep "$($m.Name)/$f" if (-not $rk.Keep) { $Excluded++ if ($ShowExcluded) { Write-Output "$($m.Name)/$f`t# $($rk.Rule)" } continue } if ($ShowExcluded) { continue } $src = Join-Path $modDir ($f -replace '/', '\') if (-not (Test-Path -LiteralPath $src -PathType Leaf)) { # 추가된 뒤 삭제/이동된 파일 — 납품 대상 아님 Write-Warning " [없음] $($m.Name)/$f" $miss++ continue } $count++ # 목록은 Write-Output(성공 스트림) 으로 — 리다이렉트하면 목록만 파일로 떨어진다. if ($DryRun) { Write-Output "$($m.Name)/$f"; continue } $dst = Join-Path (Join-Path $Out 'src') (Join-Path $m.Name ($f -replace '/', '\')) $dstDir = Split-Path -Parent $dst if (-not (Test-Path -LiteralPath $dstDir)) { New-Item -ItemType Directory -Path $dstDir -Force | Out-Null } Copy-Item -LiteralPath $src -Destination $dst -Force $meta = (Invoke-Git $modDir @('log', '-1', '--diff-filter=A', '--format=%h|%ad|%an', '--date=short', '--', $f) | Select-Object -First 1) $cHash = ''; $cDate = ''; $cAuth = '' if ($meta) { $parts = $meta.Split('|') if ($parts.Count -ge 3) { $cHash = $parts[0]; $cDate = $parts[1]; $cAuth = $parts[2] } } $leaf = Split-Path $f -Leaf $ext = if ($leaf -match '\.') { $leaf.Substring($leaf.LastIndexOf('.') + 1) } else { '' } $esc = { param($s) '"' + ($s -replace '"', '""') + '"' } $Rows.Add((@( (& $esc $m.Name), (& $esc $f), (& $esc $ext), (& $esc $cHash), (& $esc $cDate), (& $esc $cAuth) ) -join ',')) } $Total += $count $Missing += $miss $SummaryRs.Add([pscustomobject]@{ Module = $m.Name; Count = $count; Base = $baseDesc }) Write-Host ("[수집] {0,-22} {1,5} 개 base: {2}" -f $m.Name, $count, $baseDesc) } # ---- 출력 ------------------------------------------------------------------ if (-not $DryRun) { if ($Rows.Count -gt 0) { [IO.File]::AppendAllText($Manifest, (($Rows -join "`r`n") + "`r`n"), (New-Object System.Text.UTF8Encoding($false))) } $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine('# DJB 커스텀 소스 납품본') [void]$sb.AppendLine('') [void]$sb.AppendLine("- 생성 일시: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") [void]$sb.AppendLine("- 기준 날짜: ``$Cutoff`` (이 날짜 직전 마지막 커밋을 base로 삼아, 이후 **신규 추가된** 파일만 수집)") [void]$sb.AppendLine('- 수집 규칙: `git diff --diff-filter=A -M HEAD` / 확장자 allowlist / `export-filter.rules` 필터') [void]$sb.AppendLine("- 대상 확장자: ``$($AllowExt -join ',')``") [void]$sb.AppendLine("- 규칙 파일: ``$(Split-Path $RulesFile -Leaf)`` ($($Rules.Count) 개 규칙, 제외 $Excluded 건)") [void]$sb.AppendLine('') [void]$sb.AppendLine('## 모듈별 수집 결과') [void]$sb.AppendLine('') [void]$sb.AppendLine('| 모듈 | 파일 수 | base 커밋 |') [void]$sb.AppendLine('|---|---:|---|') foreach ($r in $SummaryRs) { [void]$sb.AppendLine("| $($r.Module) | $($r.Count) | $($r.Base) |") } [void]$sb.AppendLine('') [void]$sb.AppendLine("**합계: $Total 개**") if ($Missing -gt 0) { [void]$sb.AppendLine("> 추가 후 삭제/이동되어 현재 트리에 없는 파일 $Missing 개는 제외됨.") } [void]$sb.AppendLine('') [void]$sb.AppendLine('## 파일 목록') [void]$sb.AppendLine('') [void]$sb.AppendLine('`MANIFEST.csv` 참조 (module, path, ext, 최초 추가 커밋/일자/작성자).') [IO.File]::WriteAllText($Summary, $sb.ToString(), (New-Object System.Text.UTF8Encoding($true))) Write-Host '' Write-Host "MANIFEST : $Manifest" Write-Host "SUMMARY : $Summary" if ($Zip) { $zipName = "djb-custom-export-$(Get-Date -Format 'yyyyMMdd').zip" $zipPath = Join-Path (Split-Path -Parent $Out) $zipName if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force } Compress-Archive -Path (Join-Path $Out '*') -DestinationPath $zipPath Write-Host "ZIP : $zipPath" } }