modular_sync #3 OptionText: "rsync 옵션이 있나" 를 popt 규칙 하나로 (rsyncHasOption) - 거르기 값의 u 를 -u 로 오인하던 것 (데이터)

- rsync_options.dart rsyncHasOption(words, short, long): 짧은 묶음은 한 글자씩, 값 자리 (값을 받는 글자 B e f M T @ 의 뒤 ·
  값을 받는 긴 옵션의 다음 낱말) 는 옵션으로 보지 않음. rsyncShortWithArg 는 거르기 해석 (rsync_filter) 과 함께 씀
- "-u 가 있나" 세 곳 (transfer_job._update · withUpdateOption · optionsUpdate) 이 이것을 부름. 예전에는 "-로 시작하는 낱말에 u 가 있으면"
  이라 -f'- build/' · -f "- run/" 를 -u 로 읽었다: 미리 보기는 "받는 쪽이 더 새것은 건너뜀", 실제 rsync 는 덮어씀 · 앱이 맞출 때도 건너뜀
- 고치기 전 판 (512d290) 에서 같은 시험이 틀리게 나옴을 먼저 확인: 판단 [true, true, true] (기대 [true, false, false]) ·
  실제 rsync 는 -avf'- build/' 에서 build.txt 를 보내는데 앱은 -u 로 봄 (c3_before.log). 고친 판에서는 2회 통과
- 약속 시험: 판단 표 + 실제 rsync (받는 쪽이 더 새 파일, 옵션 5가지) - 앱의 -u 판단 = rsync 가 보내는지

사용자가 보게 될 동작: rsync 옵션의 거르기 값에 u 가 들어 있어도 "받는 쪽이 더 새것은 건너뜀" 으로 잘못 보거나 앱이 맞출 때 건너뛰지 않음 (실제 rsync 와 같게)

확인: analyze 0 · 단위 1486 ×2 · 통합 6/6 ×2 · base10 214장 0px · rsync_page ×2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-24 12:39:19 +09:00
parent 512d2904d2
commit bdea662daa
6 changed files with 89 additions and 6 deletions

View File

@ -252,7 +252,7 @@ class TransferJob extends ChangeNotifier {
bool get viaWebDav => isDav(dest) || sources.any(isDav); bool get viaWebDav => isDav(dest) || sources.any(isDav);
/// rsync -u (--update) 를 옵션에 넣었는지 (앱이 맞출 때도 같은 뜻으로: 대상이 더 새것이면 건너뜀) /// rsync -u (--update) 를 옵션에 넣었는지 (앱이 맞출 때도 같은 뜻으로: 대상이 더 새것이면 건너뜀)
bool get _update => splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u'))); bool get _update => rsyncHasOption(splitOptions(options), short: 'u', long: '--update');
/// 100: WebDAV 를 앱이 맞출 때도 rsync 의 지우기 (--delete 계열) 를 따른다 (확인 창에서 지울 목록을 본 뒤에만 실행됨) /// 100: WebDAV 를 앱이 맞출 때도 rsync 의 지우기 (--delete 계열) 를 따른다 (확인 창에서 지울 목록을 본 뒤에만 실행됨)
bool get _delete => method == CopyMethod.rsync && splitOptions(options).any(isRsyncDeleteOption); bool get _delete => method == CopyMethod.rsync && splitOptions(options).any(isRsyncDeleteOption);

View File

@ -1,7 +1,7 @@
import 'dart:convert' show utf8; import 'dart:convert' show utf8;
import 'dart:io' show File, Platform; import 'dart:io' show File, Platform;
import 'sync_tools.dart' show splitOptions; import 'sync_tools.dart' show splitOptions, rsyncShortWithArg;
/// 177: rsync 옵션의 거르기 규칙 (--exclude · --include · --exclude-from · --include-from · --filter "- …" / "+ …") /// 177: rsync 옵션의 거르기 규칙 (--exclude · --include · --exclude-from · --include-from · --filter "- …" / "+ …")
/// 을 실행 전 비교 (미리 보기 · 지울 목록) 에도 똑같이 적용한다. 예전에는 미리 보기가 이 규칙을 보지 않아, /// 을 실행 전 비교 (미리 보기 · 지울 목록) 에도 똑같이 적용한다. 예전에는 미리 보기가 이 규칙을 보지 않아,
@ -137,7 +137,7 @@ class RsyncFilter {
// 예전에는 -f 가 맨 앞일 때만 읽고, 묶음 안의 C · F 를 패턴 글자까지 뒤져 잘못 알렸다 // 예전에는 -f 가 맨 앞일 때만 읽고, 묶음 안의 C · F 를 패턴 글자까지 뒤져 잘못 알렸다
for (var j = 1; j < o.length; j++) { for (var j = 1; j < o.length; j++) {
final ch = o[j]; final ch = o[j];
if ('BefMT@'.contains(ch)) { if (rsyncShortWithArg.contains(ch)) {
var arg = o.substring(j + 1); var arg = o.substring(j + 1);
if (arg.isEmpty && i + 1 < t.length) arg = t[++i]; if (arg.isEmpty && i + 1 < t.length) arg = t[++i];
if (ch == 'f') filterRule(arg); if (ch == 'f') filterRule(arg);

View File

@ -110,7 +110,7 @@ String toCygwinPath(String path, {bool windows = true}) {
/// rsync 옵션에 -u (--update: 받는 쪽이 더 새 파일은 건너뜀) 를 더한다. 이미 있으면 그대로 (-avu 처럼 묶인 것도). /// rsync 옵션에 -u (--update: 받는 쪽이 더 새 파일은 건너뜀) 를 더한다. 이미 있으면 그대로 (-avu 처럼 묶인 것도).
/// 양쪽 (⇄) 으로 함께 맞출 때 서로의 새 파일을 옛 파일로 덮어쓰지 않게. /// 양쪽 (⇄) 으로 함께 맞출 때 서로의 새 파일을 옛 파일로 덮어쓰지 않게.
String withUpdateOption(String options) { String withUpdateOption(String options) {
final has = splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u'))); final has = rsyncHasOption(splitOptions(options), short: 'u', long: '--update');
return has ? options : '${options.trim()} -u'.trim(); return has ? options : '${options.trim()} -u'.trim();
} }

View File

@ -32,6 +32,45 @@ const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"',
/// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로. /// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로.
List<String> splitOptions(String s) => _parseOptions(s).words; List<String> splitOptions(String s) => _parseOptions(s).words;
/// 짧은 옵션 묶음에서 값을 받는 rsync 글자 (popt): 그 글자 뒤의 글 (비었으면 다음 낱말) 이 값 - -avf"- *.log" · -rf "- x"
const rsyncShortWithArg = 'BefMT@';
/// = 없이 다음 낱말을 값으로 받는 rsync 긴 옵션 (--exclude x · --filter "- y")
const _rsyncLongWithArg = {
'address', 'backup-dir', 'block-size', 'bwlimit', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest',
'compress-choice', 'compress-level', 'contimeout', 'copy-as', 'copy-dest', 'debug', 'early-input', 'exclude', 'exclude-from',
'files-from', 'filter', 'groupmap', 'iconv', 'include', 'include-from', 'info', 'link-dest', 'log-file', 'log-file-format',
'max-alloc', 'max-delete', 'max-size', 'min-size', 'modify-window', 'only-write-batch', 'out-format', 'outbuf', 'partial-dir',
'password-file', 'port', 'protocol', 'read-batch', 'remote-option', 'rsh', 'rsync-path', 'skip-compress', 'sockopts',
'stop-after', 'stop-at', 'suffix', 'temp-dir', 'timeout', 'usermap', 'write-batch',
};
/// modular_sync #3 OptionText: 옵션 낱말들 ([splitOptions]) 에 rsync 옵션 [short] (한 글자, 짧은 묶음 안에서도) 또는 [long] (--이름) 이 있는지.
/// popt 처럼 읽는다 - 짧은 묶음은 한 글자씩, 값 자리 (값을 받는 글자 뒤 · 값을 받는 옵션의 다음 낱말) 는 옵션으로 보지 않는다.
/// 예전에는 "-로 시작하는 낱말에 그 글자가 있으면" 으로 봐서 -f'- build/' 의 u 를 -u 로 읽었다 (미리 보기 ≠ 실제)
bool rsyncHasOption(List<String> words, {String? short, String? long}) {
for (var i = 0; i < words.length; i++) {
final o = words[i];
if (o.startsWith('--')) {
final eq = o.indexOf('=');
final name = eq < 0 ? o : o.substring(0, eq);
if (long != null && name == long) return true;
if (eq < 0 && _rsyncLongWithArg.contains(name.substring(2))) i++;
continue;
}
if (o.length < 2 || !o.startsWith('-')) continue;
for (var j = 1; j < o.length; j++) {
final ch = o[j];
if (short != null && ch == short) return true;
if (rsyncShortWithArg.contains(ch)) {
if (j == o.length - 1) i++;
break;
}
}
}
return false;
}
/// rsync 3.x 의 긴 옵션 이름 (앱에 든 rsync 3.5.1 의 --help) - 대시 하나로 붙여 넣은 –delete 를 --delete 로 읽을지 정한다 /// rsync 3.x 의 긴 옵션 이름 (앱에 든 rsync 3.5.1 의 --help) - 대시 하나로 붙여 넣은 –delete 를 --delete 로 읽을지 정한다
const _rsyncLong = { const _rsyncLong = {
'acls', 'address', 'append', 'append-verify', 'archive', 'atimes', 'backup', 'backup-dir', 'block-size', 'blocking-io', 'acls', 'address', 'append', 'append-verify', 'archive', 'atimes', 'backup', 'backup-dir', 'block-size', 'blocking-io',

View File

@ -2,7 +2,7 @@ import 'dart:io' show FileSystemEntity;
import 'file_ops.dart' show isAppTempName, isPartialFile, SourceUnreadableException; import 'file_ops.dart' show isAppTempName, isPartialFile, SourceUnreadableException;
import 'rsync_filter.dart'; import 'rsync_filter.dart';
import 'sync_tools.dart' show splitOptions, isSourceRemovingOption, CopyMethod, robocopyExtraOptions; import 'sync_tools.dart' show splitOptions, isSourceRemovingOption, CopyMethod, robocopyExtraOptions, rsyncHasOption;
export 'sync_tools.dart' show isSourceRemovingOption; export 'sync_tools.dart' show isSourceRemovingOption;
import 'vfs.dart'; import 'vfs.dart';
@ -119,7 +119,7 @@ bool optionsRemoveSource(String options) => splitOptions(options).any(isSourceRe
/// rsync 옵션에 -u (받는 쪽이 더 새 파일은 건너뜀) 가 있는지 /// rsync 옵션에 -u (받는 쪽이 더 새 파일은 건너뜀) 가 있는지
bool optionsUpdate(String options) => bool optionsUpdate(String options) =>
splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u'))); rsyncHasOption(splitOptions(options), short: 'u', long: '--update');
/// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다. /// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다.
/// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상"). /// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상").

View File

@ -0,0 +1,44 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart' show optionsUpdate;
import 'package:jj_mkvmaker/core/sync_tools.dart';
import 'package:path/path.dart' as p;
/// modular_sync #3 OptionText 약속: 옵션 글에 rsync 옵션이 있는지는 popt 규칙 하나 (rsyncHasOption) - 값 자리는 옵션으로 보지 않는다.
/// 예전 -u 확인 세 곳은 "-로 시작하는 낱말에 u 가 있으면" 이라 -f'- build/' 의 u 를 -u 로 읽었다 (미리 보기는 "더 새것 건너뜀", 실제는 덮어씀)
void main() {
test('판단 표: 짧은 묶음 · 값을 받는 글자 뒤 · 값을 받는 옵션의 다음 낱말 · 긴 옵션 · = 값', () {
bool u(String opts) => rsyncHasOption(splitOptions(opts), short: 'u', long: '--update');
expect([u('-avu'), u('-uav'), u('-a --update'), u('-a -u')], [true, true, true, true]);
expect([u('-avHPOg'), u("-avf'- build/'"), u('-av -f "- run/"'), u('--exclude -u.txt -a'), u('--filter "- u*"'), u('--exclude=-u')],
[false, false, false, false, false, false]);
expect(u("-avf'- x' -u"), isTrue, reason: '값 뒤의 진짜 -u');
expect([optionsUpdate('-avu'), optionsUpdate("-avf'- build/'")], [true, false], reason: '미리 보기가 쓰는 판단도 같은 하나');
expect(withUpdateOption("-avf'- build/'"), "-avf'- build/' -u", reason: '⇄ 에서 -u 를 더할 때도 값의 u 를 -u 로 보지 않음');
});
test('실제 rsync: 받는 쪽이 더 새 파일 - 옵션 글마다 앱의 -u 판단과 rsync 가 보내는지가 맞음', () async {
final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('rsync 없음');
final tmp = Directory.systemTemp.createTempSync('jj_opttext_');
addTearDown(() => tmp.deleteSync(recursive: true));
final now = DateTime.now();
File(p.join(tmp.path, 's', 'build.txt'))
..createSync(recursive: true)
..writeAsStringSync('old')
..setLastModifiedSync(now.subtract(const Duration(hours: 1)));
File(p.join(tmp.path, 'd', 'build.txt'))
..createSync(recursive: true)
..writeAsStringSync('NEW!')
..setLastModifiedSync(now);
// 거르기 값에 u 가 든 경우 (- build/ · - run/) 가 예전에 -u 로 오인되던 것 (build.txt 는 거르지 않음)
for (final opts in ['-av', '-avu', "-avf'- build/'", "-avuf'- build/'", '-av -f "- run/"']) {
final args = rsyncArgs(options: opts, sources: [p.join(tmp.path, 's')], dest: p.join(tmp.path, 'd'), contents: true);
final r = await Process.run(rsync, ['--dry-run', '--out-format=%n', ...args]);
expect(r.exitCode, 0, reason: '$opts: ${r.stderr}');
final sends = '${r.stdout}'.split('\n').any((l) => l.trim() == 'build.txt');
expect(sends, !optionsUpdate(opts), reason: '$opts: rsync 가 보내면 -u 아님 (${r.stdout})');
}
});
}