diff --git a/lib/core/rsync_filter.dart b/lib/core/rsync_filter.dart index 8b47198..49f7208 100644 --- a/lib/core/rsync_filter.dart +++ b/lib/core/rsync_filter.dart @@ -1,3 +1,4 @@ +import 'dart:convert' show utf8; import 'dart:io' show File, Platform; import 'sync_tools.dart' show splitOptions; @@ -13,6 +14,7 @@ import 'sync_tools.dart' show splitOptions; /// - `/` (끝 빼고) 나 `**` 가 있으면 경로 전체의 끝부분에, 없으면 이름에만 맞춘다. /// - `이름/***` 은 그 폴더와 안의 모든 것. /// - 거른 폴더 안으로는 내려가지 않는다. 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지운다). +/// - rsync 처럼 바이트 단위: `?` · `[…]` 는 UTF-8 한 바이트 (그래서 `?.txt` 는 "가.txt" 에 맞지 않는다). class RsyncFilter { final List rules; @@ -134,7 +136,8 @@ class RsyncFilterRule { final anchored = p.startsWith('/'); if (anchored) p = p.substring(1); final whole = anchored || p.contains('/') || p.contains('**'); - final body = _glob(p) + (all ? '(?:/.*)?' : ''); + // rsync 의 wildmatch 는 바이트로 맞춘다 - 패턴 · 이름을 UTF-8 바이트 (한 글자 = 한 바이트) 로 바꿔 맞춘다 + final body = _glob(_bytes(p)) + (all ? '(?:/.*)?' : ''); _re = RegExp(anchored ? '^$body\$' : whole ? '^(?:.*/)?$body\$' : '^$body\$'); _whole = whole; } @@ -143,11 +146,14 @@ class RsyncFilterRule { bool matches(String rel, {required bool isDir}) { if (_dirOnly && !isDir) return false; - if (_whole) return _re.hasMatch(rel); + if (_whole) return _re.hasMatch(_bytes(rel)); final name = rel.contains('/') ? rel.substring(rel.lastIndexOf('/') + 1) : rel; - return _re.hasMatch(name); + return _re.hasMatch(_bytes(name)); } + /// UTF-8 바이트를 한 글자씩 (0~255) 으로 - 정규식이 바이트 단위로 맞추게 + static String _bytes(String s) => String.fromCharCodes(utf8.encode(s)); + /// rsync 의 wildmatch 를 정규식으로 static String _glob(String p) { final b = StringBuffer(); @@ -155,11 +161,17 @@ class RsyncFilterRule { 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++; } + // rsync wildmatch: "**/" 는 폴더 0개에도 맞는다 ("**/deep/**" 가 맨 위 deep/x 에도) + if (i + 1 < p.length && p[i + 1] == '/') { + b.write('(?:.*/)?'); + i++; + } else { + b.write('.*'); + } } else { b.write('[^/]*'); } diff --git a/lib/core/sync_preview.dart b/lib/core/sync_preview.dart index af2fb78..e3d0368 100644 --- a/lib/core/sync_preview.dart +++ b/lib/core/sync_preview.dart @@ -104,6 +104,28 @@ Future> previewSync(String src, String dst, RsyncFilter filter = RsyncFilter.none, int limit = 2000}) async { final out = []; + // 177: 받는 쪽에만 있는 폴더 [dir] (상대 경로 [rel]) 안에서 거르지 않은 것만 지울 목록에 (거른 것이 든 폴더는 남김) + Future deleteInside(String dir, String rel) async { + List<({String path, bool isDir, int size, DateTime modified})> items; + try { + items = await vList(dir); + } catch (_) { + return; + } + for (final x in items) { + if (out.length >= limit) return; + final name = vBasename(x.path); + if (isPartialFile(name) || isAppTempName(name)) continue; + final r = '$rel/$name'; + if (filter.excluded(r, isDir: x.isDir)) continue; + if (x.isDir && await _hasExcluded(x.path, r, filter)) { + await deleteInside(x.path, r); + } else { + out.add(PreviewItem(r, PreviewAction.delete, toRight: toRight, isDir: x.isDir, size: x.size)); + } + } + } + Future walk(String s, String d, String rel) async { if (out.length >= limit) return; final there = {}; @@ -166,6 +188,12 @@ Future> previewSync(String src, String dst, } continue; } + // 177 (28 확인): 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 rsync 는 그것을 지키느라 폴더를 남기고 + // 거르지 않은 것만 지운다 - 폴더 통째로 "지워짐" 이 아니라 안의 것만 + if (delete && e.isDir && !filter.isEmpty && !filter.deleteExcluded && await _hasExcluded(e.path, r, filter)) { + await deleteInside(e.path, r); + continue; + } out.add(PreviewItem(r, delete ? PreviewAction.delete : PreviewAction.onlyTarget, toRight: toRight, isDir: e.isDir, size: e.size)); } @@ -175,6 +203,24 @@ Future> previewSync(String src, String dst, return out; } +/// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 걸러지는 것이 있는지 (거른 폴더 안으로는 들어가지 않는다) +Future _hasExcluded(String dir, String rel, RsyncFilter filter) async { + List<({String path, bool isDir, int size, DateTime modified})> items; + try { + items = await vList(dir); + } catch (_) { + return false; + } + for (final x in items) { + final name = vBasename(x.path); + if (isPartialFile(name) || isAppTempName(name)) continue; + final r = '$rel/$name'; + if (filter.excluded(r, isDir: x.isDir)) return true; + if (x.isDir && await _hasExcluded(x.path, r, filter)) return true; + } + return false; +} + /// ⇄ (양쪽 함께, -u): 두 방향을 합친다. 한쪽에만 있는 것은 다른 쪽으로 복사되므로 "한쪽에만 있음" 은 빼고, /// 양쪽에 있고 다른 파일은 더 새 쪽에서 받는 쪽으로 (한 번만). /// [delete]: --delete 와 함께면 먼저 도는 왼쪽 → 오른쪽에서 오른쪽에만 있는 것이 지워진다 (그 뒤 ← 로 건너가지 않음) diff --git a/test/rsync_filter_test.dart b/test/rsync_filter_test.dart index 0fe6780..ac145dd 100644 --- a/test/rsync_filter_test.dart +++ b/test/rsync_filter_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert' show utf8; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -62,6 +63,14 @@ void main() { final g = RsyncFilter.parse('--exclude-from=없음.txt -C --filter=": .rules"', readLines: (_) => null); expect(g.unsupported, hasLength(3)); }); + test('28 확인: 바이트 단위 (? 는 UTF-8 한 바이트) · **/ 는 폴더 0개에도', () { + expect(ex('--exclude=?.txt', '가.txt'), isFalse, reason: '"가" 는 3바이트'); + expect(ex('--exclude=???.txt', '가.txt'), isTrue); + expect(ex('--exclude=[가]*', '가.txt'), isTrue, reason: '[가] 는 첫 바이트에 맞음 (rsync 와 같음)'); + expect(ex('--exclude=**/deep/**', 'deep/x', dir: true), isTrue); + expect(ex('--exclude=**/deep/**', 'a/deep/x'), isTrue); + expect(ex('--exclude=**/deep/**', 'deep', dir: true), isFalse); + }); test('거르기가 없으면 아무것도 거르지 않음 · 묶은 짧은 옵션은 거르기가 아님', () { final f = RsyncFilter.parse('-avPog --delete'); expect(f.isEmpty, isTrue); @@ -81,11 +90,13 @@ void main() { 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']) { + 'build/y.txt', 'cache/z.txt', 'docs/build/q.txt', 'docs/r.txt', '가.txt', 'ab.bak']) { 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']) { + // 28 확인: 받는 쪽에만 있는 폴더 안에 거른 것 (old/keep.log · onlydst/a.bak · deep/x/keep.log) 이 있으면 폴더는 남는다 + for (final r in ['extra.txt', 'extra.log', 'old.tmp', 'build/old.o', 'cache/stale.txt', 'sub/gone.txt', 'sub/gone.log', + 'old/keep.log', 'old/gone.txt', 'onlydst/a.bak', 'onlydst/b.txt', 'deep/x/keep.log', 'deep/x/y.txt', 'deep/z.txt', 'plain/p.txt']) { f(dst, r); } }); @@ -96,7 +107,9 @@ void main() { /// 실제 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)}/']); + // -8: 한글 이름을 \#ooo 로 바꾸지 않게 · 출력은 UTF-8 + final r = await Process.run(rsync, ['-r', '-8', '--delete', '-n', '-i', ...splitOptions(opts), '${toCygwinPath(src)}/', '${toCygwinPath(dst)}/'], + stdoutEncoding: utf8, stderrEncoding: utf8); expect(r.exitCode, 0, reason: '${r.stderr}'); final out = {}; for (final l in (r.stdout as String).split('\n')) { @@ -112,6 +125,12 @@ void main() { out[path] = 'update'; } } + // -n 은 실제로는 못 지우는 폴더 (안에 거른 것이 남음) 도 "*deleting 폴더/" 로 적는다 - 바로 그 폴더를 + // "cannot delete non-empty directory" 로 알린다. 실제 실행에서는 남으므로 지워짐에서 뺀다 (실제 결과 기준) + for (final l in '${r.stdout}\n${r.stderr}'.split('\n')) { + final m = RegExp(r'cannot delete non-empty directory: (.+)$').firstMatch(l.trimRight()); + if (m != null) out.remove(m[1]); + } // 지워지는 폴더 안의 것은 빼고 폴더 하나로 (미리 보기처럼) 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/'))); @@ -148,6 +167,17 @@ void main() { '--filter="- *.txt"', '--exclude=[bk]*.log', '--exclude=?.txt', + // 28 확인 (10/19): 바이트 단위 ? (가.txt 는 3바이트라 ?.txt 에 맞지 않음) · 대상에만 있는 폴더 안의 거른 것 + '--exclude=??.bak', + '--exclude=???.txt', + '--exclude=[가]*', + '--include=*/ --include=*.txt --exclude=*', + '--exclude=keep.log', + '--exclude=*.bak', + '--exclude=deep/x/keep.log', + '--exclude=/deep/x/', + '--exclude=*.log --exclude=old/', + '--exclude=keep.log --delete-excluded', ]) { test('패턴 "$opts"', () async { if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');