feat(core): core:update 기본을 코어 변경분만 적용(3-way skip)으로 전환
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 를 갱신했다.
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- 코어 업데이트가 파일을 반영하는 기본 방식을 "새 버전이 실제로 추가·변경한 파일만 반영"으로 바꿔, 업데이트로 인한 사고 가능성을 크게 줄였습니다. 이제 코어가 건드리지 않은 파일은 그대로 두므로, `public/.htaccess` 에 직접 넣은 설정이나 사용자가 추가해 둔 파일이 업데이트 과정에서 사라지지 않습니다. 기존처럼 새 버전에서 제거된 파일 정리까지 포함한 전체 덮어쓰기를 원하면 `--prune` 옵션으로 업데이트하세요. (#64 @bigmsg 님께서 제보해주셨습니다.)
|
||||
- 화면·데이터를 내려받을 때의 전송 용량을 줄여 초기 로딩과 페이지 이동 속도를 개선했습니다.
|
||||
- 모듈·플러그인의 묶음 스크립트·스타일이 지금까지 압축 없이 전송되던 것을 압축 전송하도록 바꿔, 확장이 많은 사이트에서 첫 화면에 받는 용량을 크게 줄였습니다.
|
||||
- 한글 등 다국어 텍스트가 응답에 불필요하게 부풀려 담기던 것을 원래 형태 그대로 담도록 바꿔, 특히 다국어 번역 데이터의 전송 용량을 줄였습니다.
|
||||
|
||||
@@ -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<int, string>|null $applyList 증분 적용 대상 목록 (null 이면 전체 덮어쓰기)
|
||||
* @param array{apply:array<int,string>, 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 업데이트 로그를 파일로 저장합니다.
|
||||
*
|
||||
|
||||
@@ -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<int,string>, 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<int,string> $added added 누적 배열 (참조)
|
||||
* @param array<int,string> $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 를 로드해 활성 디렉토리에서 신 버전 신규 파일을 정리합니다.
|
||||
*
|
||||
|
||||
@@ -32,8 +32,13 @@ class FilePermissionHelper
|
||||
* @param string $relativePath 현재 상대 경로 (내부 재귀용)
|
||||
* @param bool $removeOrphans 소스에 없는 대상 파일/디렉토리 삭제 여부
|
||||
* @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부
|
||||
* @param array<string, bool>|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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int, string>|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<string, bool> $applySet 전체 적용 대상 정규화 경로 lookup 맵
|
||||
* @param string $normalizedTarget 정규화된 target 경로 (예: `public`)
|
||||
* @return array<string, bool> 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 와 관계가 있는지 검사합니다.
|
||||
*
|
||||
|
||||
@@ -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 스킵) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' => '設定が正常に復元されました。',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -236,7 +236,8 @@
|
||||
"blind": "ブラインド",
|
||||
"restore": "復元",
|
||||
"delete": "削除",
|
||||
"create": "投稿を追加"
|
||||
"create": "投稿を追加",
|
||||
"view_user_page": "ユーザー画面を表示"
|
||||
},
|
||||
"author_actions": {
|
||||
"view_user": "ユーザー情報を表示"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -87,7 +87,8 @@
|
||||
"edit_search": "検索編集",
|
||||
"search": "検索",
|
||||
"reset": "初期化",
|
||||
"save_preset": "プリセット保存"
|
||||
"save_preset": "プリセット保存",
|
||||
"view_user_page": "ユーザー画面を表示"
|
||||
},
|
||||
"bulk_apply": "一括適用",
|
||||
"bulk_success": "選択された商品が一括変更されました。",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
## [1.0.1] - 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
- 관리자 헤더의 "사이트 보기" 링크와 접근 가능한 메뉴 부재 안내(`view_site`·`no_accessible_menus`) 일본어 번역 추가 — 관리자에서 사용자 사이트로 이동하는 링크와 메뉴 접근 권한이 없을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 첨부 개수 초과 안내 문구(`attachment.upload_limit_exceeded`)를 이미지 전용 표현에서 일반 파일에도 맞는 범용 표현으로 갱신했습니다 — 한국어/영어 원문 변경에 맞춰 일본어 번역을 동기화했습니다.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"sidebar": {
|
||||
"no_accessible_menus": "アクセス可能なメニューがありません"
|
||||
"no_accessible_menus": "アクセス可能なメニューがありません",
|
||||
"view_site": "サイトを表示"
|
||||
},
|
||||
"profile_settings": "プロフィール設定",
|
||||
"logout": "ログアウト",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -227,5 +227,9 @@
|
||||
"confirm_remove_attachment_title": "添付ファイル削除",
|
||||
"confirm_remove_attachment_message": "ファイルが即座に削除されます。投稿保存と無関係に取り戻せません。"
|
||||
}
|
||||
},
|
||||
"admin_links": {
|
||||
"posts": "管理者投稿閲覧",
|
||||
"manage": "掲示板管理"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,5 +610,6 @@
|
||||
"blocks_order": "配送不可商品が含まれているため注文できません。該当商品を削除するか、配送国家を変更してください。",
|
||||
"shippable_to": "{country} 配送可能",
|
||||
"shipping_fee_summary": "{country} 配送料:{fee}"
|
||||
}
|
||||
},
|
||||
"admin_edit": "管理者で編集"
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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에 연결할 수 없습니다.',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\Helpers\CoreBackupHelper;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* CoreBackupHelper::computeApplyList() 단위 테스트 (공개 #64)
|
||||
*
|
||||
* `core:update` 기본(증분) 모드의 3-way 판정을 검증합니다:
|
||||
* - base == theirs (코어 미변경) → 적용 목록 제외 (사용자 상태 보존)
|
||||
* - base 없음 (신규) → added 로 목록 포함
|
||||
* - size 다름 / md5 다름 (변경) → changed 로 목록 포함
|
||||
* - symlink / excludes / protected → 목록 제외
|
||||
*/
|
||||
class CoreBackupHelperApplyListTest extends TestCase
|
||||
{
|
||||
private string $basePath; // 백업 스냅샷 (base = 구버전 원본)
|
||||
|
||||
private string $theirsPath; // _pending (theirs = 신 버전)
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$root = storage_path('app/test_apply_list_'.uniqid());
|
||||
$this->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', '<?php // identical', '<?php // identical');
|
||||
|
||||
$result = CoreBackupHelper::computeApplyList(
|
||||
$this->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', '<?php // new');
|
||||
|
||||
$result = CoreBackupHelper::computeApplyList(
|
||||
$this->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', '<?php // small', '<?php // this content is clearly longer than base');
|
||||
|
||||
$result = CoreBackupHelper::computeApplyList(
|
||||
$this->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, '<?php // real');
|
||||
|
||||
$linkPath = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -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', "<?php // core v2\n");
|
||||
|
||||
// 활성(mine): 사용자가 .htaccess 에 커스텀 블록을 넣은 상태
|
||||
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'public');
|
||||
File::put($fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'.htaccess', "# core default\n# USER CUSTOM CACHE BLOCK\n");
|
||||
File::put($fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'index.php', "<?php // core v1\n");
|
||||
|
||||
// 코어가 실제로 바꾼 파일은 index.php 뿐 (.htaccess 는 목록에 없음)
|
||||
$applyList = ['public/index.php'];
|
||||
|
||||
$this->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', '<?php // listed');
|
||||
File::put($source.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Unlisted.php', '<?php // unlisted');
|
||||
|
||||
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'app');
|
||||
|
||||
$this->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', '<?php // core');
|
||||
|
||||
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'app');
|
||||
File::put($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'UserAdded.php', '<?php // user file');
|
||||
|
||||
$this->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', '<?php // core v2');
|
||||
|
||||
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'app');
|
||||
File::put($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Core.php', '<?php // core v1');
|
||||
File::put($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Orphan.php', '<?php // orphan');
|
||||
|
||||
// prune 모드는 applyList 를 무시하고 전체 덮어쓰기
|
||||
$this->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', '<?php // a');
|
||||
File::put($source.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'B.php', '<?php // b');
|
||||
|
||||
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'app');
|
||||
|
||||
// applyList 미지정(null) → incremental=false → 전체 복사
|
||||
$this->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<int, string> $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 파일은 정리되어야 한다'
|
||||
);
|
||||
|
||||
// 사용자 추가 디렉토리는 보존
|
||||
|
||||
@@ -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+ 케이스이나, 실제 핵심 회귀 가드는
|
||||
|
||||
Reference in New Issue
Block a user