WIP 177 보충 (28 확인): 대상에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 안의 것만 지울 목록에 · ? · [..] 는 rsync 처럼 UTF-8 바이트 단위 · **/ 는 폴더 0개에도 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next3
RyuJongJin 2026-10-19 21:40:42 +09:00
parent 45e1a95df0
commit cda2a30664
3 changed files with 95 additions and 7 deletions

View File

@ -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<RsyncFilterRule> 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('[^/]*');
}

View File

@ -104,6 +104,28 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
RsyncFilter filter = RsyncFilter.none,
int limit = 2000}) async {
final out = <PreviewItem>[];
// 177: 받는 쪽에만 있는 폴더 [dir] (상대 경로 [rel]) 안에서 거르지 않은 것만 지울 목록에 (거른 것이 든 폴더는 남김)
Future<void> 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<void> walk(String s, String d, String rel) async {
if (out.length >= limit) return;
final there = <String, ({String path, bool isDir, int size, DateTime modified})>{};
@ -166,6 +188,12 @@ Future<List<PreviewItem>> 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<List<PreviewItem>> previewSync(String src, String dst,
return out;
}
/// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 걸러지는 것이 있는지 (거른 폴더 안으로는 들어가지 않는다)
Future<bool> _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 와 함께면 먼저 도는 왼쪽 → 오른쪽에서 오른쪽에만 있는 것이 지워진다 (그 뒤 ← 로 건너가지 않음)

View File

@ -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<Map<String, String>> 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 = <String, String>{};
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)');