WIP 177 보충 (28 확인): 대상에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 안의 것만 지울 목록에 · ? · [..] 는 rsync 처럼 UTF-8 바이트 단위 · **/ 는 폴더 0개에도 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>next3
parent
45e1a95df0
commit
cda2a30664
|
|
@ -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('[^/]*');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 와 함께면 먼저 도는 왼쪽 → 오른쪽에서 오른쪽에만 있는 것이 지워진다 (그 뒤 ← 로 건너가지 않음)
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
|
|
|
|||
Loading…
Reference in New Issue