From 722edc8ed1bcd83b90c07cec1dd059f0400366a2 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:09:13 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20177=20Rsync=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=A0=84=20=EB=B9=84=EA=B5=90=20(=EB=AF=B8=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=C2=B7=20=EC=A7=80=EC=9A=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D)=20=EA=B0=80=20=EC=98=B5=EC=85=98=EC=9D=98=20?= =?UTF-8?q?=EA=B1=B0=EB=A5=B4=EA=B8=B0=20(--exclude=20=C2=B7=20--include?= =?UTF-8?q?=20=C2=B7=20--exclude-from=20=C2=B7=20--filter=20=C2=B7=20--del?= =?UTF-8?q?ete-excluded)=20=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20rsync=20?= =?UTF-8?q?=EC=99=80=20=EA=B0=99=EA=B2=8C=20=EB=94=B0=EB=A6=84=20(?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- integration_test/rsync_page_test.dart | 70 ++++++++++ lib/core/rsync_filter.dart | 186 ++++++++++++++++++++++++++ lib/core/sync_preview.dart | 33 ++++- lib/ui/explorer_page.dart | 7 +- lib/ui/sync_preview_view.dart | 6 +- test/rsync_filter_test.dart | 168 +++++++++++++++++++++++ 6 files changed, 460 insertions(+), 10 deletions(-) create mode 100644 lib/core/rsync_filter.dart create mode 100644 test/rsync_filter_test.dart diff --git a/integration_test/rsync_page_test.dart b/integration_test/rsync_page_test.dart index ff95e49..e4f9fd4 100644 --- a/integration_test/rsync_page_test.dart +++ b/integration_test/rsync_page_test.dart @@ -106,6 +106,76 @@ void main() { 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 wait(int ms) async { + for (var i = 0; i < ms ~/ 100; i++) { + await tester.runAsync(() => Future.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 { final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; final tmp = Directory.systemTemp.createTempSync('jj_rsyncmove_'); diff --git a/lib/core/rsync_filter.dart b/lib/core/rsync_filter.dart new file mode 100644 index 0000000..8b47198 --- /dev/null +++ b/lib/core/rsync_filter.dart @@ -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 rules; + + /// --delete-excluded: 받는 쪽의 거른 것도 지운다 (--delete 와 함께일 때) + final bool deleteExcluded; + + /// 미리 보기가 따르지 못하는 거르기 옵션 (-C · -F · merge 규칙 · 읽지 못한 파일) - 화면에서 알릴 것 + final List 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? Function(String path)? readLines}) { + final read = readLines ?? _readLines; + final t = splitOptions(options); + final rules = []; + final unsupported = []; + 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? _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(); + } +} diff --git a/lib/core/sync_preview.dart b/lib/core/sync_preview.dart index e2ac112..af2fb78 100644 --- a/lib/core/sync_preview.dart +++ b/lib/core/sync_preview.dart @@ -1,6 +1,7 @@ import 'dart:io' show FileSystemEntity; import 'file_ops.dart' show isAppTempName, isPartialFile, SourceUnreadableException; +import 'rsync_filter.dart'; import 'sync_tools.dart' show splitOptions, isSourceRemovingOption; export 'sync_tools.dart' show isSourceRemovingOption; @@ -92,9 +93,16 @@ bool optionsUpdate(String options) => /// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다. /// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상"). /// 231: 원본의 링크 · junction 은 안으로 들어가지 않는다. [skipLinks] (앱이 직접 - 현재 방식 · WebDAV) 면 "건너뜀 (링크)", -/// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다 +/// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다. +/// 177: [filter] = rsync 옵션의 거르기 규칙 (rsync 로 맞출 때만 - 앱이 직접 맞출 때는 따르지 않으므로 넘기지 않는다). +/// 거른 것은 목록에 없고 (거른 폴더 안으로도 내려가지 않음), 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지움) Future> 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 = []; Future walk(String s, String d, String rel) async { if (out.length >= limit) return; @@ -117,8 +125,10 @@ Future> previewSync(String src, String dst, if (out.length >= limit) return; final name = vBasename(e.path); if (isPartialFile(name) || isAppTempName(name)) continue; - names.add(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]; if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) { if (skipLinks) { @@ -148,7 +158,15 @@ Future> previewSync(String src, String dst, for (final e in there.values) { final name = vBasename(e.path); 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)); } } @@ -160,9 +178,10 @@ Future> previewSync(String src, String dst, /// ⇄ (양쪽 함께, -u): 두 방향을 합친다. 한쪽에만 있는 것은 다른 쪽으로 복사되므로 "한쪽에만 있음" 은 빼고, /// 양쪽에 있고 다른 파일은 더 새 쪽에서 받는 쪽으로 (한 번만). /// [delete]: --delete 와 함께면 먼저 도는 왼쪽 → 오른쪽에서 오른쪽에만 있는 것이 지워진다 (그 뒤 ← 로 건너가지 않음) -Future> previewBoth(String left, String right, {bool delete = false, int limit = 2000}) async { - final a = await previewSync(left, right, toRight: true, update: true, delete: delete, limit: limit); - final b = await previewSync(right, left, toRight: false, update: true, limit: limit); +Future> previewBoth(String left, String right, + {bool delete = false, RsyncFilter filter = RsyncFilter.none, int limit = 2000}) async { + 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 seen = {}; return [ diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 32399c7..fc06ae2 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -37,6 +37,7 @@ import 'folder_picker.dart' show pickFolderOrDav; import 'trash_page.dart'; import 'dav_external.dart'; import '../platform/windows/recycle_bin.dart'; +import '../core/rsync_filter.dart'; import '../core/sync_preview.dart'; import 'path_label.dart'; import 'sync_preview_view.dart'; @@ -3452,8 +3453,9 @@ class _ExplorerPageState extends State { SyncPreviewView( // 옵션이 바뀌면 (원본 파일 지우기) 다시 비교 key: ValueKey([for (var i = 0; i < tasks.length; i++) runOpts(i)].join('|')), + // 177: rsync 로 맞추면 옵션의 거르기 (--exclude 등) 도 비교에 (WebDAV 는 앱이 직접 - 거르기를 따르지 않음) 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) ...await previewSync(t.sources.first, t.dest, @@ -3461,7 +3463,8 @@ class _ExplorerPageState extends State { delete: deletes(), // 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다 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: 다시 비교하는 동안은 예전 결과로 실행하지 못하게 onStart: () => set(() { diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index f31e1cc..fff0ed7 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../app/settings.dart' show CopyTask; import '../core/file_ops.dart' show formatSize; +import '../core/rsync_filter.dart'; import '../core/sync_preview.dart'; import '../core/sync_tools.dart'; import '../core/vfs.dart'; @@ -212,6 +213,8 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM ? skipNewer || splitOptions(t.options).any((o) => o.toUpperCase() == '/XO') : optionsUpdate(t.options) || (direct && skipNewer); 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( context: context, builder: (ctx) => StatefulBuilder( @@ -243,7 +246,8 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM compute: () async => [ for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update, // robocopy 도 링크를 따라가지 않는다 (/XJ - 옵션 칸에 /SJ · /SL 이 없으면) - skipLinks: direct || (robo && robocopySkipsLinks(splitOptions(t.options)))), + skipLinks: direct || (robo && robocopySkipsLinks(splitOptions(t.options))), + filter: filter), ], onStart: () => set(() { preview = null; diff --git a/test/rsync_filter_test.dart b/test/rsync_filter_test.dart new file mode 100644 index 0000000..0fe6780 --- /dev/null +++ b/test/rsync_filter_test.dart @@ -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> 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 = {}; + 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> 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)); + }); + }); +}