WIP 177 Rsync 실행 전 비교 (미리 보기 · 지울 목록) 가 옵션의 거르기 (--exclude · --include · --exclude-from · --filter · --delete-excluded) 를 실제 rsync 와 같게 따름 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next3
RyuJongJin 2026-10-19 21:09:13 +09:00
parent a74e7be59b
commit 722edc8ed1
6 changed files with 460 additions and 10 deletions

View File

@ -106,6 +106,76 @@ void main() {
tmp.deleteSync(recursive: true); tmp.deleteSync(recursive: true);
}); });
testWidgets('177: 옵션에 --exclude 가 있으면 실행 전 비교 목록도 실제 rsync 와 같게 (뺀 것 · 받는 쪽의 뺀 것은 지우지 않음)', (tester) async {
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final tmp = Directory.systemTemp.createTempSync('jj_rsyncexcl_');
final a = p.join(tmp.path, 'exSrc'), b = p.join(tmp.path, 'exDst');
void f(String dir, String rel) => File(p.join(dir, rel))
..createSync(recursive: true)
..writeAsStringSync(rel);
for (final r in ['a.txt', 'b.log', 'build/x.o', 'sub/c.txt', 'sub/deep/d.txt']) {
f(a, r);
}
for (final r in ['extra.txt', 'keep.log', 'build/old.o']) {
f(b, r);
}
await tester.runAsync(findBundledRsync);
final c = AppController(PlatformServices.create());
c.settings
..rsyncPaths = [tmp.path, tmp.path]
..rsyncOptions = '-avPog --delete --exclude=*.log --exclude=build/ --exclude=/sub/deep/';
final key = GlobalKey();
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
Future<void> wait(int ms) async {
for (var i = 0; i < ms ~/ 100; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await tester.pump();
}
}
await tester.pumpWidget(MaterialApp(
theme: buildTheme(),
builder: (_, child) => RepaintBoundary(key: key, child: child),
home: ExplorerPage(c: c, rsync: true),
));
await wait(2500);
await tester.tap(find.text('exSrc').first);
await wait(600);
await tester.tap(find.text('exDst').last);
await wait(600);
await tester.tap(find.text('좌 → 우'));
await wait(2500); // 비교
Finder inDialog(String t) => find.descendant(of: find.byType(Dialog), matching: find.textContaining(t));
final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final image = await tester.runAsync(() => boundary.toImage());
final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png));
File('$out/rsync_exclude_confirm.png').writeAsBytesSync(bytes!.buffer.asUint8List());
// 비교 목록: 보낼 것 · 지울 것만 (뺀 것은 없음)
expect(inDialog('a.txt'), findsWidgets);
expect(inDialog('c.txt'), findsWidgets);
expect(inDialog('extra.txt'), findsWidgets, reason: '--delete 로 지워짐');
for (final t in ['b.log', 'keep.log', 'x.o', 'old.o', 'd.txt']) {
expect(inDialog(t), findsNothing, reason: '$t 는 걸러짐 (실제 rsync 도 건드리지 않음)');
}
await tester.tap(find.text('실행'));
for (var i = 0; i < 100 && File(p.join(b, 'extra.txt')).existsSync(); i++) {
await wait(200);
}
await wait(1500);
// 실제 rsync 결과가 비교 목록과 같다
expect(File(p.join(b, 'a.txt')).existsSync(), isTrue);
expect(File(p.join(b, 'sub', 'c.txt')).existsSync(), isTrue);
expect(File(p.join(b, 'extra.txt')).existsSync(), isFalse);
expect(File(p.join(b, 'b.log')).existsSync(), isFalse);
expect(File(p.join(b, 'keep.log')).existsSync(), isTrue, reason: '받는 쪽의 뺀 것은 지우지 않음');
expect(File(p.join(b, 'build', 'old.o')).existsSync(), isTrue);
expect(File(p.join(b, 'build', 'x.o')).existsSync(), isFalse);
expect(Directory(p.join(b, 'sub', 'deep')).existsSync(), isFalse);
tester.view.reset();
tmp.deleteSync(recursive: true);
});
testWidgets('원본 파일 지우기 (--remove-source-files) · 빈 폴더 정리 · lsync 최종 정리', (tester) async { testWidgets('원본 파일 지우기 (--remove-source-files) · 빈 폴더 정리 · lsync 최종 정리', (tester) async {
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final tmp = Directory.systemTemp.createTempSync('jj_rsyncmove_'); final tmp = Directory.systemTemp.createTempSync('jj_rsyncmove_');

186
lib/core/rsync_filter.dart Normal file
View File

@ -0,0 +1,186 @@
import 'dart:io' show File, Platform;
import 'sync_tools.dart' show splitOptions;
/// 177: rsync 옵션의 거르기 규칙 (--exclude · --include · --exclude-from · --include-from · --filter "- …" / "+ …")
/// 을 실행 전 비교 (미리 보기 · 지울 목록) 에도 똑같이 적용한다. 예전에는 미리 보기가 이 규칙을 보지 않아,
/// 빼기로 한 폴더가 "새로" · "지워짐" 으로 나왔다 (실제 rsync 는 따름).
///
/// rsync 의 규칙 (man rsync "FILTER RULES") 그대로:
/// - 위에서부터 처음 맞는 규칙이 이긴다. 맞는 것이 없으면 포함.
/// - `*` = `/` 를 뺀 아무 글자, `**` = `/` 까지 아무 글자, `?` = `/` 가 아닌 한 글자, `[…]` = 글자 묶음.
/// - 끝이 `/` 면 폴더에만 맞는다. 앞이 `/` 면 옮기는 맨 위 (원본/ 의 안) 기준.
/// - `/` (끝 빼고) 나 `**` 가 있으면 경로 전체의 끝부분에, 없으면 이름에만 맞춘다.
/// - `이름/***` 은 그 폴더와 안의 모든 것.
/// - 거른 폴더 안으로는 내려가지 않는다. 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지운다).
class RsyncFilter {
final List<RsyncFilterRule> rules;
/// --delete-excluded: 받는 쪽의 거른 것도 지운다 (--delete 와 함께일 때)
final bool deleteExcluded;
/// 미리 보기가 따르지 못하는 거르기 옵션 (-C · -F · merge 규칙 · 읽지 못한 파일) - 화면에서 알릴 것
final List<String> unsupported;
const RsyncFilter(this.rules, {this.deleteExcluded = false, this.unsupported = const []});
static const none = RsyncFilter([]);
bool get isEmpty => rules.isEmpty && !deleteExcluded;
/// [options] (rsync 옵션 칸의 글) 에서 규칙을 읽는다. [readLines] 는 --exclude-from 파일 읽기 (시험에서 바꿈)
factory RsyncFilter.parse(String options, {List<String>? Function(String path)? readLines}) {
final read = readLines ?? _readLines;
final t = splitOptions(options);
final rules = <RsyncFilterRule>[];
final unsupported = <String>[];
var deleteExcluded = false;
void fromFile(String path, bool include) {
final lines = read(path);
if (lines == null) {
unsupported.add('${include ? '--include-from' : '--exclude-from'}=$path');
return;
}
for (final l in lines) {
final s = l.trimRight();
if (s.isEmpty || s.startsWith('#') || s.startsWith(';')) continue;
// 파일 안에서도 "+ " · "- " 머리를 따른다
if (s.startsWith('+ ')) {
rules.add(RsyncFilterRule(s.substring(2), include: true));
} else if (s.startsWith('- ')) {
rules.add(RsyncFilterRule(s.substring(2), include: false));
} else {
rules.add(RsyncFilterRule(s, include: include));
}
}
}
void filterRule(String r) {
final m = RegExp(r'^(\+|-|include|exclude)(?:,[a-z!]*)?\s+(.+)$').firstMatch(r.trim());
if (m == null) {
unsupported.add('--filter=$r');
return;
}
rules.add(RsyncFilterRule(m.group(2)!, include: m.group(1) == '+' || m.group(1) == 'include'));
}
for (var i = 0; i < t.length; i++) {
final o = t[i];
String? value(String name) {
if (o.startsWith('$name=')) return o.substring(name.length + 1);
if (o == name && i + 1 < t.length) return t[++i];
return null;
}
String? v;
if ((v = value('--exclude')) != null) {
rules.add(RsyncFilterRule(v!, include: false));
} else if ((v = value('--include')) != null) {
rules.add(RsyncFilterRule(v!, include: true));
} else if ((v = value('--exclude-from')) != null) {
fromFile(v!, false);
} else if ((v = value('--include-from')) != null) {
fromFile(v!, true);
} else if ((v = value('--filter')) != null) {
filterRule(v!);
} else if (o == '-f' && i + 1 < t.length) {
filterRule(t[++i]);
} else if (o == '--delete-excluded') {
deleteExcluded = true;
} else if (o == '--cvs-exclude' || o == '-F' || (o.startsWith('-') && !o.startsWith('--') && (o.contains('C') || o.contains('F')))) {
unsupported.add(o);
}
}
return RsyncFilter(rules, deleteExcluded: deleteExcluded, unsupported: unsupported);
}
static List<String>? _readLines(String path) {
// Windows 의 rsync (MSYS2) 는 /cygdrive/c/… 로 적어야 읽는다 - 같은 파일을 여기서도 읽게
final m = RegExp(r'^/cygdrive/([a-zA-Z])(/.*)?$').firstMatch(path);
if (m != null && Platform.isWindows) path = '${m[1]!.toUpperCase()}:${m[2] ?? '/'}';
try {
return File(path).readAsLinesSync();
} catch (_) {
return null;
}
}
/// 옮기는 맨 위 기준 상대 경로 [rel] ("/" 구분) 이 걸러지는지
bool excluded(String rel, {required bool isDir}) {
for (final r in rules) {
if (r.matches(rel, isDir: isDir)) return !r.include;
}
return false;
}
}
class RsyncFilterRule {
final String pattern;
final bool include;
late final bool _dirOnly;
late final RegExp _re;
RsyncFilterRule(this.pattern, {required this.include}) {
var p = pattern;
var all = false; // "이름/***" = 그 폴더와 안의 모든 것
if (p.endsWith('/***')) {
all = true;
p = p.substring(0, p.length - 4);
_dirOnly = false;
} else {
_dirOnly = p.endsWith('/') && p.length > 1;
if (_dirOnly) p = p.substring(0, p.length - 1);
}
final anchored = p.startsWith('/');
if (anchored) p = p.substring(1);
final whole = anchored || p.contains('/') || p.contains('**');
final body = _glob(p) + (all ? '(?:/.*)?' : '');
_re = RegExp(anchored ? '^$body\$' : whole ? '^(?:.*/)?$body\$' : '^$body\$');
_whole = whole;
}
late final bool _whole;
bool matches(String rel, {required bool isDir}) {
if (_dirOnly && !isDir) return false;
if (_whole) return _re.hasMatch(rel);
final name = rel.contains('/') ? rel.substring(rel.lastIndexOf('/') + 1) : rel;
return _re.hasMatch(name);
}
/// rsync 의 wildmatch 를 정규식으로
static String _glob(String p) {
final b = StringBuffer();
for (var i = 0; i < p.length; i++) {
final c = p[i];
if (c == '*') {
if (i + 1 < p.length && p[i + 1] == '*') {
b.write('.*');
i++;
while (i + 1 < p.length && p[i + 1] == '*') {
i++;
}
} else {
b.write('[^/]*');
}
} else if (c == '?') {
b.write('[^/]');
} else if (c == '[') {
final end = p.indexOf(']', i + 2);
if (end < 0) {
b.write(r'\[');
continue;
}
var set = p.substring(i + 1, end);
if (set.startsWith('!')) set = '^${set.substring(1)}';
b.write('[${set.replaceAll(r'\', r'\\')}]');
i = end;
} else if (c == r'\' && i + 1 < p.length) {
b.write(RegExp.escape(p[++i]));
} else {
b.write(RegExp.escape(c));
}
}
return b.toString();
}
}

View File

@ -1,6 +1,7 @@
import 'dart:io' show FileSystemEntity; import 'dart:io' show FileSystemEntity;
import 'file_ops.dart' show isAppTempName, isPartialFile, SourceUnreadableException; import 'file_ops.dart' show isAppTempName, isPartialFile, SourceUnreadableException;
import 'rsync_filter.dart';
import 'sync_tools.dart' show splitOptions, isSourceRemovingOption; import 'sync_tools.dart' show splitOptions, isSourceRemovingOption;
export 'sync_tools.dart' show isSourceRemovingOption; export 'sync_tools.dart' show isSourceRemovingOption;
@ -92,9 +93,16 @@ bool optionsUpdate(String options) =>
/// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다. /// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다.
/// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상"). /// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상").
/// 231: 원본의 링크 · junction 은 안으로 들어가지 않는다. [skipLinks] (앱이 직접 - 현재 방식 · WebDAV) 면 "건너뜀 (링크)", /// 231: 원본의 링크 · junction 은 안으로 들어가지 않는다. [skipLinks] (앱이 직접 - 현재 방식 · WebDAV) 면 "건너뜀 (링크)",
/// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다 /// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다.
/// 177: [filter] = rsync 옵션의 거르기 규칙 (rsync 로 맞출 때만 - 앱이 직접 맞출 때는 따르지 않으므로 넘기지 않는다).
/// 거른 것은 목록에 없고 (거른 폴더 안으로도 내려가지 않음), 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지움)
Future<List<PreviewItem>> previewSync(String src, String dst, Future<List<PreviewItem>> previewSync(String src, String dst,
{required bool toRight, bool delete = false, bool update = false, bool skipLinks = false, int limit = 2000}) async { {required bool toRight,
bool delete = false,
bool update = false,
bool skipLinks = false,
RsyncFilter filter = RsyncFilter.none,
int limit = 2000}) async {
final out = <PreviewItem>[]; final out = <PreviewItem>[];
Future<void> walk(String s, String d, String rel) async { Future<void> walk(String s, String d, String rel) async {
if (out.length >= limit) return; if (out.length >= limit) return;
@ -117,8 +125,10 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
if (out.length >= limit) return; if (out.length >= limit) return;
final name = vBasename(e.path); final name = vBasename(e.path);
if (isPartialFile(name) || isAppTempName(name)) continue; if (isPartialFile(name) || isAppTempName(name)) continue;
names.add(name);
final r = rel.isEmpty ? name : '$rel/$name'; final r = rel.isEmpty ? name : '$rel/$name';
// 177: 거른 것은 보내지 않는다 - 받는 쪽의 같은 이름은 아래에서 (지우지 않음 / --delete-excluded)
if (filter.excluded(r, isDir: e.isDir)) continue;
names.add(name);
final t = there[name]; final t = there[name];
if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) { if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) {
if (skipLinks) { if (skipLinks) {
@ -148,7 +158,15 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
for (final e in there.values) { for (final e in there.values) {
final name = vBasename(e.path); final name = vBasename(e.path);
if (names.contains(name) || isPartialFile(name) || isAppTempName(name)) continue; if (names.contains(name) || isPartialFile(name) || isAppTempName(name)) continue;
out.add(PreviewItem(rel.isEmpty ? name : '$rel/$name', delete ? PreviewAction.delete : PreviewAction.onlyTarget, final r = rel.isEmpty ? name : '$rel/$name';
if (filter.excluded(r, isDir: e.isDir)) {
// 거른 것은 rsync 가 건드리지 않는다 (--delete-excluded 와 --delete 면 지운다)
if (delete && filter.deleteExcluded) {
out.add(PreviewItem(r, PreviewAction.delete, toRight: toRight, isDir: e.isDir, size: e.size));
}
continue;
}
out.add(PreviewItem(r, delete ? PreviewAction.delete : PreviewAction.onlyTarget,
toRight: toRight, isDir: e.isDir, size: e.size)); toRight: toRight, isDir: e.isDir, size: e.size));
} }
} }
@ -160,9 +178,10 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
/// ⇄ (양쪽 함께, -u): 두 방향을 합친다. 한쪽에만 있는 것은 다른 쪽으로 복사되므로 "한쪽에만 있음" 은 빼고, /// ⇄ (양쪽 함께, -u): 두 방향을 합친다. 한쪽에만 있는 것은 다른 쪽으로 복사되므로 "한쪽에만 있음" 은 빼고,
/// 양쪽에 있고 다른 파일은 더 새 쪽에서 받는 쪽으로 (한 번만). /// 양쪽에 있고 다른 파일은 더 새 쪽에서 받는 쪽으로 (한 번만).
/// [delete]: --delete 와 함께면 먼저 도는 왼쪽 → 오른쪽에서 오른쪽에만 있는 것이 지워진다 (그 뒤 ← 로 건너가지 않음) /// [delete]: --delete 와 함께면 먼저 도는 왼쪽 → 오른쪽에서 오른쪽에만 있는 것이 지워진다 (그 뒤 ← 로 건너가지 않음)
Future<List<PreviewItem>> previewBoth(String left, String right, {bool delete = false, int limit = 2000}) async { Future<List<PreviewItem>> previewBoth(String left, String right,
final a = await previewSync(left, right, toRight: true, update: true, delete: delete, limit: limit); {bool delete = false, RsyncFilter filter = RsyncFilter.none, int limit = 2000}) async {
final b = await previewSync(right, left, toRight: false, update: true, limit: limit); final a = await previewSync(left, right, toRight: true, update: true, delete: delete, filter: filter, limit: limit);
final b = await previewSync(right, left, toRight: false, update: true, filter: filter, limit: limit);
final gone = {for (final x in a) if (x.action == PreviewAction.delete) x.rel}; final gone = {for (final x in a) if (x.action == PreviewAction.delete) x.rel};
final seen = <String>{}; final seen = <String>{};
return [ return [

View File

@ -37,6 +37,7 @@ import 'folder_picker.dart' show pickFolderOrDav;
import 'trash_page.dart'; import 'trash_page.dart';
import 'dav_external.dart'; import 'dav_external.dart';
import '../platform/windows/recycle_bin.dart'; import '../platform/windows/recycle_bin.dart';
import '../core/rsync_filter.dart';
import '../core/sync_preview.dart'; import '../core/sync_preview.dart';
import 'path_label.dart'; import 'path_label.dart';
import 'sync_preview_view.dart'; import 'sync_preview_view.dart';
@ -3452,8 +3453,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
SyncPreviewView( SyncPreviewView(
// 옵션이 바뀌면 (원본 파일 지우기) 다시 비교 // 옵션이 바뀌면 (원본 파일 지우기) 다시 비교
key: ValueKey([for (var i = 0; i < tasks.length; i++) runOpts(i)].join('|')), key: ValueKey([for (var i = 0; i < tasks.length; i++) runOpts(i)].join('|')),
// 177: rsync 로 맞추면 옵션의 거르기 (--exclude 등) 도 비교에 (WebDAV 는 앱이 직접 - 거르기를 따르지 않음)
compute: () async => both compute: () async => both
? previewBoth(l, r, delete: deletes()) ? previewBoth(l, r, delete: deletes(), filter: _viaDav([l, r]) ? RsyncFilter.none : RsyncFilter.parse(opts(tasks.first)))
: [ : [
for (final t in tasks) for (final t in tasks)
...await previewSync(t.sources.first, t.dest, ...await previewSync(t.sources.first, t.dest,
@ -3461,7 +3463,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
delete: deletes(), delete: deletes(),
// 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다 // 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다
update: optionsUpdate(opts(t)) || (_viaDav([l, r]) && c.settings.copySkipNewer), update: optionsUpdate(opts(t)) || (_viaDav([l, r]) && c.settings.copySkipNewer),
skipLinks: _viaDav([l, r])), skipLinks: _viaDav([l, r]),
filter: _viaDav([l, r]) ? RsyncFilter.none : RsyncFilter.parse(opts(t))),
], ],
// 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게 // 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게
onStart: () => set(() { onStart: () => set(() {

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../app/settings.dart' show CopyTask; import '../app/settings.dart' show CopyTask;
import '../core/file_ops.dart' show formatSize; import '../core/file_ops.dart' show formatSize;
import '../core/rsync_filter.dart';
import '../core/sync_preview.dart'; import '../core/sync_preview.dart';
import '../core/sync_tools.dart'; import '../core/sync_tools.dart';
import '../core/vfs.dart'; import '../core/vfs.dart';
@ -212,6 +213,8 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
? skipNewer || splitOptions(t.options).any((o) => o.toUpperCase() == '/XO') ? skipNewer || splitOptions(t.options).any((o) => o.toUpperCase() == '/XO')
: optionsUpdate(t.options) || (direct && skipNewer); : optionsUpdate(t.options) || (direct && skipNewer);
String dstOf(String src) => t.contents ? t.dest : vJoin(t.dest, vBasename(src)); String dstOf(String src) => t.contents ? t.dest : vJoin(t.dest, vBasename(src));
// 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음)
final filter = t.method == 'rsync' && !direct ? RsyncFilter.parse(t.options) : RsyncFilter.none;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
@ -243,7 +246,8 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
compute: () async => [ compute: () async => [
for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update, for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update,
// robocopy 도 링크를 따라가지 않는다 (/XJ - 옵션 칸에 /SJ · /SL 이 없으면) // robocopy 도 링크를 따라가지 않는다 (/XJ - 옵션 칸에 /SJ · /SL 이 없으면)
skipLinks: direct || (robo && robocopySkipsLinks(splitOptions(t.options)))), skipLinks: direct || (robo && robocopySkipsLinks(splitOptions(t.options))),
filter: filter),
], ],
onStart: () => set(() { onStart: () => set(() {
preview = null; preview = null;

168
test/rsync_filter_test.dart Normal file
View File

@ -0,0 +1,168 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'package:jj_mkvmaker/core/sync_tools.dart' show splitOptions, toCygwinPath;
import 'package:path/path.dart' as p;
/// 177: Rsync 실행 전 비교 (미리 보기 · 지울 목록) 가 옵션의 거르기 규칙을 실제 rsync 와 같게 따른다
void main() {
group('규칙 맞추기 (man rsync FILTER RULES)', () {
bool ex(String opts, String rel, {bool dir = false}) => RsyncFilter.parse(opts).excluded(rel, isDir: dir);
test('* 는 이름에 (어느 깊이든) · / 가 없으면 이름만', () {
expect(ex('--exclude=*.log', 'a.log'), isTrue);
expect(ex('--exclude=*.log', 'sub/deep/a.log'), isTrue);
expect(ex('--exclude=*.log', 'a.log.txt'), isFalse);
expect(ex('--exclude *.log', 'x/b.log'), isTrue, reason: '띄어 쓴 값');
});
test('끝 / = 폴더에만', () {
expect(ex('--exclude=build/', 'build', dir: true), isTrue);
expect(ex('--exclude=build/', 'build'), isFalse, reason: '같은 이름의 파일은 아님');
expect(ex('--exclude=build/', 'docs/build', dir: true), isTrue);
});
test('앞 / = 옮기는 맨 위 기준', () {
expect(ex('--exclude=/build', 'build', dir: true), isTrue);
expect(ex('--exclude=/build', 'docs/build', dir: true), isFalse);
});
test('/ 가 든 패턴은 경로 끝부분에 · ** 는 / 를 넘는다', () {
expect(ex('--exclude=sub/*.txt', 'sub/c.txt'), isTrue);
expect(ex('--exclude=sub/*.txt', 'x/sub/c.txt'), isTrue);
expect(ex('--exclude=sub/*.txt', 'sub/deep/e.txt'), isFalse, reason: '* 는 / 를 넘지 않음');
expect(ex('--exclude=sub/**.txt', 'sub/deep/e.txt'), isTrue);
expect(ex('--exclude=**/deep', 'sub/deep', dir: true), isTrue);
});
test('이름/*** = 폴더와 안의 모든 것', () {
expect(ex('--exclude=/cache/***', 'cache', dir: true), isTrue);
expect(ex('--exclude=/cache/***', 'cache/a/b.txt'), isTrue);
expect(ex('--exclude=/cache/***', 'cachex', dir: true), isFalse);
});
test('처음 맞는 규칙이 이긴다 (--include 먼저)', () {
const o = '--include=keep.log --exclude=*.log';
expect(ex(o, 'keep.log'), isFalse);
expect(ex(o, 'b.log'), isTrue);
expect(ex('--exclude=*.log --include=keep.log', 'keep.log'), isTrue, reason: '뒤의 include 는 늦음');
});
test('? · [..] · 따옴표 · --filter / -f', () {
expect(ex('--exclude=a?.txt', 'ab.txt'), isTrue);
expect(ex('--exclude=a?.txt', 'a/.txt'), isFalse);
expect(ex('--exclude=[ab]*.tmp', 'b1.tmp'), isTrue);
expect(ex('--exclude=[!ab]*.tmp', 'b1.tmp'), isFalse);
expect(ex('--exclude="My Folder/"', 'My Folder', dir: true), isTrue);
expect(ex('--filter="- *.bak"', 'x.bak'), isTrue);
expect(ex('-f "+ keep.bak" -f "- *.bak"', 'keep.bak'), isFalse);
});
test('--exclude-from 파일 (빈 줄 · # · + / - 머리) · 못 읽으면 알림 목록', () {
final f = RsyncFilter.parse('--exclude-from=list.txt',
readLines: (path) => path == 'list.txt' ? ['# 주석', '', '+ keep.log', '*.log', '- tmp/'] : null);
expect(f.excluded('keep.log', isDir: false), isFalse);
expect(f.excluded('a.log', isDir: false), isTrue);
expect(f.excluded('tmp', isDir: true), isTrue);
final g = RsyncFilter.parse('--exclude-from=없음.txt -C --filter=": .rules"', readLines: (_) => null);
expect(g.unsupported, hasLength(3));
});
test('거르기가 없으면 아무것도 거르지 않음 · 묶은 짧은 옵션은 거르기가 아님', () {
final f = RsyncFilter.parse('-avPog --delete');
expect(f.isEmpty, isTrue);
expect(f.unsupported, isEmpty);
expect(f.excluded('a.log', isDir: false), isFalse);
});
});
group('미리 보기 = 실제 rsync (--dry-run)', () {
late Directory tmp;
late String src, dst;
void f(String dir, String rel) => File(p.join(dir, rel))
..createSync(recursive: true)
..writeAsStringSync(rel);
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_rsfilter_');
src = p.join(tmp.path, 'src');
dst = p.join(tmp.path, 'dst');
for (final r in ['a.txt', 'b.log', 'keep.log', 'top.tmp', 'sub/c.txt', 'sub/d.log', 'sub/deep/e.txt', 'build/x.o',
'build/y.txt', 'cache/z.txt', 'docs/build/q.txt', 'docs/r.txt']) {
f(src, r);
}
// 받는 쪽에만 있는 것 (--delete 로 지워지거나, 걸러져 남는 것)
for (final r in ['extra.txt', 'extra.log', 'old.tmp', 'build/old.o', 'cache/stale.txt', 'sub/gone.txt', 'sub/gone.log']) {
f(dst, r);
}
});
tearDown(() => tmp.deleteSync(recursive: true));
// 앱에 들어 있는 rsync (Windows). 없으면 건너뜀
final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
/// 실제 rsync -r --delete -n -i 의 결과: 상대 경로 → 'add' · 'update' · 'delete' (지워지는 폴더 안의 것은 폴더 하나로)
Future<Map<String, String>> real(String opts) async {
final r = await Process.run(rsync, ['-r', '--delete', '-n', '-i', ...splitOptions(opts), '${toCygwinPath(src)}/', '${toCygwinPath(dst)}/']);
expect(r.exitCode, 0, reason: '${r.stderr}');
final out = <String, String>{};
for (final l in (r.stdout as String).split('\n')) {
final s = l.trimRight();
if (s.length < 13) continue;
final code = s.substring(0, 11), path = s.substring(12).replaceAll(RegExp(r'/$'), '');
if (path == '.' || path.isEmpty) continue;
if (code.startsWith('*deleting')) {
out[path] = 'delete';
} else if (code[2] == '+' || code.contains('+++++')) {
out[path] = 'add';
} else if (code[0] == '>' || code[0] == '<') {
out[path] = 'update';
}
}
// 지워지는 폴더 안의 것은 빼고 폴더 하나로 (미리 보기처럼)
final dirs = [for (final e in out.entries) if (e.value == 'delete') e.key];
out.removeWhere((k, v) => v == 'delete' && dirs.any((d) => k.startsWith('$d/')));
return out;
}
Future<Map<String, String>> preview(String opts) async {
final items = await previewSync(src, dst, toRight: true, delete: true, filter: RsyncFilter.parse(opts));
return {
for (final x in items)
if (x.action != PreviewAction.onlyTarget && x.action != PreviewAction.skipLink)
x.rel: switch (x.action) {
PreviewAction.add => 'add',
PreviewAction.update => 'update',
PreviewAction.delete => 'delete',
_ => '${x.action}',
},
};
}
for (final opts in [
'',
'--exclude=*.log',
'--exclude=build/',
'--exclude=/build',
'--exclude=build',
'--exclude=sub/*.txt',
'--exclude=**/deep/**',
'--exclude=sub/deep/',
'--exclude=/cache/***',
'--include=keep.log --exclude=*.log',
'--exclude=*.tmp --delete-excluded',
'--exclude=*.log --delete-excluded',
'--filter="- *.txt"',
'--exclude=[bk]*.log',
'--exclude=?.txt',
]) {
test('패턴 "$opts"', () async {
if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
final want = await real(opts);
final got = await preview(opts);
expect(got, want);
});
}
test('--exclude-from 파일', () async {
if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
final list = File(p.join(tmp.path, 'excl.txt'))..writeAsStringSync('# 빼기\n+ keep.log\n*.log\nbuild/\n');
// Windows rsync 는 /cygdrive 경로로 읽는다 (앱의 거르기도 같은 글을 읽음)
final opts = '--exclude-from=${toCygwinPath(list.path)}';
expect(await preview(opts), await real(opts));
});
});
}