From d0943d6cb62763a67388a8a49823b1f36dce6a6c Mon Sep 17 00:00:00 2001 From: HeuJung Date: Tue, 7 Jul 2026 14:53:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(core):=20core:update=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EC=9D=84=20=EC=BD=94=EC=96=B4=20=EB=B3=80=EA=B2=BD=EB=B6=84?= =?UTF-8?q?=EB=A7=8C=20=EC=A0=81=EC=9A=A9(3-way=20skip)=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core:update Step 7 이 targets 전체를 무조건 재복사하고 orphan 을 삭제하여 사용자 .htaccess 커스텀 블록·_bundled 커스텀 확장이 소실되던 사고(공개 )를 차단한다. 백업(base)↔_pending(theirs) 3-way md5 비교로 코어가 실제 변경/추가한 파일만 산출해 적용하고, 코어 미변경 파일은 복사·권한·mtime 을 전부 스킵해 현재 디스크 상태(사용자 수정 포함)를 보존한다. 전체 덮어쓰기 + orphan 정리가 필요하면 --prune 으로 opt-in. 백업 부재(--no-backup) 시 base 가 없어 전체 덮어쓰기로 안전 회귀 + 안내를 출력한다. 기존 stale 정리 테스트는 새 기본(증분)에서 orphan 을 삭제하지 않으므로 prune 컨텍스트로 전환했고, 증분 3-way 판정·목록 적용·fallback 단위 테스트와 --prune 커맨드 표면 테스트를 추가했다. 번들 ja 언어팩은 코어 apply_mode 안내 키(g7-core-ja)와 함께, 같은 빌드에서 동기화된 관리자↔유저 크로스링크 라벨(board/ecommerce/admin_basic/basic)의 일본어 번역을 반영하고 각 패키지 버전·CHANGELOG 를 갱신했다. --- CHANGELOG.md | 1 + .../Commands/Core/CoreUpdateCommand.php | 87 +++++- app/Extension/Helpers/CoreBackupHelper.php | 170 ++++++++++++ .../Helpers/FilePermissionHelper.php | 14 +- app/Services/CoreUpdateService.php | 87 +++++- docs/backend/core-update-system.md | 43 ++- lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 6 + .../g7-core-ja/backend/ja/settings.php | 4 + .../_bundled/g7-core-ja/language-pack.json | 2 +- .../g7-module-sirsoft-board-ja/CHANGELOG.md | 6 + .../frontend/partial/admin/posts.json | 3 +- .../language-pack.json | 2 +- .../CHANGELOG.md | 6 + .../frontend/partial/admin/product.json | 3 +- .../language-pack.json | 2 +- .../CHANGELOG.md | 4 + .../frontend/partial/admin.json | 3 +- .../g7-template-sirsoft-basic-ja/CHANGELOG.md | 1 + .../frontend/partial/board.json | 4 + .../frontend/partial/shop.json | 3 +- lang/en/settings.php | 6 + lang/ko/settings.php | 6 + .../Console/CoreUpdateWithBundleTest.php | 13 + .../CoreBackupHelperApplyListTest.php | 254 ++++++++++++++++++ tests/Unit/Services/CoreUpdateServiceTest.php | 199 +++++++++++++- .../core-update-active-dir-preservation.yaml | 21 +- 26 files changed, 921 insertions(+), 29 deletions(-) create mode 100644 tests/Unit/Extension/CoreBackupHelperApplyListTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0e398b..96a314a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Changed +- 코어 업데이트가 파일을 반영하는 기본 방식을 "새 버전이 실제로 추가·변경한 파일만 반영"으로 바꿔, 업데이트로 인한 사고 가능성을 크게 줄였습니다. 이제 코어가 건드리지 않은 파일은 그대로 두므로, `public/.htaccess` 에 직접 넣은 설정이나 사용자가 추가해 둔 파일이 업데이트 과정에서 사라지지 않습니다. 기존처럼 새 버전에서 제거된 파일 정리까지 포함한 전체 덮어쓰기를 원하면 `--prune` 옵션으로 업데이트하세요. (#64 @bigmsg 님께서 제보해주셨습니다.) - 화면·데이터를 내려받을 때의 전송 용량을 줄여 초기 로딩과 페이지 이동 속도를 개선했습니다. - 모듈·플러그인의 묶음 스크립트·스타일이 지금까지 압축 없이 전송되던 것을 압축 전송하도록 바꿔, 확장이 많은 사이트에서 첫 화면에 받는 용량을 크게 줄였습니다. - 한글 등 다국어 텍스트가 응답에 불필요하게 부풀려 담기던 것을 원래 형태 그대로 담도록 바꿔, 특히 다국어 번역 데이터의 전송 용량을 줄였습니다. diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php index e6104a0c..3012dc67 100644 --- a/app/Console/Commands/Core/CoreUpdateCommand.php +++ b/app/Console/Commands/Core/CoreUpdateCommand.php @@ -28,6 +28,7 @@ class CoreUpdateCommand extends Command protected $signature = 'core:update {--force : 버전 비교 없이 강제 업데이트} {--no-backup : 백업 생성 건너뛰기} + {--prune : 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용하고 나머지는 보존} {--no-maintenance : 유지보수 모드 활성화 건너뛰기} {--local : 로컬 코드베이스를 업데이트 소스로 사용 (GitHub 스킵)} {--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)} @@ -346,6 +347,12 @@ class CoreUpdateCommand extends Command // // `--no-backup` 모드면 backupPath 가 null 이므로 manifest 생성을 스킵 — 롤백 // 자체가 불가능한 모드이므로 기존 동작 유지. + // 증분 적용(3-way) 대상 목록. 백업 있음 + --prune 미지정 시에만 산출. + // null 로 남으면(백업 부재 또는 --prune) applyUpdate 가 전체 덮어쓰기로 회귀. + $applyList = null; + $applyStats = null; + $prune = (bool) $this->option('prune'); + if ($backupPath !== null) { $bar->setMessage('신규 파일 manifest 생성 중...'); $log('신규 파일 manifest 생성 시작'); @@ -371,15 +378,51 @@ class CoreUpdateCommand extends Command 'error' => $manifestError->getMessage(), ]); } + + // ── 증분 적용 대상 산출 (3-way) ── + // --prune 이면 전체 덮어쓰기이므로 산출 자체를 스킵. + if (! $prune) { + try { + $applyStats = CoreBackupHelper::computeApplyList( + $backupPath, + $pendingPath, + (array) config('app.update.targets', []), + (array) config('app.update.protected_paths', []), + (array) config('app.update.excludes', []), + ); + $applyList = $applyStats['apply']; + $log(sprintf( + '증분 적용 대상 산출 완료 (added=%d, changed=%d, total=%d)', + $applyStats['added_count'], + $applyStats['changed_count'], + count($applyList), + )); + } catch (\Throwable $applyError) { + // 산출 실패 시 안전하게 전체 덮어쓰기로 회귀 (applyList=null 유지) + $applyList = null; + $applyStats = null; + $log("증분 적용 대상 산출 실패 (전체 덮어쓰기로 회귀): {$applyError->getMessage()}"); + Log::warning('코어 업데이트: 증분 적용 대상 산출 실패', [ + 'error' => $applyError->getMessage(), + ]); + } + } } // ── Step 7: 파일 적용 ── $bar->setMessage(__('settings.core_update.step_apply')); $bar->advance(); - $log('코어 파일 덮어쓰기 시작'); - $service->applyUpdate($pendingPath, $onProgress); - $log('코어 파일 덮어쓰기 완료'); + if ($prune) { + $log('코어 파일 덮어쓰기 시작 (--prune: 전체 덮어쓰기 + orphan 정리)'); + } elseif ($applyList !== null) { + $log(sprintf('코어 파일 적용 시작 (증분: 코어 변경분 %d건만 적용, 나머지 보존)', count($applyList))); + } else { + $log('코어 파일 덮어쓰기 시작 (백업 부재 — 전체 덮어쓰기로 회귀)'); + } + + $service->applyUpdate($pendingPath, $onProgress, prune: $prune, applyList: $applyList); + $log('코어 파일 적용 완료'); // ── Step 8: vendor 디렉토리 복사 (_pending → 운영) ── if ($composerSkipped) { @@ -503,6 +546,9 @@ class CoreUpdateCommand extends Command $this->info("그누보드7 코어가 {$toVersion} 버전으로 업데이트되었습니다!"); $this->newLine(); + // ── 파일 적용 방식 요약 (부수의무: 설명의무) ── + $this->summarizeApplyMode($prune, $applyList, $applyStats); + // _bundled 확장 일괄 업데이트 프롬프트 (번들에 신버전이 있을 때만 표시). // // 주의: Step 11 restoreOwnership 이후에 실행되므로, 프롬프트 내 실제 @@ -1068,6 +1114,41 @@ class CoreUpdateCommand extends Command return true; } + /** + * 파일 적용 방식(증분/prune/fallback)과 통계를 사용자에게 요약 출력합니다. + * + * @param bool $prune --prune 지정 여부 + * @param array|null $applyList 증분 적용 대상 목록 (null 이면 전체 덮어쓰기) + * @param array{apply:array, added_count:int, changed_count:int, has_symlink:bool}|null $applyStats computeApplyList 산출 통계 + */ + private function summarizeApplyMode(bool $prune, ?array $applyList, ?array $applyStats): void + { + if ($prune) { + $this->line(__('settings.core_update.apply_mode_prune')); + $this->newLine(); + + return; + } + + if ($applyList === null) { + // 백업 부재 또는 산출 실패 → 전체 덮어쓰기로 회귀 + $this->warn(__('settings.core_update.apply_mode_fallback')); + $this->newLine(); + + return; + } + + // 증분 모드 + $added = $applyStats['added_count'] ?? 0; + $changed = $applyStats['changed_count'] ?? 0; + $this->line(__('settings.core_update.apply_mode_incremental', [ + 'added' => $added, + 'changed' => $changed, + ])); + $this->line(__('settings.core_update.apply_mode_incremental_prune_hint')); + $this->newLine(); + } + /** * 업데이트 로그를 파일로 저장합니다. * diff --git a/app/Extension/Helpers/CoreBackupHelper.php b/app/Extension/Helpers/CoreBackupHelper.php index 08d305b7..204bd42e 100644 --- a/app/Extension/Helpers/CoreBackupHelper.php +++ b/app/Extension/Helpers/CoreBackupHelper.php @@ -336,6 +336,176 @@ class CoreBackupHelper ]; } + /** + * 코어(신 버전)가 실제로 추가·변경한 파일 목록(적용 대상)을 3-way 비교로 산출합니다. + * + * `core:update` 기본(증분) 모드에서 `applyUpdate()` 가 참조하는 "적용 대상 집합"을 + * 만든다. 코어가 건드리지 않은 파일(base == theirs)은 목록에서 제외되어, 활성 + * 디렉토리에 남은 현재 상태(사용자 수정 포함 가능)를 그대로 보존한다. + * + * 3-way 판정: + * - base = 구버전 원본 = 백업 스냅샷 (`$backupPath/$target`) + * - theirs = 신 버전 = _pending 소스 (`$sourcePath/$target`) + * - 규칙: + * · base 없음 (theirs 에만 존재) → added (신규 파일 — 적용) + * · base 있고 size 또는 md5 다름 → changed (코어가 변경 — 적용) + * · base 있고 size·md5 동일 → 제외 (코어 미변경 — 스킵/보존) + * + * 성능: size 가 다르면 md5 를 계산하지 않는다(즉시 changed). size 가 같을 때만 + * `md5_file`. mtime 은 비교하지 않는다 — `_pending` 은 방금 추출되어 mtime 이 + * 전부 추출 시각이므로 항상 "다름"으로 오판한다. + * + * symlink 는 목록에서 제외한다 — 링크 자체는 `copyDirectory` 의 별도 링크 처리 + * 경로가 담당하며, 증분 모드에서도 항상 재생성되어야 한다(target 추적 없이 동일 + * 링크). 따라서 반환값 `is_partial` 이 true 여도 링크는 정상 반영된다. + * + * `writeNewFilesManifest()` 와 동일한 순회 구조·가드(`excludes`/`protectedPaths`)를 + * 재사용한다. 두 산출을 각각 별도 순회하지만, 반환값은 in-memory 로 즉시 소비되어 + * 파일화 비용이 없다(롤백용 `_new_files_manifest.json` 은 별도). + * + * @param string $backupPath 백업 디렉토리 경로 (= base, 활성 사전 스냅샷) + * @param string $sourcePath 소스 디렉토리 경로 (= theirs, _pending 신 버전) + * @param array $targets 처리할 target 경로 목록 (`app.update.targets`) + * @param array $protectedPaths 보호 경로 목록 (목록에서 제외) + * @param array $excludes 제외 패턴 목록 (예: ['node_modules', '.git']) + * @return array{apply:array, added_count:int, changed_count:int, has_symlink:bool} + * apply = 적용 대상 상대경로 목록(정렬됨, 슬래시 정규화) + */ + public static function computeApplyList( + string $backupPath, + string $sourcePath, + array $targets, + array $protectedPaths, + array $excludes, + ): array { + $added = []; + $changed = []; + $hasSymlink = false; + + $protectedSet = self::normalizeProtectedSet($protectedPaths); + $excludeSet = array_values(array_filter(array_map('trim', $excludes))); + + foreach ($targets as $target) { + $target = trim($target); + if ($target === '') { + continue; + } + + if (self::isWithinProtectedPath($target, $protectedSet)) { + continue; + } + + $sourceTargetPath = $sourcePath.DIRECTORY_SEPARATOR.$target; + if (! file_exists($sourceTargetPath)) { + continue; + } + + // 단일 파일 target + if (is_file($sourceTargetPath) && ! is_link($sourceTargetPath)) { + $rel = self::normalizeRelative($target); + $backupFilePath = $backupPath.DIRECTORY_SEPARATOR.$target; + self::classifyForApply($rel, $sourceTargetPath, $backupFilePath, $added, $changed); + + continue; + } + + if (is_link($sourceTargetPath)) { + $hasSymlink = true; + + continue; + } + + if (! is_dir($sourceTargetPath)) { + continue; + } + + // 디렉토리 target — 재귀 비교 + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($sourceTargetPath, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iterator as $item) { + /** @var SplFileInfo $item */ + $absolute = $item->getPathname(); + $relative = self::normalizeRelative($target.'/'.ltrim(substr($absolute, strlen($sourceTargetPath)), DIRECTORY_SEPARATOR.'/')); + + if (self::matchesExcludes($relative, $excludeSet)) { + continue; + } + if (self::isWithinProtectedPath($relative, $protectedSet)) { + continue; + } + + // symlink 는 목록 제외 — 링크 처리 경로가 별도 담당 + if ($item->isLink()) { + $hasSymlink = true; + + continue; + } + + // 디렉토리 자체는 목록에 담지 않는다 — 파일 단위 적용이 디렉토리를 + // 필요 시 생성한다(copyDirectory 가 대상 디렉토리를 ensure). + if (! $item->isFile()) { + continue; + } + + $backupItemPath = $backupPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative); + self::classifyForApply($relative, $absolute, $backupItemPath, $added, $changed); + } + } + + $apply = array_values(array_unique(array_merge($added, $changed))); + sort($apply, SORT_STRING); + + return [ + 'apply' => $apply, + 'added_count' => count($added), + 'changed_count' => count($changed), + 'has_symlink' => $hasSymlink, + ]; + } + + /** + * 3-way 판정으로 파일 하나를 added/changed 로 분류하거나 제외합니다. + * + * base(백업) 부재 → added, size 다름 또는 md5 다름 → changed, 동일 → 제외. + * + * @param string $relative 슬래시 정규화된 상대 경로 (목록 등재값) + * @param string $theirsAbsolute 신 버전(_pending) 파일 절대 경로 + * @param string $baseAbsolute 백업(base) 파일 절대 경로 + * @param array $added added 누적 배열 (참조) + * @param array $changed changed 누적 배열 (참조) + */ + private static function classifyForApply( + string $relative, + string $theirsAbsolute, + string $baseAbsolute, + array &$added, + array &$changed, + ): void { + if (! file_exists($baseAbsolute) || is_dir($baseAbsolute)) { + $added[] = $relative; + + return; + } + + // size 선필터 — 다르면 md5 생략하고 즉시 changed + $theirsSize = @filesize($theirsAbsolute); + $baseSize = @filesize($baseAbsolute); + if ($theirsSize !== $baseSize) { + $changed[] = $relative; + + return; + } + + // size 동일 → 내용 비교 + if (@md5_file($theirsAbsolute) !== @md5_file($baseAbsolute)) { + $changed[] = $relative; + } + // 동일하면 제외 (코어 미변경 — 스킵) + } + /** * 백업 디렉토리의 manifest 를 로드해 활성 디렉토리에서 신 버전 신규 파일을 정리합니다. * diff --git a/app/Extension/Helpers/FilePermissionHelper.php b/app/Extension/Helpers/FilePermissionHelper.php index e29877b2..35e99155 100644 --- a/app/Extension/Helpers/FilePermissionHelper.php +++ b/app/Extension/Helpers/FilePermissionHelper.php @@ -32,8 +32,13 @@ class FilePermissionHelper * @param string $relativePath 현재 상대 경로 (내부 재귀용) * @param bool $removeOrphans 소스에 없는 대상 파일/디렉토리 삭제 여부 * @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부 + * @param array|null $applyList 적용 대상 파일 상대경로 화이트리스트 + * (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면 + * 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어 + * 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink + * 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님). */ - public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false): void + public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false, ?array $applyList = null): void { if (! File::isDirectory($destination)) { // 신규 디렉토리: 부모 디렉토리의 퍼미션/소유권 상속 @@ -76,8 +81,13 @@ class FilePermissionHelper if ($item->isDir()) { // preserveTopLevelOrphans 는 최상위 한 레벨 한정 — 자식 재귀에는 항상 false 전달. - static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false); + static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false, applyList: $applyList); } else { + // 증분 모드(applyList 지정): 화이트리스트에 없는 파일은 스킵 — + // 코어가 변경하지 않은 파일의 현재 디스크 상태(사용자 수정 포함)를 보존. + if ($applyList !== null && ! isset($applyList[$itemRelativePath])) { + continue; + } static::copyFile($item->getPathname(), $destPath); } } diff --git a/app/Services/CoreUpdateService.php b/app/Services/CoreUpdateService.php index b3523eff..10cd302d 100644 --- a/app/Services/CoreUpdateService.php +++ b/app/Services/CoreUpdateService.php @@ -885,7 +885,16 @@ class CoreUpdateService } /** - * 코어 업데이트 대상 파일만 선택적으로 덮어씁니다. + * 코어 업데이트 대상 파일을 덮어씁니다. + * + * 기본(증분) 모드 — `$applyList` 가 주어지고 `$prune === false`: + * - 코어(신 버전)가 실제로 추가·변경한 파일만 적용한다(3-way 판정 산출물). + * - 코어가 건드리지 않은 파일은 복사·chmod·chown·mtime 갱신을 전부 스킵하여 현재 + * 디스크 상태(사용자 수정 포함 가능)를 그대로 보존한다. + * - orphan(소스에 없는 대상 파일) 삭제를 하지 않는다 — 사용자가 추가한 신규 파일 보존. + * + * prune 모드 — `$prune === true` 또는 `$applyList === null`(백업 부재 fallback): + * - targets 전체를 무조건 덮어쓰고, 소스에 없는 orphan 을 삭제한다(기존 동작). * * 주의: ExtensionPendingHelper::copyToActive()는 PHP copy()를 사용하여 * 파일 퍼미션/소유자를 보존하지 않으므로, 코어 업데이트에서는 사용하지 않습니다. @@ -893,12 +902,29 @@ class CoreUpdateService * * @param string $sourcePath 소스 경로 (_pending 내) * @param \Closure|null $onProgress 진행 콜백 + * @param bool $prune true 면 전체 덮어쓰기 + orphan 삭제(기존 동작), false 면 증분 적용 + * @param array|null $applyList 적용 대상 상대경로 목록(target 접두사 포함). + * `CoreBackupHelper::computeApplyList()` 산출물. null 이면 증분 불가로 판단해 전체 덮어쓰기. */ - public function applyUpdate(string $sourcePath, ?\Closure $onProgress = null): void + public function applyUpdate(string $sourcePath, ?\Closure $onProgress = null, bool $prune = false, ?array $applyList = null): void { $targets = config('app.update.targets', []); $excludes = config('app.update.excludes', []); + // 증분 모드 여부: prune 이 아니고 적용 목록이 주어진 경우에만 증분 적용. + // applyList 가 null 이면(백업 부재 등) 안전을 위해 전체 덮어쓰기로 회귀한다. + $incremental = ! $prune && $applyList !== null; + + // 증분 모드에서 각 target 하위 파일을 O(1) 로 조회하기 위한 lookup 맵. + // computeApplyList 는 target 접두사를 포함한 상대경로(예: 'public/.htaccess')를 + // 반환하므로, 그대로 정규화 키로 담는다. + $applySet = []; + if ($incremental) { + foreach ($applyList as $rel) { + $applySet[$this->normalizeRelativePath((string) $rel)] = true; + } + } + $applied = []; foreach ($targets as $target) { @@ -909,21 +935,45 @@ class CoreUpdateService continue; } + $normalizedTarget = $this->normalizeRelativePath($target); + $onProgress?->__invoke('apply', $target); if (File::isDirectory($src)) { + // 증분 모드: 이 target 하위의 적용 대상만 추린 뒤, target 접두사를 벗겨 + // copyDirectory 의 내부 상대경로($itemRelativePath)와 직접 매칭되는 + // 화이트리스트 맵을 만든다. + $targetApplyList = null; + if ($incremental) { + $targetApplyList = $this->buildTargetApplyList($applySet, $normalizedTarget); + } + // `{domain}/_bundled` 타깃은 최상위 한 레벨의 orphan 을 보존한다 — 사용자가 // `_bundled/` 바로 아래에 직접 만든 커스텀 확장 디렉토리/파일이 코어 업데이트 // 소스(번들 확장만 포함)에 없다는 이유로 삭제되던 결함 차단. - // 번들 확장 디렉토리 *내부* stale 정리는 그대로 유지된다. - $preserveTopLevelOrphans = str_ends_with($this->normalizeRelativePath($target), '_bundled'); - FilePermissionHelper::copyDirectory($src, $dest, $onProgress, $excludes, removeOrphans: true, preserveTopLevelOrphans: $preserveTopLevelOrphans); + // 번들 확장 디렉토리 *내부* stale 정리는 prune 모드에서만 수행된다. + $preserveTopLevelOrphans = str_ends_with($normalizedTarget, '_bundled'); + FilePermissionHelper::copyDirectory( + $src, + $dest, + $onProgress, + $excludes, + removeOrphans: $prune, + preserveTopLevelOrphans: $preserveTopLevelOrphans, + applyList: $targetApplyList, + ); } else { + // 단일 파일 target — 증분 모드에서는 적용 목록에 있을 때만 복사. + if ($incremental && ! isset($applySet[$normalizedTarget])) { + $applied[$normalizedTarget] = true; + + continue; + } File::ensureDirectoryExists(dirname($dest)); FilePermissionHelper::copyFile($src, $dest); } - $applied[$this->normalizeRelativePath($target)] = true; + $applied[$normalizedTarget] = true; } // 자동 발견 폴백 — 부모 프로세스(구버전) 의 stale `app.update.targets` 가 @@ -1005,6 +1055,31 @@ class CoreUpdateService return trim(str_replace(['\\', '/'], '/', $path), '/'); } + /** + * 전체 적용 집합에서 특정 target 하위 항목만 추려, target 접두사를 제거한 + * `copyDirectory` 내부 상대경로 화이트리스트 맵을 만듭니다. + * + * `computeApplyList` 는 target 접두사를 포함한 경로(예: `public/.htaccess`)를 담지만, + * `copyDirectory` 는 target 루트 기준 상대경로(`.htaccess`)로 항목을 순회하므로 접두사를 + * 벗겨야 매칭된다. + * + * @param array $applySet 전체 적용 대상 정규화 경로 lookup 맵 + * @param string $normalizedTarget 정규화된 target 경로 (예: `public`) + * @return array target 내부 상대경로 화이트리스트 (빈 맵일 수 있음 → 전부 스킵) + */ + private function buildTargetApplyList(array $applySet, string $normalizedTarget): array + { + $prefix = $normalizedTarget.'/'; + $out = []; + foreach ($applySet as $rel => $_) { + if (str_starts_with((string) $rel, $prefix)) { + $out[substr((string) $rel, strlen($prefix))] = true; + } + } + + return $out; + } + /** * 정규화된 path 가 이미 처리된 상위/하위 path 와 관계가 있는지 검사합니다. * diff --git a/docs/backend/core-update-system.md b/docs/backend/core-update-system.md index 947f195b..7f663cc8 100644 --- a/docs/backend/core-update-system.md +++ b/docs/backend/core-update-system.md @@ -111,7 +111,8 @@ ├─ Step 4: 다운로드 (GitHub zipball 또는 --source/--local에서 복사) ├─ Step 5: 백업 생성 (--no-backup 시 스킵) ├─ Step 6: Composer install (_pending에서 실행, 변경 없으면 스킵) -├─ Step 7: 파일 적용 (_pending → base_path 선택적 덮어쓰기) +├─ Step 6.5: 신규 파일 manifest 생성 + 증분 적용 대상(3-way) 산출 (백업 있을 때) +├─ Step 7: 파일 적용 (_pending → base_path, 기본=코어 변경분만 / --prune=전체 덮어쓰기) ├─ Step 8: vendor 복사 (_pending/vendor → base_path/vendor, Step 6 스킵 시 함께 스킵) ├─ Step 9: 마이그레이션 + 동기화 (migrate, roles, permissions, menus, mail templates) ├─ Step 10: 업그레이드 스텝 실행 (upgrades/Upgrade_X_Y_Z.php) @@ -178,16 +179,46 @@ v접두사 자동 감지 (resolveGithubArchiveUrl): > Vendor 번들 시스템 상세: [docs/extension/vendor-bundle.md](../extension/vendor-bundle.md) +### Step 6.5: 신규 파일 manifest + 증분 적용 대상 산출 + +```text +백업이 있을 때(--no-backup 아님)만 수행: + +1. writeNewFilesManifest() — 자동 롤백용 `_new_files_manifest.json` 기록 + (base=백업 vs theirs=_pending: 신 버전이 추가한 파일/디렉토리 목록) + +2. computeApplyList() — 기본(증분) 모드의 3-way 적용 대상 산출 (--prune 시 스킵) + · base = 구버전 원본 = 백업 스냅샷 + · theirs = 신 버전 = _pending + · base 없음 → added / size·md5 다름 → changed / 동일 → 제외(스킵) + · size 선필터 후 size 동일할 때만 md5 (mtime 비교 안 함 — _pending 은 추출 시각) + · symlink / excludes / protected_paths 하위 → 목록 제외 +``` + ### Step 7: 파일 적용 ```text -- _pending 소스에서 config('app.update.targets') 에 해당하는 파일/디렉토리 선택적 덮어쓰기 +기본(증분) 모드 — --prune 미지정 + 백업 있음: +- Step 6.5 의 applyList(코어가 실제 변경/추가한 파일)에 있는 파일만 적용 +- 코어가 건드리지 않은 파일은 복사·chmod·chown·mtime 갱신을 전부 스킵 → 현재 디스크 + 상태(사용자 수정 포함 가능)를 그대로 보존 +- orphan(소스에 없는 대상 파일) 삭제 안 함 → 사용자가 추가한 신규 파일 보존 + +--prune 모드 (또는 백업 부재 fallback): +- targets 전체 무조건 덮어쓰기 + orphan 삭제 (기존 동작) +- 백업 부재 시 base 가 없어 3-way 불가 → 안전하게 전체 덮어쓰기로 회귀 + 안내 출력 + +공통: - 자동 발견 폴백: targets 에 미등재된 source 최상위 항목도 스캔하여 적용 (config('app.update.protected_paths') 와 config('app.update.excludes') 매치 시 스킵) - FilePermissionHelper::copyDirectory() 사용 → 원본 파일 권한 보존 - ExtensionPendingHelper::copyToActive() 미사용 (권한 유실 방지) ``` +> **기본 동작 변경 배경 (공개 #64)**: 이전에는 Step 7 이 targets 전체를 무조건 재복사하고 orphan 을 삭제하여, 사용자가 수정한 `public/.htaccess` 커스텀 블록이나 `_bundled/` 아래 커스텀 확장이 소실되는 사고가 반복 제보되었다. 기본 동작을 "코어가 실제로 변경/추가한 파일만 적용(3-way)"으로 전환해 발생 표면을 제거했다. 전체 덮어쓰기 + 정리를 원하면 `--prune` 을 지정한다. "코어도 바꾸고 사용자도 바꾼" 파일은 코어 버전으로 갱신되지만 백업에 원본이 보존되어 복구 가능하다. + +> **증분 모드 잔존 stale 파일 정리**: 기본(증분) 모드는 orphan 을 삭제하지 않으므로, 신 버전에서 제거된 파일이 활성 디렉토리에 잔존할 수 있다. 완료 요약이 잔존을 안내하며, 정리하려면 다음 업데이트를 `--prune` 으로 실행하거나 단발성 정리 도구 `php artisan hotfix:rollback-stale-files --prune` 을 사용한다 (진단 모드 기본, `--prune` 시 확인 프롬프트 후 정리, symlink/protected_paths 가드 적용). 상세 사용법: [docs/cheatsheet.md](../cheatsheet.md) "단발성 결함 보정 (hotfix)". + > **자동 발견 폴백의 배경 (engine-v / beta.4 이후)**: Step 7 은 부모 프로세스의 `config('app.update.targets')` 를 사용한다. 부모는 업그레이드 *직전* 의 코드/메모리 상태이므로 신버전이 도입한 신규 최상위 디렉토리(예: beta.4 의 `lang-packs/`) 가 부모의 stale targets 에서 누락된다. 폴백은 이 결함을 안전망으로 차단하며, `config/app.php` 의 `update.protected_paths` 가 런타임 데이터(`storage`)·로컬 환경(`.env*`)·별도 파이프라인 산출물(`vendor`)·개발 메타(`.git`/`.claude`/`.serena` 등) 의 의도치 않은 덮어쓰기를 방지한다. ### Step 9: 마이그레이션 + 동기화 @@ -631,12 +662,13 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값') |--------|---------|------| | `createBackup()` | `(?Closure $onProgress): string` | CoreBackupHelper로 백업 생성 | | `restoreFromBackup()` | `(string $backupPath, ?Closure $onProgress): void` | 백업에서 파일 복원 | +| `CoreBackupHelper::computeApplyList()` | `(string $backupPath, string $sourcePath, array $targets, array $protectedPaths, array $excludes): array` | 3-way 판정으로 증분 적용 대상(added/changed) 산출 (`apply`, `added_count`, `changed_count`, `has_symlink`) | ### 적용 및 설치 | 메서드 | 시그니처 | 설명 | |--------|---------|------| -| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress): void` | _pending → base_path 선택적 덮어쓰기 | +| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress, bool $prune = false, ?array $applyList = null): void` | _pending → base_path 적용. `$applyList` 지정 + `!$prune` 이면 증분(코어 변경분만), 그 외 전체 덮어쓰기 + orphan 삭제 | | `runComposerInstallInPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending에서 composer install (--no-scripts) | | `isComposerUnchangedForCore()` | `(string $pendingPath): bool` | composer.json/lock MD5 비교 | | `runComposerInstall()` | `(?Closure $onProgress): void` | base_path에서 composer install | @@ -678,13 +710,14 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값') ### core:update ```bash -php artisan core:update [--force] [--no-backup] [--no-maintenance] [--local] [--source={path}] +php artisan core:update [--force] [--no-backup] [--prune] [--no-maintenance] [--local] [--source={path}] ``` | 옵션 | 설명 | |------|------| | `--force` | 버전 비교 스킵, 동일 버전이어도 강제 업데이트 | -| `--no-backup` | 백업 생성 스킵 (Step 5) | +| `--no-backup` | 백업 생성 스킵 (Step 5). 증분 적용 불가 → 전체 덮어쓰기로 회귀 | +| `--prune` | 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용(3-way)하고 나머지는 보존 | | `--no-maintenance` | 유지보수 모드 스킵 (Step 3) | | `--local` | 현재 코드베이스를 소스로 사용 (GitHub 스킵) | | `--source={path}` | 지정 디렉토리를 소스로 사용 (GitHub 스킵) | diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 12836fe4..8527fcf1 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-07 + +### Added + +- 코어 업데이트 파일 적용 방식 안내 메시지 일본어 번역 추가 (`settings.core_update.apply_mode_incremental`·`apply_mode_incremental_prune_hint`·`apply_mode_prune`·`apply_mode_fallback`) — 코어가 실제 변경한 파일만 적용하는 기본 방식·`--prune` 전체 덮어쓰기·백업 부재 회귀 안내가 일본어 로케일에서 자연스럽게 표시됩니다. + ## [1.0.1] - 2026-07-03 ### Added diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php index 30137ecb..692414e8 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php @@ -144,6 +144,10 @@ return [ 'zip_file_not_found' => '指定されたZIPファイルが見つかりません::path', 'source_vendor_missing' => 'ソースディレクトリにvendorがありません。composer installが実行されていない可能性があります。', 'composer_failed_with_output' => 'composer installの実行に失敗しました。:output', + 'apply_mode_incremental' => 'ファイル適用: コアが実際に変更したファイルのみ適用しました (新規 :added個、変更 :changed個)。その他のファイルは現在の状態のまま保存されました。', + 'apply_mode_incremental_prune_hint' => '新しいバージョンで削除されたファイルを一緒に整理する場合は、`--prune` オプションで再実行するか、`php artisan hotfix:rollback-stale-files --prune` を使用してください。', + 'apply_mode_prune' => 'ファイル適用: 完全な上書き + 削除されたファイルの整理を実行しました (--prune)。', + 'apply_mode_fallback' => 'ファイル適用: バックアップがないため増分適用をスキップし、完全な上書きを実行しました。ユーザーカスタムファイルの保存を希望する場合は、次回からはバックアップを維持したまま更新してください。', ], 'backup_path_required' => 'バックアップパスを入力してください。', 'restore_success' => '設定が正常に復元されました。', diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json index 4517babf..0acc94e5 100644 --- a/lang-packs/_bundled/g7-core-ja/language-pack.json +++ b/lang-packs/_bundled/g7-core-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 core Japanese language pack (bundled)", "ja": "G7 コア 日本語 言語パック(バンドル)" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "scope": "core", "target_identifier": null, diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md index 8619deee..40b159ef 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.1] - 2026-07-07 + +### Added + +- 관리자 게시글 목록의 "사용자 화면 보기" 링크 라벨(`view_user_page`) 일본어 번역 추가 — 관리자에서 해당 게시글의 사용자 화면으로 이동하는 링크가 일본어 로케일에서 자연스럽게 표시됩니다. + ## [1.0.0] - 2026-07-01 ### Added diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/posts.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/posts.json index 23aa803f..9abc52f1 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/posts.json +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/posts.json @@ -236,7 +236,8 @@ "blind": "ブラインド", "restore": "復元", "delete": "削除", - "create": "投稿を追加" + "create": "投稿を追加", + "view_user_page": "ユーザー画面を表示" }, "author_actions": { "view_user": "ユーザー情報を表示" diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json index fa4e1f7f..f682d6cf 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-board) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-board) 日本語 言語パック(バンドル)" }, - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-board", diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 705b8467..6d4f6b2c 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.1] - 2026-07-07 + +### Added + +- 관리자 상품 목록의 "사용자 화면 보기" 링크 라벨(`view_user_page`) 일본어 번역 추가 — 관리자에서 해당 상품의 사용자 화면으로 이동하는 링크가 일본어 로케일에서 자연스럽게 표시됩니다. + ## [1.0.0] - 2026-07-01 ### Added diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/product.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/product.json index 55472f47..5397e684 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/product.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/product.json @@ -87,7 +87,8 @@ "edit_search": "検索編集", "search": "検索", "reset": "初期化", - "save_preset": "プリセット保存" + "save_preset": "プリセット保存", + "view_user_page": "ユーザー画面を表示" }, "bulk_apply": "一括適用", "bulk_success": "選択された商品が一括変更されました。", diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json index 65f2995f..bb1e5257 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)" }, - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-ecommerce", diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 58157976..12d14538 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -6,6 +6,10 @@ ## [1.0.1] - 2026-07-06 +### Added + +- 관리자 헤더의 "사이트 보기" 링크와 접근 가능한 메뉴 부재 안내(`view_site`·`no_accessible_menus`) 일본어 번역 추가 — 관리자에서 사용자 사이트로 이동하는 링크와 메뉴 접근 권한이 없을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. + ### Changed - 첨부 개수 초과 안내 문구(`attachment.upload_limit_exceeded`)를 이미지 전용 표현에서 일반 파일에도 맞는 범용 표현으로 갱신했습니다 — 한국어/영어 원문 변경에 맞춰 일본어 번역을 동기화했습니다. diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index e6096df6..46e6649b 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1,6 +1,7 @@ { "sidebar": { - "no_accessible_menus": "アクセス可能なメニューがありません" + "no_accessible_menus": "アクセス可能なメニューがありません", + "view_site": "サイトを表示" }, "profile_settings": "プロフィール設定", "logout": "ログアウト", diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md index 35c00761..fe8f9f68 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md @@ -10,6 +10,7 @@ - 미발행 페이지 관리자 미리보기 안내 배너(`user.page.preview_banner`) 일본어 번역 추가 — 관리자가 발행 전 페이지를 사용자 화면에서 미리 볼 때의 안내 문구가 일본어 로케일에서 자연스럽게 표시됩니다. - 첨부 개수 초과 안내 문구(`attachment.upload_limit_exceeded`) 일본어 번역 추가 — 게시글 첨부를 허용 개수보다 많이 선택했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 게시판·상품 사용자 화면의 관리자 이동 링크 라벨(`admin_links.posts`·`admin_links.manage`·상품 `admin_edit`) 일본어 번역 추가 — 관리자가 사용자 화면에서 관리자 게시글 목록·게시판 관리·상품 편집으로 이동하는 링크가 일본어 로케일에서 자연스럽게 표시됩니다. ## [1.0.0] - 2026-07-01 diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/board.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/board.json index be417029..bff2ab3e 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/board.json +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/board.json @@ -227,5 +227,9 @@ "confirm_remove_attachment_title": "添付ファイル削除", "confirm_remove_attachment_message": "ファイルが即座に削除されます。投稿保存と無関係に取り戻せません。" } + }, + "admin_links": { + "posts": "管理者投稿閲覧", + "manage": "掲示板管理" } } diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/shop.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/shop.json index f57ee354..97ce15d7 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/shop.json +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/shop.json @@ -610,5 +610,6 @@ "blocks_order": "配送不可商品が含まれているため注文できません。該当商品を削除するか、配送国家を変更してください。", "shippable_to": "{country} 配送可能", "shipping_fee_summary": "{country} 配送料:{fee}" - } + }, + "admin_edit": "管理者で編集" } diff --git a/lang/en/settings.php b/lang/en/settings.php index 0756b246..2b93309e 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -144,6 +144,12 @@ return [ 'step_composer_prod' => 'Running composer install in production directory...', 'step_cleanup' => 'Cleaning up...', + // File apply mode summary (Step 7) + 'apply_mode_incremental' => 'File apply: only files actually changed by the core were applied (:added new, :changed changed). All other files were left untouched as-is.', + 'apply_mode_incremental_prune_hint' => 'To also remove files deleted in the new version, re-run with the `--prune` option or use `php artisan hotfix:rollback-stale-files --prune`.', + 'apply_mode_prune' => 'File apply: full overwrite plus cleanup of removed files was performed (--prune).', + 'apply_mode_fallback' => 'File apply: no backup available, so incremental apply was skipped and a full overwrite was performed. To preserve custom files, keep the backup enabled on future updates.', + // GitHub API error messages 'github_url_not_configured' => 'GitHub repository URL is not configured.', 'github_api_failed' => 'Unable to connect to GitHub API.', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 4c49dd4f..4a2b10e4 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -144,6 +144,12 @@ return [ 'step_composer_prod' => '운영 디렉토리 composer install 중...', 'step_cleanup' => '정리 중...', + // 파일 적용 방식 요약 (Step 7) + 'apply_mode_incremental' => '파일 적용: 코어가 실제 변경한 파일만 적용했습니다 (신규 :added개, 변경 :changed개). 그 외 파일은 현재 상태 그대로 보존되었습니다.', + 'apply_mode_incremental_prune_hint' => '신 버전에서 제거된 파일을 함께 정리하려면 `--prune` 옵션으로 다시 실행하거나 `php artisan hotfix:rollback-stale-files --prune` 을 사용하세요.', + 'apply_mode_prune' => '파일 적용: 전체 덮어쓰기 + 제거된 파일 정리를 수행했습니다 (--prune).', + 'apply_mode_fallback' => '파일 적용: 백업이 없어 증분 적용을 건너뛰고 전체 덮어쓰기를 수행했습니다. 사용자 커스텀 파일 보존을 원하면 다음부터는 백업을 유지한 채 업데이트하세요.', + // GitHub API 에러 메시지 'github_url_not_configured' => 'GitHub 저장소 URL이 설정되지 않았습니다.', 'github_api_failed' => 'GitHub API에 연결할 수 없습니다.', diff --git a/tests/Feature/Console/CoreUpdateWithBundleTest.php b/tests/Feature/Console/CoreUpdateWithBundleTest.php index 734e8dcd..302cbf34 100644 --- a/tests/Feature/Console/CoreUpdateWithBundleTest.php +++ b/tests/Feature/Console/CoreUpdateWithBundleTest.php @@ -120,4 +120,17 @@ class CoreUpdateWithBundleTest extends TestCase ->expectsOutputToContain('--vendor-mode') ->assertExitCode(0); } + + /** + * `--prune` 옵션이 등록되어 있는지 검증합니다 (공개 #64). + * + * 실제 파일 적용 로직(증분/prune)은 CoreUpdateServiceTest 의 applyUpdate 단위 + * 테스트가 커버하며, 여기서는 커맨드 표면에 옵션이 노출되는지만 확인한다. + */ + public function test_core_update_command_accepts_prune_option(): void + { + $this->artisan('core:update', ['--help' => true]) + ->expectsOutputToContain('--prune') + ->assertExitCode(0); + } } diff --git a/tests/Unit/Extension/CoreBackupHelperApplyListTest.php b/tests/Unit/Extension/CoreBackupHelperApplyListTest.php new file mode 100644 index 00000000..11b702a5 --- /dev/null +++ b/tests/Unit/Extension/CoreBackupHelperApplyListTest.php @@ -0,0 +1,254 @@ +basePath = $root.DIRECTORY_SEPARATOR.'base'; + $this->theirsPath = $root.DIRECTORY_SEPARATOR.'theirs'; + + File::ensureDirectoryExists($this->basePath); + File::ensureDirectoryExists($this->theirsPath); + } + + protected function tearDown(): void + { + $root = dirname($this->basePath); + if (File::isDirectory($root)) { + File::deleteDirectory($root); + } + + parent::tearDown(); + } + + /** + * base == theirs 인 파일은 적용 목록에서 제외됩니다 (코어 미변경 → 보존). + */ + public function test_unchanged_file_is_excluded_from_apply_list(): void + { + $this->writeBoth('app/Same.php', 'basePath, + $this->theirsPath, + ['app'], + [], + [], + ); + + $this->assertNotContains('app/Same.php', $result['apply']); + $this->assertSame(0, $result['added_count']); + $this->assertSame(0, $result['changed_count']); + } + + /** + * base 에 없고 theirs 에만 있는 파일은 added 로 포함됩니다 (신규). + */ + public function test_new_file_is_added_to_apply_list(): void + { + File::ensureDirectoryExists($this->theirsPath.DIRECTORY_SEPARATOR.'app'); + File::put($this->theirsPath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'New.php', 'basePath, + $this->theirsPath, + ['app'], + [], + [], + ); + + $this->assertContains('app/New.php', $result['apply']); + $this->assertSame(1, $result['added_count']); + $this->assertSame(0, $result['changed_count']); + } + + /** + * size 가 다른 파일은 md5 계산 없이 changed 로 포함됩니다. + */ + public function test_size_differing_file_is_changed(): void + { + $this->writeBoth('app/Grown.php', 'basePath, + $this->theirsPath, + ['app'], + [], + [], + ); + + $this->assertContains('app/Grown.php', $result['apply']); + $this->assertSame(1, $result['changed_count']); + $this->assertSame(0, $result['added_count']); + } + + /** + * size 는 같지만 내용(md5)이 다른 파일은 changed 로 포함됩니다. + */ + public function test_same_size_different_content_file_is_changed(): void + { + // 동일 길이(12), 내용만 다름 + $this->writeBoth('app/Edited.php', 'AAAAAAAAAAAA', 'BBBBBBBBBBBB'); + + $result = CoreBackupHelper::computeApplyList( + $this->basePath, + $this->theirsPath, + ['app'], + [], + [], + ); + + $this->assertContains('app/Edited.php', $result['apply']); + $this->assertSame(1, $result['changed_count']); + } + + /** + * excludes 패턴에 매칭되는 파일은 목록에서 제외됩니다. + */ + public function test_excluded_file_is_not_in_apply_list(): void + { + // theirs 에만 존재하지만 node_modules 하위 → 제외 + File::ensureDirectoryExists($this->theirsPath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'node_modules'); + File::put($this->theirsPath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'x.js', 'x'); + + $result = CoreBackupHelper::computeApplyList( + $this->basePath, + $this->theirsPath, + ['app'], + [], + ['node_modules'], + ); + + $this->assertSame([], $result['apply']); + } + + /** + * protected_paths 하위 항목은 목록에서 제외됩니다. + */ + public function test_protected_path_file_is_not_in_apply_list(): void + { + File::ensureDirectoryExists($this->theirsPath.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app'); + File::put($this->theirsPath.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'runtime.txt', 'data'); + + $result = CoreBackupHelper::computeApplyList( + $this->basePath, + $this->theirsPath, + ['storage'], + ['storage'], + [], + ); + + $this->assertSame([], $result['apply']); + } + + /** + * added 와 changed 가 섞여 있을 때 apply 목록이 정렬되어 둘 다 포함합니다. + */ + public function test_apply_list_merges_added_and_changed_sorted(): void + { + $this->writeBoth('app/Zeta.php', 'old', 'new-different'); // changed + File::put($this->theirsPath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Alpha.php', 'brand-new'); // added + $this->writeBoth('app/Keep.php', 'same', 'same'); // unchanged → 제외 + + $result = CoreBackupHelper::computeApplyList( + $this->basePath, + $this->theirsPath, + ['app'], + [], + [], + ); + + $this->assertSame(['app/Alpha.php', 'app/Zeta.php'], $result['apply']); + $this->assertSame(1, $result['added_count']); + $this->assertSame(1, $result['changed_count']); + } + + /** + * 단일 파일 target 도 3-way 판정을 받습니다 (변경 시 changed). + */ + public function test_single_file_target_is_classified(): void + { + File::put($this->basePath.DIRECTORY_SEPARATOR.'artisan', 'v1'); + File::put($this->theirsPath.DIRECTORY_SEPARATOR.'artisan', 'v2-changed'); + + $result = CoreBackupHelper::computeApplyList( + $this->basePath, + $this->theirsPath, + ['artisan'], + [], + [], + ); + + $this->assertContains('artisan', $result['apply']); + $this->assertSame(1, $result['changed_count']); + } + + /** + * theirs 의 symlink 는 적용 목록에서 제외되고 has_symlink 플래그가 켜집니다. + * (링크 자체는 copyDirectory 의 별도 링크 처리 경로가 담당 — 목록 산출 대상 아님) + */ + public function test_symlink_is_excluded_and_flagged(): void + { + // theirs/app/real.php + theirs/app/link.php(→ real.php) 구성 + File::ensureDirectoryExists($this->theirsPath.DIRECTORY_SEPARATOR.'app'); + $realTarget = $this->theirsPath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'real.php'; + File::put($realTarget, 'theirsPath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'link.php'; + if (! @symlink($realTarget, $linkPath) || ! is_link($linkPath)) { + // Windows SeCreateSymbolicLink 권한 부족 등 — 링크 생성 불가 환경은 skip + $this->markTestSkipped('symlink 생성 불가 환경 (권한 부족)'); + } + + $result = CoreBackupHelper::computeApplyList( + $this->basePath, + $this->theirsPath, + ['app'], + [], + [], + ); + + // real.php 는 added 로 포함되지만 link.php 는 목록에서 제외 + $this->assertContains('app/real.php', $result['apply']); + $this->assertNotContains('app/link.php', $result['apply']); + $this->assertTrue($result['has_symlink'], 'symlink 발견 시 has_symlink 플래그가 켜져야 한다'); + } + + /** + * base·theirs 양쪽에 동일 파일을 씁니다. + */ + private function writeBoth(string $relative, string $baseContent, string $theirsContent): void + { + $rel = str_replace('/', DIRECTORY_SEPARATOR, $relative); + + $baseFile = $this->basePath.DIRECTORY_SEPARATOR.$rel; + File::ensureDirectoryExists(dirname($baseFile)); + File::put($baseFile, $baseContent); + + $theirsFile = $this->theirsPath.DIRECTORY_SEPARATOR.$rel; + File::ensureDirectoryExists(dirname($theirsFile)); + File::put($theirsFile, $theirsContent); + } +} diff --git a/tests/Unit/Services/CoreUpdateServiceTest.php b/tests/Unit/Services/CoreUpdateServiceTest.php index e6c99788..5cf780d0 100644 --- a/tests/Unit/Services/CoreUpdateServiceTest.php +++ b/tests/Unit/Services/CoreUpdateServiceTest.php @@ -1111,6 +1111,189 @@ MD; $this->assertFalse(File::exists($tempDest.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'OrphanFile.php')); } + // ======================================================================== + // applyUpdate() — 기본(증분) 3-way 적용 + --prune (공개 #64) + // ======================================================================== + + /** + * 증분 모드(applyList 지정): 코어가 변경하지 않은 파일(applyList 미포함)은 + * 스킵되어 사용자가 수정한 현재 디스크 내용이 보존됩니다. + * + * 이슈 #64 핵심 회귀 가드 — `.htaccess` 커스텀 블록 소실 방지. + */ + public function test_apply_update_incremental_preserves_user_modified_unchanged_file(): void + { + [$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['public']); + + try { + // source(theirs): .htaccess 는 코어가 변경하지 않았다고 가정 → applyList 미포함 + File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'public'); + File::put($source.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'.htaccess', "# core default\n"); + File::put($source.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'index.php', "service->applyUpdate($source, null, prune: false, applyList: $applyList); + + // .htaccess: 사용자 커스텀 블록 보존 (스킵) + $this->assertStringContainsString( + 'USER CUSTOM CACHE BLOCK', + File::get($fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'.htaccess'), + '.htaccess 는 applyList 에 없으므로 사용자 수정이 보존되어야 한다', + ); + + // index.php: 코어 변경분 적용 + $this->assertStringContainsString( + 'core v2', + File::get($fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'index.php'), + ); + } finally { + $restore(); + } + } + + /** + * 증분 모드: applyList 에 없는 신규 파일이 source 에 있어도 스킵됩니다. + * (코어가 실제 추가했다면 applyList 에 담겼을 것 — 목록이 최종 권위) + */ + public function test_apply_update_incremental_applies_only_listed_files(): void + { + [$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['app']); + + try { + File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'app'); + File::put($source.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Listed.php', 'service->applyUpdate($source, null, prune: false, applyList: ['app/Listed.php']); + + $this->assertFileExists($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Listed.php'); + $this->assertFileDoesNotExist( + $fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Unlisted.php', + 'applyList 에 없는 파일은 복사되지 않아야 한다', + ); + } finally { + $restore(); + } + } + + /** + * 증분 모드는 orphan(소스에 없는 대상 파일)을 삭제하지 않습니다 + * (사용자가 추가한 신규 파일 보존). + */ + public function test_apply_update_incremental_does_not_remove_orphans(): void + { + [$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['app']); + + try { + File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'app'); + File::put($source.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Core.php', 'service->applyUpdate($source, null, prune: false, applyList: ['app/Core.php']); + + $this->assertFileExists( + $fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'UserAdded.php', + '증분 모드는 orphan 을 삭제하지 않아야 한다', + ); + } finally { + $restore(); + } + } + + /** + * prune=true: 전체 덮어쓰기 + orphan 삭제 (기존 동작). + */ + public function test_apply_update_prune_removes_orphans_and_overwrites_all(): void + { + [$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['app']); + + try { + File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'app'); + File::put($source.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Core.php', 'service->applyUpdate($source, null, prune: true, applyList: ['app/Core.php']); + + $this->assertStringContainsString('core v2', File::get($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Core.php')); + $this->assertFileDoesNotExist( + $fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Orphan.php', + 'prune 모드는 orphan 을 삭제해야 한다', + ); + } finally { + $restore(); + } + } + + /** + * applyList=null (백업 부재 fallback): prune 이 아니어도 전체 덮어쓰기로 회귀. + */ + public function test_apply_update_null_apply_list_falls_back_to_full_overwrite(): void + { + [$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['app']); + + try { + File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'app'); + File::put($source.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'A.php', 'service->applyUpdate($source, null, prune: false, applyList: null); + + $this->assertFileExists($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'A.php'); + $this->assertFileExists($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'B.php'); + } finally { + $restore(); + } + } + + /** + * applyUpdate 환경(격리 base_path + config targets)을 준비합니다. + * + * @param array $targets config('app.update.targets') 로 설정할 값 + * @return array{0:string, 1:string, 2:\Closure} [source 경로, fakeBase 경로, 복원 클로저] + */ + private function prepareApplyUpdateEnv(array $targets): array + { + $source = storage_path('test_apply3_src_'.uniqid()); + $fakeBase = storage_path('test_apply3_base_'.uniqid()); + $this->tempDirs[] = $source; + $this->tempDirs[] = $fakeBase; + File::ensureDirectoryExists($source); + File::ensureDirectoryExists($fakeBase); + + $originalBasePath = base_path(); + app()->setBasePath($fakeBase); + + config([ + 'app.update.targets' => $targets, + 'app.update.excludes' => [], + 'app.update.protected_paths' => ['.env', 'storage', 'vendor', 'node_modules', '.git'], + ]); + + $restore = function () use ($originalBasePath): void { + app()->setBasePath($originalBasePath); + }; + + return [$source, $fakeBase, $restore]; + } + // ======================================================================== // applyUpdate() — 신규 최상위 디렉토리 자동 발견 폴백 (회귀: lang-packs 누락) // ======================================================================== @@ -1340,10 +1523,15 @@ MD; } /** - * 비회귀: `_bundled` 최상위 사용자 항목은 보존하되, source 에 존재하는 코어 - * 번들 확장 디렉토리 *내부* 의 stale 파일 정리는 그대로 유지되어야 한다. + * 비회귀: `--prune` 모드에서 `_bundled` 최상위 사용자 항목은 보존하되, source 에 + * 존재하는 코어 번들 확장 디렉토리 *내부* 의 stale 파일 정리는 그대로 유지되어야 한다. + * + * 주의: orphan(stale) 정리는 `--prune` 지정 시에만 수행된다 (공개 #64). + * 기본(증분) 모드는 orphan 을 삭제하지 않으므로 본 회귀 가드는 prune 컨텍스트로 검증한다. + * 기본 모드에서 stale 파일이 보존되는 동작은 별도 테스트 + * (test_apply_update_incremental_does_not_remove_orphans)가 커버한다. */ - public function test_apply_update_still_cleans_stale_file_inside_bundled_extension(): void + public function test_apply_update_prune_still_cleans_stale_file_inside_bundled_extension(): void { $sourcePath = storage_path('test_388_stale_src_'.uniqid()); $this->tempDirs[] = $sourcePath; @@ -1379,12 +1567,13 @@ MD; 'app.update.protected_paths' => ['.env', 'storage', 'vendor', 'node_modules', '.git'], ]); - $this->service->applyUpdate($sourcePath); + // --prune 컨텍스트: orphan(stale) 정리 활성화 + $this->service->applyUpdate($sourcePath, null, prune: true); // 코어 번들 확장 내부의 stale 파일은 정리되어야 함 $this->assertFileDoesNotExist( $baseExt.DIRECTORY_SEPARATOR.'old.json', - '코어 번들 확장 내부의 stale 파일은 정리되어야 한다' + '--prune 모드에서 코어 번들 확장 내부의 stale 파일은 정리되어야 한다' ); // 사용자 추가 디렉토리는 보존 diff --git a/tests/scenarios/core-update-active-dir-preservation.yaml b/tests/scenarios/core-update-active-dir-preservation.yaml index 1a6ebc71..894b4298 100644 --- a/tests/scenarios/core-update-active-dir-preservation.yaml +++ b/tests/scenarios/core-update-active-dir-preservation.yaml @@ -41,11 +41,18 @@ axes: lang_packs_pending_depth: [depth_2, depth_3, depth_5, depth_unbounded] # locatePendingLangPacksSourceDeep user_added_bundled_entry: [dir_present, file_present, absent] # _bundled/ 바로 아래 사용자 추가 항목 bundled_target_domain: [modules, plugins, templates, lang-packs] # 보존 대상 4종 + # 기본=코어 변경분만 적용(3-way) + --prune (공개 #64) + apply_mode: [incremental, prune, fallback_no_backup] # 파일 적용 방식 + three_way_verdict: [unchanged_same, added_new, changed_size, changed_md5] # base↔theirs 판정 + user_local_modification: [modified, untouched] # mine 이 base 와 다른지 (사용자 수정) exclusions: - { source_structure: empty_top_level, active_subdir_state: present, reason: "source 가 비었으면 자동 발견 폴백 자체가 트리거 안 됨 — 활성 서브디렉토리 보존 무조건 보장" } - { applied_targets_set: no_targets, source_structure: bundled_only, reason: "targets 가 비면 source 의 templates/ 자체가 자동 발견 대상이 됨 — protected_paths 분기로 차단되어야 정상" } - { domain: language_packs, deactivated_columns: absent, source_url_column: column_absent, reason: "마이그레이션 미적용 환경 — 단발성 마킹/안내 모두 graceful skip 외 다른 효과 없음" } + - { apply_mode: prune, three_way_verdict: unchanged_same, reason: "prune 은 3-way 판정을 건너뛰고 전체 덮어쓰기 — three_way_verdict 무의미" } + - { apply_mode: fallback_no_backup, three_way_verdict: unchanged_same, reason: "백업 부재 시 base 자체가 없어 3-way 불가 — 전체 덮어쓰기로 회귀" } + - { apply_mode: incremental, user_local_modification: modified, three_way_verdict: changed_size, reason: "코어+사용자 동시 변경 — 코어 버전으로 갱신(§6 한계), 백업에서 복구 가능" } effects: - applyDiscoveredTopLevelPaths_does_not_remove_active_subdirs_when_source_only_has_bundled @@ -69,14 +76,26 @@ effects: # 사용자가 _bundled/ 바로 아래 직접 만든 항목 보존 (확장 3종 + 언어팩). # effect 명 = 실제 테스트 메서드명 (CoreUpdateServiceTest / FilePermissionHelperTest). - test_apply_update_preserves_user_added_bundled_subdir_all_domains - - test_apply_update_still_cleans_stale_file_inside_bundled_extension + - test_apply_update_prune_still_cleans_stale_file_inside_bundled_extension # prune 모드에서만 stale 정리 - test_preserve_top_level_orphans_keeps_top_level_dir_and_file - test_preserve_top_level_orphans_still_cleans_nested_orphans - test_preserve_top_level_orphans_defaults_to_existing_behavior + # 기본=코어 변경분만 적용(3-way) + --prune (공개 #64). + # effect 명 = 실제 테스트 메서드명 (CoreBackupHelperApplyListTest / CoreUpdateServiceTest). + - test_unchanged_file_is_excluded_from_apply_list # base==theirs → 제외 + - test_new_file_is_added_to_apply_list # base 없음 → added + - test_size_differing_file_is_changed # size 다름 → changed + - test_same_size_different_content_file_is_changed # md5 다름 → changed + - test_apply_update_incremental_preserves_user_modified_unchanged_file # 이슈 #64 회귀 가드 (.htaccess) + - test_apply_update_incremental_applies_only_listed_files # 목록만 적용 + - test_apply_update_incremental_does_not_remove_orphans # 증분=삭제 안 함 + - test_apply_update_prune_removes_orphans_and_overwrites_all # prune=전체 덮어쓰기+삭제 + - test_apply_update_null_apply_list_falls_back_to_full_overwrite # 백업 부재 fallback test_files: - tests/Unit/Services/CoreUpdateServiceTest.php - tests/Unit/Helpers/FilePermissionHelperTest.php + - tests/Unit/Extension/CoreBackupHelperApplyListTest.php - tests/Feature/Upgrades/Upgrade_7_0_0_beta_5RecoveryTest.php # 본 시나리오 매니페스트의 axes cross product 는 50+ 케이스이나, 실제 핵심 회귀 가드는