getFilename(); $itemRelativePath = $relativePath === '' ? $itemName : $relativePath.'/'.$itemName; if (static::isExcluded($itemName, $itemRelativePath, $excludes)) { continue; } $destPath = $destination.DIRECTORY_SEPARATOR.$itemName; if ($item->isDir()) { static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans); } else { static::copyFile($item->getPathname(), $destPath); } } // 소스에 없는 대상 파일/디렉토리 삭제 if ($removeOrphans && File::isDirectory($destination)) { static::removeOrphanItems($source, $destination, $excludes, $relativePath); } } /** * 소스에 없고 대상에만 있는 파일/디렉토리를 삭제합니다. * * excludes 목록에 해당하는 항목은 삭제하지 않습니다. * * @param string $source 소스 디렉토리 경로 * @param string $destination 대상 디렉토리 경로 * @param array $excludes 제외할 이름 또는 경로 목록 * @param string $relativePath 현재 상대 경로 * @return void */ protected static function removeOrphanItems(string $source, string $destination, array $excludes, string $relativePath): void { $destItems = new \FilesystemIterator($destination, \FilesystemIterator::SKIP_DOTS); foreach ($destItems as $destItem) { $itemName = $destItem->getFilename(); $itemRelativePath = $relativePath === '' ? $itemName : $relativePath.'/'.$itemName; // excludes 대상은 삭제하지 않음 if (static::isExcluded($itemName, $itemRelativePath, $excludes)) { continue; } $srcPath = $source.DIRECTORY_SEPARATOR.$itemName; // 소스에 존재하지 않는 항목만 삭제 if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) { if ($destItem->isDir()) { File::deleteDirectory($destItem->getPathname()); } else { File::delete($destItem->getPathname()); } } } } /** * 항목이 제외 대상인지 확인합니다. * * - 단순 이름 (슬래시 미포함): 모든 레벨에서 해당 이름과 매칭 * - 경로 패턴 (슬래시 포함): 상대 경로와 정확히 매칭 * * @param string $itemName 현재 항목의 파일/디렉토리 이름 * @param string $itemRelativePath 루트로부터의 상대 경로 * @param array $excludes 제외 목록 * @return bool 제외 대상 여부 */ public static function isExcluded(string $itemName, string $itemRelativePath, array $excludes): bool { foreach ($excludes as $exclude) { if (str_contains($exclude, '/')) { // 경로 패턴: 상대 경로와 정확히 매칭 if ($itemRelativePath === $exclude) { return true; } } else { // 단순 이름: 모든 레벨에서 매칭 if ($itemName === $exclude) { return true; } } } return false; } /** * 퍼미션과 소유권을 보존하면서 파일을 복사합니다. * * - 기존 파일: 복사 후 원래 퍼미션/소유자/그룹 복원 * - 신규 파일: PHP 기본 퍼미션 적용 (umask 기반) * * @param string $source 소스 파일 * @param string $destination 대상 파일 * @return void */ public static function copyFile(string $source, string $destination): void { $existingPerms = null; $existingOwner = null; $existingGroup = null; if (File::exists($destination)) { $existingPerms = fileperms($destination); $existingOwner = fileowner($destination); $existingGroup = filegroup($destination); } File::ensureDirectoryExists(dirname($destination)); File::copy($source, $destination); if ($existingPerms !== null) { @chmod($destination, $existingPerms); } if ($existingOwner !== null && function_exists('chown')) { @chown($destination, $existingOwner); } if ($existingGroup !== null && function_exists('chgrp')) { @chgrp($destination, $existingGroup); } } }