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:
HeuJung committed 2026-07-07 22:45:16 +09:00
1 parent efc8a7ae6b
commit d0943d6cb6
26 files changed
+921 -29

No files matched your search

+194 -5
View File
@@ -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 파일은 정리되어야 한다'
);
// 사용자 추가 디렉토리는 보존