robocopy 복사도 링크 · junction 을 따라가지 않음 (/XJ 처음 값)

- 데이터 (관리자 지시): 따라가면 원본 바깥 파일을 복사하거나 고리 junction 을 끝없이 돈다 - rsync -a 처럼 복사 · 이동 · lsync 모두 /XJ
- 옵션 칸에 사용자가 넣은 링크 옵션 (/XJ · /XJD · /XJF · /SJ · /SL) 은 그대로. 명령 줄 · 미리 보기도 같게 (robocopyExtraOptions)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-12 00:23:12 +09:00
parent b9739fd71c
commit 976f4633aa
6 changed files with 58 additions and 24 deletions

View File

@ -455,6 +455,8 @@ class LiveSync extends ChangeNotifier {
x.target,
...opts,
if (!opts.any((o) => o.toUpperCase() == '/E')) '/E',
// 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동과 같게 - robocopyExtraOptions)
if (!robocopyLinkOption(opts)) '/XJ',
if (x.delete) '/PURGE',
if (s.copyBandwidthKBps > 0) '/IPG:${robocopyIpg(s.copyBandwidthKBps)}',
'/BYTES', '/NJH', '/NJS', '/NDL', '/NP',

View File

@ -111,12 +111,7 @@ List<List<String>> robocopyRuns({
}) {
// 178: 원본을 지우는 옵션 (/MOV · /MOVE) 은 [move] 로만
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o];
final extra = [
if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}',
if (move) ...robocopyMoveOptions(opts),
// 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" - robocopy 는 기본으로 예전 원본 (Older) 도 덮어쓴다
if (skipNewer && !opts.any((o) => o.toUpperCase() == '/XO')) '/XO',
];
final extra = robocopyExtraOptions(opts, move: move, skipNewer: skipNewer, bandwidthKBps: bandwidthKBps);
final runs = <List<String>>[
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],
];
@ -131,15 +126,32 @@ List<List<String>> robocopyRuns({
return runs;
}
/// 앱이 옵션 칸 ([opts], 원본 지우기 옵션은 뺀 것) 뒤에 붙이는 robocopy 옵션 - 실행 (robocopyRuns) 과 화면의 명령 줄이 같게
List<String> robocopyExtraOptions(List<String> opts, {bool move = false, bool skipNewer = false, int bandwidthKBps = 0}) => [
if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}',
if (move) ...robocopyMoveOptions(opts),
// 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동 모두 처음 값 - rsync -a 가 링크를 링크로 두는 것처럼).
// 따라가면 원본 바깥 폴더의 파일을 옮기고 지우거나 (/MOVE), 고리 junction (AppData 의 "Application Data" 등) 을 끝없이 돈다.
// 옵션 칸에 사용자가 링크 옵션을 넣었으면 그대로
if (!robocopyLinkOption(opts)) '/XJ',
// 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" - robocopy 는 기본으로 예전 원본 (Older) 도 덮어쓴다
if (skipNewer && !opts.any((o) => o.toUpperCase() == '/XO')) '/XO',
];
/// 옵션 칸에 링크 · junction 을 다루는 robocopy 옵션이 있는지 (/XJ · /XJD · /XJF · /SJ · /SL)
bool robocopyLinkOption(List<String> opts) => opts.any((o) => const ['/XJ', '/XJD', '/XJF', '/SJ', '/SL'].contains(o.toUpperCase()));
/// robocopy 가 원본의 링크 · junction 을 건너뛰는지 (미리 보기) - 사용자가 /SJ · /SL 로 링크째 복사를 고르지 않았으면
bool robocopySkipsLinks(List<String> opts) => !opts.any((o) => const ['/SJ', '/SL'].contains(o.toUpperCase()));
/// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함.
/// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인).
/// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다.
/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216).
/// 데이터: 원본 안의 링크 · junction 은 빼고 (/XJ) - robocopy 는 기본으로 junction 을 따라가 /MOVE 가
/// 링크가 가리키는 원본 바깥 폴더의 파일까지 옮기고 지웠다 (Windows 에서 확인). 앱이 직접 할 때 (214) 와 같게 링크는 남긴다
/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216). 링크 · junction 은 [robocopyExtraOptions] 의 /XJ 로 남긴다
/// (robocopy 는 기본으로 junction 을 따라가 /MOVE 가 링크가 가리키는 원본 바깥 폴더의 파일까지 옮기고 지웠다 - Windows 에서 확인)
List<String> robocopyMoveOptions(List<String> opts) => [
'/MOVE',
for (final o in const ['/IS', '/IT', '/IM', '/XJ'])
for (final o in const ['/IS', '/IT', '/IM'])
if (!opts.any((x) => x.toUpperCase() == o)) o,
];

View File

@ -183,25 +183,20 @@ bool taskDeletes(CopyTask t) {
};
}
/// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다.
/// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true
/// 178 · 209: 실제로 실행할 방법 · 옵션 (원본 지우기 옵션은 빼고, 이동일 때만 그 방법의 원본 지우기를 붙인다).
/// 238: robocopy 이동은 실제로 붙는 /MOVE /IS /IT /IM /XJ 까지 (robocopyRuns 와 같게). [skipNewer] (229): robocopy 의 /XO
/// 238: robocopy 는 실제로 붙는 것까지 (이동의 /MOVE /IS /IT /IM, 링크 /XJ, [skipNewer] (229) 의 /XO - robocopyRuns 와 같게)
String effectiveRunText(String method, String options, {required bool move, bool skipNewer = false}) {
final name = method == 'builtin' ? tr('현재 방식') : method;
if (method == 'builtin') return name;
final opts = withoutSourceRemovingOptions(options);
final extra = !move
? ''
: method == 'robocopy'
? ' ${robocopyMoveOptions(splitOptions(opts)).join(' ')}'
: method == 'rsync'
? ' --remove-source-files'
: '';
final xo = method == 'robocopy' && skipNewer && !splitOptions(opts).any((o) => o.toUpperCase() == '/XO') ? ' /XO' : '';
return '$name $opts$extra$xo'.trim();
final extra = method == 'robocopy'
? robocopyExtraOptions(splitOptions(opts), move: move, skipNewer: skipNewer)
: [if (move && method == 'rsync') '--remove-source-files'];
return [name, opts, ...extra].where((x) => x.isNotEmpty).join(' ');
}
/// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다.
/// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true
/// [includeMove] (210): 이동 (원본 파일 지우기) 도 미리 보기 · 확인을 거친다 - 모니터링의 [실행] 처럼 따로 묻는 창이 없을 때.
/// [skipNewer] (229): 앱이 직접 할 때 (현재 방식 · WebDAV) 받는 쪽이 더 새것이면 건너뛰는지 (환경 설정)
Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeMove = false, bool skipNewer = false}) async {
@ -247,8 +242,8 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
SyncPreviewView(
compute: () async => [
for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update,
// robocopy 이동도 링크를 빼고 (/XJ) 남긴다
skipLinks: direct || (robo && t.move)),
// robocopy 도 링크를 따라가지 않는다 (/XJ - 옵션 칸에 /SJ · /SL 이 없으면)
skipLinks: direct || (robo && robocopySkipsLinks(splitOptions(t.options)))),
],
onStart: () => set(() {
preview = null;

View File

@ -317,6 +317,7 @@ void main() {
testWidgets('209: 모니터링 카드는 실제로 실행할 것을 이동 / 복사와 함께 보인다 (복사 카드 칸의 원본 지우기 옵션은 쓰지 않는다고 알림)', (tester) async {
expect(effectiveRunText('rsync', '-avPog', move: true), 'rsync -avPog --remove-source-files');
expect(effectiveRunText('rsync', '-avPog --remove-source-files', move: false), 'rsync -avPog');
expect(effectiveRunText('robocopy', '/E /R:2', move: false), 'robocopy /E /R:2 /XJ'); // 링크 · junction 은 복사도
expect(effectiveRunText('robocopy', '/E /MOV', move: true), 'robocopy /E /MOVE /IS /IT /IM /XJ'); // 238: 실제로 붙는 것까지
expect(effectiveRunText('robocopy', '/E', move: true, skipNewer: true), 'robocopy /E /MOVE /IS /IT /IM /XJ /XO'); // 229
final a = p.join(tmp.path, 'ca'), b = p.join(tmp.path, 'cb');

View File

@ -143,6 +143,19 @@ void main() {
expect(File(p.join(src, 'a.txt')).existsSync(), isFalse, reason: '나머지는 옮김');
});
test('robocopy 복사 (데이터): 원본 안의 junction 을 따라가 원본 바깥 파일을 대상에 복사하지 않는다 (/XJ - rsync -a 처럼)', () async {
if (!Platform.isWindows) return;
final outside = Directory(p.join(p.dirname(src), 'outside_c_${p.basename(src)}'))..createSync();
addTearDown(() => outside.existsSync() ? outside.deleteSync(recursive: true) : null);
File(p.join(outside.path, 'keep.txt')).writeAsStringSync('k');
Link(p.join(src, 'linked')).createSync(outside.path); // junction
final job = TransferJob(sources: [src], dest: dst, move: false, contents: true, method: CopyMethod.robocopy, options: '/E /COPY:DAT /DCOPY:T /R:2 /W:2');
await job.run();
expect(job.error, isNull);
expect(File(p.join(dst, 'a.txt')).existsSync(), isTrue);
expect(File(p.join(dst, 'linked', 'keep.txt')).existsSync(), isFalse, reason: '링크 안의 바깥 파일은 복사하지 않음');
});
test('235 · 229: robocopy 이동도 받는 쪽이 더 새것이면 건너뜀 (환경 설정 → /XO) · 남은 것은 카드 기록에도 (남은 수 · 옮긴 수)', () async {
// 옵션 칸에는 /XO 가 없다 - 환경 설정 (skipNewer) 만으로 robocopy 가 건너뛴다
final old = File(p.join(src, 'old.txt'))..writeAsStringSync('o');

View File

@ -110,6 +110,17 @@ void main() {
expect(rc(true).where(isSourceRemovingOption), ['/MOVE']);
});
test('robocopy 링크 · junction (데이터): 복사 · 이동 모두 처음 값 /XJ, 옵션 칸에 링크 옵션이 있으면 그대로', () {
List<String> rc(String o, {bool move = false}) => robocopyRuns(options: o, folders: [r'C:\a'], files: const [], dest: r'D:\b', move: move).single;
expect(rc('/E'), contains('/XJ'));
expect(rc('/E', move: true), contains('/XJ'));
expect(rc('/E /SJ'), isNot(contains('/XJ')));
expect(rc('/E /xjd').where((o) => o.toUpperCase().startsWith('/XJ')), ['/xjd']);
expect([robocopySkipsLinks(['/E']), robocopySkipsLinks(['/E', '/SJ'])], [true, false]);
final live = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), AppSettings()..robocopyOptions = '/E');
expect(live, contains('/XJ'));
});
test('61: robocopy 파일 줄은 Windows 언어와 상관없이 (일본어 · 중국어 · 한국어 종류 글)', () {
final o = RobocopyOutput();
expect(o.feed('\t 新しいファイル \t\t 3000000\tC:\\t\\a b.txt\r\n 34% \r\n'), [r'C:\t\a b.txt']);