robocopy 이동은 원본 안의 링크 · junction 을 따라가지 않음 (/XJ, 데이터)

- 데이터 (버그, 개발 중 Windows 에서 재현): robocopy /E /MOVE 가 junction 을 따라가 원본 바깥 폴더의 파일을 옮기고 지웠다
- 이동에 /XJ - 링크는 원본에 남긴다 (앱이 직접 할 때 214 와 같게). rsync 는 링크를 링크로 옮겨 문제없음 (확인)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-12 00:08:38 +09:00
parent d86a929ba1
commit f1206ae1f2
5 changed files with 27 additions and 8 deletions

View File

@ -134,10 +134,12 @@ List<List<String>> robocopyRuns({
/// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함. /// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함.
/// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인). /// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인).
/// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다. /// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다.
/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216) /// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216).
/// 데이터: 원본 안의 링크 · junction 은 빼고 (/XJ) - robocopy 는 기본으로 junction 을 따라가 /MOVE 가
/// 링크가 가리키는 원본 바깥 폴더의 파일까지 옮기고 지웠다 (Windows 에서 확인). 앱이 직접 할 때 (214) 와 같게 링크는 남긴다
List<String> robocopyMoveOptions(List<String> opts) => [ List<String> robocopyMoveOptions(List<String> opts) => [
'/MOVE', '/MOVE',
for (final o in const ['/IS', '/IT', '/IM']) for (final o in const ['/IS', '/IT', '/IM', '/XJ'])
if (!opts.any((x) => x.toUpperCase() == o)) o, if (!opts.any((x) => x.toUpperCase() == o)) o,
]; ];

View File

@ -186,7 +186,7 @@ bool taskDeletes(CopyTask t) {
/// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다. /// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다.
/// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true /// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true
/// 178 · 209: 실제로 실행할 방법 · 옵션 (원본 지우기 옵션은 빼고, 이동일 때만 그 방법의 원본 지우기를 붙인다). /// 178 · 209: 실제로 실행할 방법 · 옵션 (원본 지우기 옵션은 빼고, 이동일 때만 그 방법의 원본 지우기를 붙인다).
/// 238: robocopy 이동은 실제로 붙는 /MOVE /IS /IT /IM 까지 (robocopyRuns 와 같게). [skipNewer] (229): robocopy 의 /XO /// 238: robocopy 이동은 실제로 붙는 /MOVE /IS /IT /IM /XJ 까지 (robocopyRuns 와 같게). [skipNewer] (229): robocopy 의 /XO
String effectiveRunText(String method, String options, {required bool move, bool skipNewer = false}) { String effectiveRunText(String method, String options, {required bool move, bool skipNewer = false}) {
final name = method == 'builtin' ? tr('현재 방식') : method; final name = method == 'builtin' ? tr('현재 방식') : method;
if (method == 'builtin') return name; if (method == 'builtin') return name;
@ -246,7 +246,9 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
const SizedBox(height: 8), const SizedBox(height: 8),
SyncPreviewView( SyncPreviewView(
compute: () async => [ compute: () async => [
for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update, skipLinks: direct), for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update,
// robocopy 이동도 링크를 빼고 (/XJ) 남긴다
skipLinks: direct || (robo && t.move)),
], ],
onStart: () => set(() { onStart: () => set(() {
preview = null; preview = null;

View File

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

View File

@ -128,6 +128,21 @@ void main() {
expect(job.leftInSource, 0); expect(job.leftInSource, 0);
}); });
test('robocopy 이동 (데이터): 원본 안의 junction 을 따라가 원본 바깥 폴더의 파일을 옮기고 지우지 않는다 (/XJ)', () async {
if (!Platform.isWindows) return;
final outside = Directory(p.join(p.dirname(src), 'outside_${p.basename(src)}'))..createSync();
addTearDown(() => outside.existsSync() ? outside.deleteSync(recursive: true) : null);
final keep = 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: true, contents: true, method: CopyMethod.robocopy, options: '/E /COPY:DAT /DCOPY:T /R:2 /W:2');
await job.run();
expect(job.error, isNull);
expect(keep.existsSync(), isTrue, reason: '링크가 가리키는 바깥 파일은 그대로');
expect(File(p.join(dst, 'linked', 'keep.txt')).existsSync(), isFalse, reason: '링크 안의 것은 옮기지 않음');
expect(Link(p.join(src, 'linked')).existsSync(), isTrue, reason: '링크는 원본에 남김');
expect(File(p.join(src, 'a.txt')).existsSync(), isFalse, reason: '나머지는 옮김');
});
test('235 · 229: robocopy 이동도 받는 쪽이 더 새것이면 건너뜀 (환경 설정 → /XO) · 남은 것은 카드 기록에도 (남은 수 · 옮긴 수)', () async { test('235 · 229: robocopy 이동도 받는 쪽이 더 새것이면 건너뜀 (환경 설정 → /XO) · 남은 것은 카드 기록에도 (남은 수 · 옮긴 수)', () async {
// 옵션 칸에는 /XO 가 없다 - 환경 설정 (skipNewer) 만으로 robocopy 가 건너뛴다 // 옵션 칸에는 /XO 가 없다 - 환경 설정 (skipNewer) 만으로 robocopy 가 건너뛴다
final old = File(p.join(src, 'old.txt'))..writeAsStringSync('o'); final old = File(p.join(src, 'old.txt'))..writeAsStringSync('o');

View File

@ -61,8 +61,8 @@ void main() {
bandwidthKBps: 640, bandwidthKBps: 640,
move: true, move: true,
); );
expect(runs[0], [r'C:\a\movies', p.join(r'E:\dst', 'movies'), '/E', '/R:2', '/IPG:100', '/MOVE', '/IS', '/IT', '/IM']); expect(runs[0], [r'C:\a\movies', p.join(r'E:\dst', 'movies'), '/E', '/R:2', '/IPG:100', '/MOVE', '/IS', '/IT', '/IM', '/XJ']);
expect(runs[1], [r'C:\b', r'E:\dst', '1.mkv', '2.mkv', '/R:2', '/IPG:100', '/MOV', '/IS', '/IT', '/IM']); // 파일만: /E 빼고 /MOV. 217: 이동은 같은 파일도 (/IS · /IT · /IM) expect(runs[1], [r'C:\b', r'E:\dst', '1.mkv', '2.mkv', '/R:2', '/IPG:100', '/MOV', '/IS', '/IT', '/IM', '/XJ']); // 파일만: /E 빼고 /MOV. 217: 이동은 같은 파일도 (/IS · /IT · /IM), 링크는 빼고 (/XJ)
expect(runs[2].sublist(0, 3), [r'C:\c', r'E:\dst', '3.mkv']); expect(runs[2].sublist(0, 3), [r'C:\c', r'E:\dst', '3.mkv']);
expect(robocopyIpg(0), 0); expect(robocopyIpg(0), 0);
// 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" → /XO (옵션 칸에 있으면 두 번 넣지 않음) // 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" → /XO (옵션 칸에 있으면 두 번 넣지 않음)