From 09fbe0337a17b4f1e97e4ca5a1ad34fb37b38246 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:45:33 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20177=20=EB=B3=B4=EC=B6=A9=202:=2028=20?= =?UTF-8?q?=EC=9D=98=20=EC=9E=A5=EB=A9=B4=20=C2=B7=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?19=EA=B0=9C=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20rsync=20=EC=99=80?= =?UTF-8?q?=20=EB=B9=84=EA=B5=90=ED=95=98=EB=8A=94=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=C2=B7=20=EA=B0=80=EC=9A=B4=EB=8D=B0=20"/**/"=20=EB=8A=94=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=ED=95=98=EB=82=98=20=EC=9D=B4=EC=83=81=20?= =?UTF-8?q?(=EB=A7=A8=20=EC=95=9E=20"**/"=20=EB=A7=8C=200=EA=B0=9C)=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 --- lib/core/rsync_filter.dart | 5 ++- test/rsync_filter_test.dart | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/lib/core/rsync_filter.dart b/lib/core/rsync_filter.dart index 49f7208..a1ef1a0 100644 --- a/lib/core/rsync_filter.dart +++ b/lib/core/rsync_filter.dart @@ -165,8 +165,9 @@ class RsyncFilterRule { while (i + 1 < p.length && p[i + 1] == '*') { i++; } - // rsync wildmatch: "**/" 는 폴더 0개에도 맞는다 ("**/deep/**" 가 맨 위 deep/x 에도) - if (i + 1 < p.length && p[i + 1] == '/') { + // rsync wildmatch: 패턴 맨 앞의 "**/" 는 폴더 0개에도 맞는다 ("**/deep/**" 가 맨 위 deep/x 에도). + // 가운데의 "/**/" 는 폴더 하나 이상 ("foo/**/bar" 는 foo/bar 에 맞지 않음 - 실제 rsync 확인) + if (b.isEmpty && i + 1 < p.length && p[i + 1] == '/') { b.write('(?:.*/)?'); i++; } else { diff --git a/test/rsync_filter_test.dart b/test/rsync_filter_test.dart index ac145dd..5437822 100644 --- a/test/rsync_filter_test.dart +++ b/test/rsync_filter_test.dart @@ -195,4 +195,87 @@ void main() { expect(await preview(opts), await real(opts)); }); }); + + // 28 (10/19) 이 비교에 쓴 장면과 옵션 19개 그대로 - 옵션을 rsync 에 그대로 (+ -8 -n -i), 미리 보기는 그 옵션의 거르기 · 지우기 + group('28 의 장면 · 옵션 19개 = 실제 rsync', () { + late Directory tmp; + late String s, d; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_rsfilter28_'); + s = p.join(tmp.path, 's'); + d = p.join(tmp.path, 'd'); + for (final r in ['a.txt', 'b.log', 'build/x.o', 'build/sub/y.o', 'sub/c.txt', 'sub/deep/d.txt', 'sub/deep/e.log', + 'node_modules/m/i.js', 'src/node_modules/n.js', 'docs/readme.md', 'docs/img/p.png', '한글 폴더/가.txt', 'space dir/s.bak', + 'Keep.TXT', 'tmp/t.txt', 'x/tmp/t2.txt', 'foo/bar', 'foo/q/bar']) { + File(p.join(s, r)) + ..createSync(recursive: true) + ..writeAsStringSync('s:$r'); + } + for (final r in ['extra.txt', 'keep.log', 'build/old.o', 'old/keep.log', 'old/gone.txt', 'sub/deep/z.txt', 'tmp/old.txt', + 'onlydst/a.bak']) { + File(p.join(d, r)) + ..createSync(recursive: true) + ..writeAsStringSync('d:$r'); + } + }); + tearDown(() => tmp.deleteSync(recursive: true)); + final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe'); + + for (final (i, opts) in [ + '-a --delete --exclude=*.log --exclude=build/ --exclude=/sub/deep/', + '-a --delete --exclude=*.log', + '-a --delete --include=*/ --include=*.txt --exclude=*', + '-a --delete --exclude=node_modules/', + '-a --delete --exclude=/tmp/', + '-a --delete --exclude=tmp/', + '-a --delete --exclude=sub/deep', + '-a --delete --exclude=docs/***', + '-a --delete --exclude=foo/**/bar', + '-a --delete --exclude=**/bar', + '-a --delete --exclude=*.[lb][oa][gk]', + '-a --delete --exclude="space dir/"', + '-a --delete --exclude=한글*/', + '-a --delete --exclude=*.bak --delete-excluded', + '-a --delete --exclude=*.log --delete-excluded', + '-a --delete --filter="- *.o" --filter="+ build/"', + '-a --delete --include=sub/deep/d.txt --exclude=sub/deep/*', + '-a --delete --exclude=?.txt', + '-a --delete --exclude=*.TXT', + ].indexed) { + test('${i + 1}: $opts', () async { + if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음'); + final r = await Process.run(rsync, ['-8', '-n', '-i', ...splitOptions(opts), '${toCygwinPath(s)}/', '${toCygwinPath(d)}/'], + stdoutEncoding: utf8, stderrEncoding: utf8); + expect(r.exitCode, 0, reason: '${r.stderr}'); + final want = {}; + for (final l in (r.stdout as String).split('\n')) { + final x = l.trimRight(); + if (x.length < 13) continue; + final code = x.substring(0, 11), path = x.substring(12).replaceAll(RegExp(r'/$'), ''); + if (path == '.' || path.isEmpty) continue; + if (code.startsWith('*deleting')) { + want[path] = 'delete'; + } else if (code.contains('+++++')) { + want[path] = 'add'; + } else if (code[0] == '>' || code[0] == '<') { + want[path] = 'update'; + } + } + // -n 이 적는 "*deleting 폴더/" 중 실제로는 못 지우는 것 (안에 거른 것이 남음) 은 빼고, 지워지는 폴더 안의 것은 폴더 하나로 + 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) want.remove(m[1]); + } + final gone = [for (final e in want.entries) if (e.value == 'delete') e.key]; + want.removeWhere((k, v) => v == 'delete' && gone.any((g) => k.startsWith('$g/'))); + final items = await previewSync(s, d, toRight: true, delete: optionsDelete(opts), filter: RsyncFilter.parse(opts)); + final got = { + for (final x in items) + if (x.action == PreviewAction.add || x.action == PreviewAction.update || x.action == PreviewAction.delete) + x.rel: x.action == PreviewAction.add ? 'add' : x.action == PreviewAction.update ? 'update' : 'delete', + }; + expect(got, want); + }); + } + }); }