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); + }); + } + }); }