WIP 177 Rsync 실행 전 비교 (미리 보기 · 지울 목록) 가 옵션의 거르기 (--exclude · --include · --exclude-from · --filter · --delete-excluded) 를 실제 rsync 와 같게 따름 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>next3
parent
a74e7be59b
commit
722edc8ed1
|
|
@ -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<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 {
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_rsyncmove_');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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>[];
|
||||
Future<void> walk(String s, String d, String rel) async {
|
||||
if (out.length >= limit) return;
|
||||
|
|
@ -117,8 +125,10 @@ Future<List<PreviewItem>> 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<List<PreviewItem>> 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<List<PreviewItem>> previewSync(String src, String dst,
|
|||
/// ⇄ (양쪽 함께, -u): 두 방향을 합친다. 한쪽에만 있는 것은 다른 쪽으로 복사되므로 "한쪽에만 있음" 은 빼고,
|
||||
/// 양쪽에 있고 다른 파일은 더 새 쪽에서 받는 쪽으로 (한 번만).
|
||||
/// [delete]: --delete 와 함께면 먼저 도는 왼쪽 → 오른쪽에서 오른쪽에만 있는 것이 지워진다 (그 뒤 ← 로 건너가지 않음)
|
||||
Future<List<PreviewItem>> 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<List<PreviewItem>> 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 = <String>{};
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -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<ExplorerPage> {
|
|||
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<ExplorerPage> {
|
|||
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(() {
|
||||
|
|
|
|||
|
|
@ -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<bool> 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<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
|
|
@ -243,7 +246,8 @@ Future<bool> 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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue