using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using MergeHub.Views.Controls;
using System.Threading;
using System.Threading.Tasks;
namespace MergeHub.Services
{
/// 폴더 비교에서 한 항목이 어떤 상태인지.
public enum FolderEntryStatus
{
/// 양쪽이 같다.
Same,
/// 양쪽에 있고 내용이 다르다.
Different,
/// 왼쪽에만 있다.
LeftOnly,
/// 오른쪽에만 있다.
RightOnly,
/// 내용이 다르지만 이진 파일이라 줄 단위로 볼 수 없다.
Binary,
///
/// 코드는 같고 주석·공백만 다르다.
///
/// 따로 두는 이유 - 봐야 할 성격이 다르다. 주석만 손댄 파일은 눈으로 훑고 넘길 수 있는데
/// 다른 것과 같은 색으로 섞여 있으면 전부 열어 봐야 한다.
///
CommentOnly
}
///
/// 바이트는 다른데 설정 때문에 "같음" 으로 본 까닭(10차 F-2).
///
/// 설정 [C# 코드 스타일 맞춤] 이 켜져 있으면 중괄호 자리만 다른 파일, [비교 · 병합에서 머지 주석 무시] 가 켜져 있으면
/// 머지 주석 줄만 다른 파일을 같음으로 본다(6차 B-5 · E-1). 그런데 화면에는 그냥 "같음" 이라 크기가 다른 파일이
/// "같은 것만" 목록에 섞여 내용이 다른 파일처럼 보였다. 같음은 그대로 두고 왜 같은지를 따로 적는다(사용자 결정).
///
public enum SameReason
{
/// 바이트가 같다(또는 빠른 비교에서 크기 · 수정일이 같다).
None,
/// C# 중괄호 자리만 다르다.
Style,
/// 머지 주석 줄(또는 줄 끝 머지 주석)만 다르다.
MergeNotes,
/// 둘 다를 맞춰야 같다.
StyleAndMergeNotes
}
/// 폴더 비교 목록의 상태 글자. 화면과 검사가 같은 글을 쓴다(10차 F-1 · F-2).
public static class FolderStatusText
{
public static string Of(FolderEntryStatus status, SameReason reason) => status switch
{
FolderEntryStatus.Same => reason switch
{
SameReason.Style => "같음(스타일만 다름)",
SameReason.MergeNotes => "같음(머지 주석만 다름)",
SameReason.StyleAndMergeNotes => "같음(스타일 · 머지 주석만 다름)",
_ => "같음"
},
FolderEntryStatus.Different => "다름",
FolderEntryStatus.LeftOnly => "왼쪽만",
FolderEntryStatus.RightOnly => "오른쪽만",
FolderEntryStatus.Binary => "다름(이진)",
FolderEntryStatus.CommentOnly => "주석만 다름",
_ => string.Empty
};
///
/// "같은 것만 · 왼쪽에만 · 오른쪽에만" 으로 걸러 볼 때 폴더 줄에 적을 글. 걸린 파일 수를 적는다.
/// 폴더 자신의 상태(안에 다른 것이 하나라도 있으면 "다름")를 적으면 "같은 것만" 목록에 주황 "다름" 줄이 섞여
/// 내용이 다른 것이 보이는 것처럼 읽혔다(10차 F-1).
///
public static string FilteredFolder(FolderEntryStatus kind, int count) => kind switch
{
FolderEntryStatus.Same => $"같음 {count}개",
FolderEntryStatus.LeftOnly => $"왼쪽만 {count}개",
FolderEntryStatus.RightOnly => $"오른쪽만 {count}개",
_ => $"{count}개"
};
}
/// 폴더 비교 규칙.
public sealed class FolderCompareOptions
{
///
/// 빠른 비교. 내용을 읽지 않고 크기와 수정 시각만 본다.
/// 네트워크 드라이브나 수만 개짜리 폴더에서 몇 분이 몇 초로 줄어든다.
///
public bool QuickCompare { get; set; }
///
/// 이 크기를 넘는 파일은 빠른 비교로 돌린다.
/// 전체 비교를 골랐더라도 수백 MB 짜리를 바이트로 훑는 것은 득보다 실이 크다.
///
public long FullCompareLimitBytes { get; set; } = 8L * 1024 * 1024;
/// 포함할 파일 정규식. 비우면 전부 포함.
public string IncludePattern { get; set; } = string.Empty;
/// 제외할 파일·폴더 정규식. 비우면 제외 없음. 포함 규칙보다 우선한다.
public string ExcludePattern { get; set; } = string.Empty;
/// 하위 폴더까지 훑을지.
public bool IncludeSubfolders { get; set; } = true;
///
/// 머지 주석(// [이름] 날짜)만 다른 파일을 같은 파일로 볼지(6차 B-5).
/// 주석 줄을 빼고 줄 끝 주석을 뗀 글이 같으면 "같음" 이다. 줄끝 · 인코딩 차이는 가리지 않는다.
///
public bool IgnoreMergeNotes { get; set; }
///
/// C# 파일을 이 스타일로 맞춘 뒤 견줄지(6차 추가). 중괄호 줄바꿈만 다른 파일은 "같음" 이다.
/// 줄끝 · 인코딩 차이는 가리지 않는다.
///
public CSharpBraceStyle CSharpStyle { get; set; }
///
/// 수정 시각을 같다고 볼 오차. 파일 시스템마다 정밀도가 달라(FAT 는 2초) 정확히 같기를
/// 기대하면 안 된다.
///
public TimeSpan TimeTolerance { get; set; } = TimeSpan.FromSeconds(2);
}
/// 비교 결과 한 항목. 폴더면 Children 이 채워진다.
public sealed class FolderCompareItem
{
public FolderCompareItem(string name, string relativePath, bool isDirectory)
{
Name = name;
RelativePath = relativePath;
IsDirectory = isDirectory;
}
public string Name { get; }
/// 두 뿌리 폴더 기준의 상대 경로. 양쪽 실제 경로를 만들 때 쓴다.
public string RelativePath { get; }
public bool IsDirectory { get; }
public FolderEntryStatus Status { get; set; }
/// 설정 때문에 같음으로 본 까닭. 같음이 아니거나 바이트가 같으면 None(10차 F-2).
public SameReason SameReason { get; set; }
public bool LeftExists { get; set; }
public bool RightExists { get; set; }
public long LeftSize { get; set; }
public long RightSize { get; set; }
public DateTime? LeftModified { get; set; }
public DateTime? RightModified { get; set; }
public List Children { get; } = new List();
public bool IsDifferent => Status != FolderEntryStatus.Same;
}
public sealed class FolderCompareResult
{
public List Items { get; } = new List();
public int TotalFiles { get; set; }
public int SameCount { get; set; }
public int DifferentCount { get; set; }
public int LeftOnlyCount { get; set; }
public int RightOnlyCount { get; set; }
/// 그중 코드는 같고 주석만 다른 것. "다름" 안에 든 수다.
public int CommentOnlyCount { get; set; }
/// 항목 상한에 걸려 도중에 멈췄는지.
public bool Truncated { get; set; }
public string Summary =>
$"전체 {TotalFiles}개 · 다름 {DifferentCount}"
+ (CommentOnlyCount > 0 ? $"(주석만 {CommentOnlyCount})" : string.Empty)
+ $" · 왼쪽만 {LeftOnlyCount} · 오른쪽만 {RightOnlyCount} · 같음 {SameCount}"
+ (Truncated ? " (너무 많아 일부만 봄)" : string.Empty);
}
///
/// 폴더 두 개를 재귀로 견준다.
///
/// 파일 하나하나를 여는 것이 비용의 전부라서, 열기 전에 걸러낼 수 있는 것은 모두 먼저 거른다 -
/// 크기가 다르면 내용을 볼 필요가 없고, 빠른 비교면 아예 열지 않는다.
///
public static class FolderComparer
{
/// 이 개수를 넘으면 멈춘다. 실수로 C:\ 를 고른 경우에 화면이 굳는 것을 막는다.
private const int MaxEntries = 50_000;
///
/// 큰 폴더를 잘못 골랐을 때 기다리는 수밖에 없으면 안 된다.
/// 항목 하나를 볼 때마다 확인하므로 누르는 즉시 멈춘다.
///
public static FolderCompareResult Compare(string leftRoot, string rightRoot, FolderCompareOptions options,
CancellationToken cancel = default)
{
var result = new FolderCompareResult();
if (string.IsNullOrWhiteSpace(leftRoot) || string.IsNullOrWhiteSpace(rightRoot)) return result;
if (!Directory.Exists(leftRoot) && !Directory.Exists(rightRoot)) return result;
var include = Compile(options.IncludePattern);
var exclude = Compile(options.ExcludePattern);
// 세 걸음으로 나눈다.
// ① 훑기 - 폴더를 돌며 트리를 세운다. 파일은 "아직 안 가린 것"으로 모아 둔다.
// ② 가리기 - 모아 둔 것을 여러 갈래로 나눠 가린다. 여기가 파일을 여는 자리다.
// ③ 세기 - 트리 차례대로 폴더 상태를 정하고 개수를 센다.
//
// 예전에는 훑으면서 그 자리에서 가렸다. 그러면 파일을 여는 동안 다음 폴더로 못 간다.
// 나눠 놓으면 가리는 일만 여러 갈래로 돌릴 수 있고, 결과 차례는 트리가 정하므로
// 몇 갈래로 돌리든 똑같이 나온다 - 나눠 돌린 것이 화면에 티가 나면 안 된다.
var pending = new List();
var count = 0;
Walk(leftRoot, rightRoot, string.Empty, options, include, exclude, result.Items, pending, ref count, cancel);
ClassifyAll(pending, options, cancel);
Tally(result.Items, result);
result.Truncated = count >= MaxEntries;
return result;
}
///
/// 여기부터 여러 갈래로 나눈다. 그 아래는 나눠 봐야 갈래를 세우는 값이 더 든다.
///
private const int ParallelThreshold = 64;
/// 아직 무엇인지 가리지 않은 파일 하나. 경로를 다시 만들지 않으려고 같이 들고 다닌다.
private readonly struct Pending
{
public Pending(FolderCompareItem item, string leftPath, string rightPath)
{
Item = item;
LeftPath = leftPath;
RightPath = rightPath;
}
public FolderCompareItem Item { get; }
public string LeftPath { get; }
public string RightPath { get; }
}
/// 모아 둔 파일을 가린다. 몇 개 안 되면 그냥 차례로 한다.
private static void ClassifyAll(List pending, FolderCompareOptions options, CancellationToken cancel)
{
if (pending.Count < ParallelThreshold)
{
foreach (var p in pending)
{
cancel.ThrowIfCancellationRequested();
p.Item.Status = Classify(p.Item, p.LeftPath, p.RightPath, options);
}
return;
}
var parallel = new ParallelOptions { CancellationToken = cancel };
Parallel.ForEach(pending, parallel, p => p.Item.Status = Classify(p.Item, p.LeftPath, p.RightPath, options));
}
///
/// 트리를 차례대로 돌며 폴더 상태를 정하고 개수를 센다.
/// 세는 일은 반드시 한 갈래로 한다 - 여러 갈래로 더하면 값이 어긋난다.
///
private static void Tally(List items, FolderCompareResult result)
{
foreach (var item in items)
{
if (item.IsDirectory)
{
Tally(item.Children, result);
item.Status = FolderStatus(item);
continue;
}
result.TotalFiles++;
switch (item.Status)
{
case FolderEntryStatus.Same: result.SameCount++; break;
// "주석만 다름"도 다름의 한 갈래다. 둘 다 센다 - 요약이 "다름 3(주석만 1)" 로 읽힌다.
case FolderEntryStatus.CommentOnly:
result.DifferentCount++;
result.CommentOnlyCount++;
break;
case FolderEntryStatus.LeftOnly: result.LeftOnlyCount++; break;
case FolderEntryStatus.RightOnly: result.RightOnlyCount++; break;
default: result.DifferentCount++; break;
}
}
}
///
/// 폴더 하나를 훑어 트리를 세운다. 파일은 아직 가리지 않고 에 모아 둔다.
///
/// 이름만 얻고 크기·시각을 다시 묻는 대신 로 훑는다.
/// 목록을 얻을 때 크기와 수정 시각이 딸려 오기 때문이다. 예전에는 파일 하나마다
/// File.Exists 한 번과 new FileInfo(...) 한 번을 양쪽에 물었다 -
/// 4,500개면 그것만 만 팔천 번이고, 실제로 훑는 시간의 열에 아홉이 여기였다.
///
private static void Walk(string leftRoot, string rightRoot, string relative,
FolderCompareOptions options, Regex? include, Regex? exclude,
List into, List pending, ref int count,
CancellationToken cancel)
{
cancel.ThrowIfCancellationRequested();
if (count >= MaxEntries) return;
var leftDir = Path.Combine(leftRoot, relative);
var rightDir = Path.Combine(rightRoot, relative);
// ---- 파일 ----
var files = new SortedDictionary(StringComparer.OrdinalIgnoreCase);
foreach (var info in SafeFiles(leftDir))
{
files[info.Name] = new SidePair(info, null);
}
foreach (var info in SafeFiles(rightDir))
{
files[info.Name] = files.TryGetValue(info.Name, out var got)
? new SidePair(got.Left, info)
: new SidePair(null, info);
}
foreach (var pair in files)
{
cancel.ThrowIfCancellationRequested();
if (count >= MaxEntries) return;
var name = pair.Key;
var childRelative = string.IsNullOrEmpty(relative) ? name : Path.Combine(relative, name);
if (IsFilteredOut(name, childRelative, include, exclude)) continue;
var item = new FolderCompareItem(name, childRelative, isDirectory: false)
{
LeftExists = pair.Value.Left is not null,
RightExists = pair.Value.Right is not null,
};
if (pair.Value.Left is { } left)
{
item.LeftSize = left.Length;
item.LeftModified = left.LastWriteTime;
}
if (pair.Value.Right is { } right)
{
item.RightSize = right.Length;
item.RightModified = right.LastWriteTime;
}
pending.Add(new Pending(item, Path.Combine(leftDir, name), Path.Combine(rightDir, name)));
into.Add(item);
count++;
}
if (!options.IncludeSubfolders) return;
// ---- 하위 폴더 ----
var dirNames = new SortedSet(StringComparer.OrdinalIgnoreCase);
foreach (var info in SafeDirs(leftDir)) dirNames.Add(info.Name);
foreach (var info in SafeDirs(rightDir)) dirNames.Add(info.Name);
foreach (var name in dirNames)
{
if (count >= MaxEntries) return;
var childRelative = string.IsNullOrEmpty(relative) ? name : Path.Combine(relative, name);
// 폴더는 제외 규칙만 본다. 포함 규칙은 파일 이름에 거는 것이라 폴더에 걸면 트리가 통째로 사라진다.
if (exclude is not null && (exclude.IsMatch(name) || exclude.IsMatch(childRelative))) continue;
var item = new FolderCompareItem(name, childRelative, isDirectory: true)
{
LeftExists = Directory.Exists(Path.Combine(leftDir, name)),
RightExists = Directory.Exists(Path.Combine(rightDir, name)),
};
Walk(leftRoot, rightRoot, childRelative, options, include, exclude, item.Children, pending, ref count, cancel);
// 알맹이가 하나도 없는 폴더는 목록만 어지럽힌다. 한쪽에만 있는 폴더는 남긴다.
// 폴더 상태는 아래 것들이 다 가려진 뒤에 정한다(Tally).
if (item.Children.Count == 0 && item.LeftExists && item.RightExists) continue;
into.Add(item);
count++;
}
}
/// 같은 이름의 양쪽 파일. 없는 쪽은 null.
private readonly struct SidePair
{
public SidePair(FileInfo? left, FileInfo? right)
{
Left = left;
Right = right;
}
public FileInfo? Left { get; }
public FileInfo? Right { get; }
}
/// 폴더 상태는 아래에 담긴 것들로 정한다.
private static FolderEntryStatus FolderStatus(FolderCompareItem folder)
{
if (!folder.RightExists) return FolderEntryStatus.LeftOnly;
if (!folder.LeftExists) return FolderEntryStatus.RightOnly;
foreach (var child in folder.Children)
{
if (child.Status != FolderEntryStatus.Same) return FolderEntryStatus.Different;
}
return FolderEntryStatus.Same;
}
private static FolderEntryStatus Classify(FolderCompareItem item, string leftPath, string rightPath,
FolderCompareOptions options)
{
if (!item.RightExists) return FolderEntryStatus.LeftOnly;
if (!item.LeftExists) return FolderEntryStatus.RightOnly;
// 크기가 다르면 내용을 볼 필요가 없다.
if (item.LeftSize != item.RightSize) return DifferentKind(item, leftPath, rightPath, Math.Max(item.LeftSize, item.RightSize), options);
var quick = options.QuickCompare || item.LeftSize > options.FullCompareLimitBytes;
if (quick)
{
var left = item.LeftModified ?? DateTime.MinValue;
var right = item.RightModified ?? DateTime.MinValue;
var gap = left > right ? left - right : right - left;
return gap <= options.TimeTolerance
? FolderEntryStatus.Same
: DifferentKind(item, leftPath, rightPath, Math.Max(item.LeftSize, item.RightSize), options);
}
return SameContent(leftPath, rightPath)
? FolderEntryStatus.Same
: DifferentKind(item, leftPath, rightPath, Math.Max(item.LeftSize, item.RightSize), options);
}
///
/// 다른 파일을 어떤 "다름"으로 볼지.
///
/// 이진이면 줄 단위 비교 화면을 열어도 볼 것이 없으니 따로 표시한다.
/// 글 파일이면 코드는 같고 주석만 다른지를 한 번 더 본다.
///
private static FolderEntryStatus DifferentKind(FolderCompareItem item, string leftPath, string rightPath, long size,
FolderCompareOptions options)
{
if (TextFile.IsBinary(leftPath) || TextFile.IsBinary(rightPath)) return FolderEntryStatus.Binary;
if ((options.IgnoreMergeNotes || options.CSharpStyle != CSharpBraceStyle.None) &&
OnlyMergeNotesDiffer(leftPath, rightPath, size, options))
{
item.SameReason = WhySame(leftPath, rightPath, size, options);
return FolderEntryStatus.Same;
}
return OnlyCommentsDiffer(leftPath, rightPath, size, options)
? FolderEntryStatus.CommentOnly
: FolderEntryStatus.Different;
}
///
/// 코드는 같고 주석·공백만 다른가.
///
/// 판단은 주석 토큰을 걷어내고 공백을 지운 뒤 견주는 것이다. 주석 기호는 언어마다 다르므로
/// 확장자로 고른 언어표를 따른다. 언어를 모르는 파일(평문·표 등)은 이 판단을 하지 않는다 -
/// 주석이라는 것이 없다.
///
/// 줄 구분은 코드가 있는 줄에만 남긴다. 그래야 주석 한 줄을 통째로 지운 경우에도
/// 같다고 볼 수 있고, 반대로 코드를 다른 줄로 옮긴 것은 다르다고 본다.
///
/// 큰 파일은 하지 않는다. 전체 비교에서 이미 걸러 둔 한도를 그대로 쓴다 -
/// 폴더 하나에 수천 개가 있을 때 이 판단만으로 몇 분이 더 들면 안 된다.
///
private static bool OnlyCommentsDiffer(string leftPath, string rightPath, long size,
FolderCompareOptions options)
{
if (size > options.FullCompareLimitBytes) return false;
var profile = LanguageCatalog.ForPath(leftPath);
if (!profile.IsCode) return false;
// 확장자가 서로 다르면 같은 언어로 볼 근거가 없다.
if (!ReferenceEquals(profile, LanguageCatalog.ForPath(rightPath))) return false;
var left = CodeOnly(leftPath, profile);
if (left is null) return false;
var right = CodeOnly(rightPath, profile);
if (right is null) return false;
return string.Equals(left, right, StringComparison.Ordinal);
}
///
/// 머지 주석만 다른가(6차 B-5). 주석만 있는 줄은 빼고, 줄 끝 주석은 떼고 견준다.
///
/// 어느 쪽에서도 주석을 뗀 것이 없으면 볼 것도 없다 - 바이트가 다른 까닭이 주석이 아니다.
/// 줄끝(\r)은 그대로 남겨 견준다. 주석을 핑계로 줄끝 바꿈까지 "같음" 이 되면 안 된다.
///
///
/// 설정으로 같음이 된 까닭(10차 F-2). 한 가지만 켜서도 같은지 차례로 본다 - 스타일만으로 같으면 스타일,
/// 머지 주석만으로 같으면 머지 주석, 둘 다 맞춰야 같으면 둘 다. 같음이 된 파일만 이 값을 치르므로 비교 시간에는 거의 안 든다.
///
private static SameReason WhySame(string leftPath, string rightPath, long size, FolderCompareOptions options)
{
var styleOnly = new FolderCompareOptions { CSharpStyle = options.CSharpStyle, IgnoreMergeNotes = false, FullCompareLimitBytes = options.FullCompareLimitBytes };
if (options.CSharpStyle != CSharpBraceStyle.None && OnlyMergeNotesDiffer(leftPath, rightPath, size, styleOnly))
return SameReason.Style;
var notesOnly = new FolderCompareOptions { CSharpStyle = CSharpBraceStyle.None, IgnoreMergeNotes = true, FullCompareLimitBytes = options.FullCompareLimitBytes };
if (options.IgnoreMergeNotes && OnlyMergeNotesDiffer(leftPath, rightPath, size, notesOnly))
return SameReason.MergeNotes;
return options.IgnoreMergeNotes && options.CSharpStyle != CSharpBraceStyle.None
? SameReason.StyleAndMergeNotes
: options.IgnoreMergeNotes ? SameReason.MergeNotes : SameReason.Style;
}
private static bool OnlyMergeNotesDiffer(string leftPath, string rightPath, long size,
FolderCompareOptions options)
{
if (size > options.FullCompareLimitBytes) return false;
var left = WithoutMergeNotes(leftPath, options, out var leftCut);
if (left is null) return false;
var right = WithoutMergeNotes(rightPath, options, out var rightCut);
if (right is null) return false;
return (leftCut || rightCut) && string.Equals(left, right, StringComparison.Ordinal);
}
///
/// 머지 주석을 걷어내고(켰으면) C# 스타일을 맞춘(골랐으면) 글. 읽지 못하면 null.
/// 하나라도 바꾼 것이 있으면 이 참.
/// 스타일 맞춤은 줄끝을 그 파일의 줄끝으로 다시 이으므로, 양쪽 줄끝이 다르면 여전히 다르게 나온다.
///
private static string? WithoutMergeNotes(string path, FolderCompareOptions options, out bool cut)
{
cut = false;
try
{
var original = TextFile.Read(path);
var text = Services.CSharpStyle.Applies(path, options.CSharpStyle)
? Services.CSharpStyle.Format(original, options.CSharpStyle)
: original;
cut = !ReferenceEquals(text, original);
if (!options.IgnoreMergeNotes) return text;
var builder = new System.Text.StringBuilder(text.Length);
foreach (var raw in text.Split('\n'))
{
var hasCr = raw.EndsWith("\r", StringComparison.Ordinal);
var line = hasCr ? raw.Substring(0, raw.Length - 1) : raw;
if (MergeNote.LooksLikeNote(line, path))
{
cut = true;
continue;
}
var at = MergeNote.TailStart(line, path);
if (at > 0)
{
line = line.Substring(0, at);
cut = true;
}
builder.Append(line).Append(hasCr ? "\r\n" : "\n");
}
return builder.ToString();
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
/// 주석과 공백을 걷어낸 글. 읽지 못하면 null.
private static string? CodeOnly(string path, LanguageProfile profile)
{
try
{
var text = TextFile.Read(path);
var builder = new System.Text.StringBuilder(text.Length);
var carry = CarryOver.None;
foreach (var line in text.Split('\n'))
{
var body = line.TrimEnd('\r');
var tokens = CodeTokenizer.Tokenize(body, profile, carry, out carry);
var isComment = new bool[body.Length];
foreach (var token in tokens)
{
if (token.Kind != CodeTokenKind.Comment && token.Kind != CodeTokenKind.XmlDoc) continue;
var end = Math.Min(body.Length, token.Start + token.Length);
for (var i = Math.Max(0, token.Start); i < end; i++) isComment[i] = true;
}
var before = builder.Length;
for (var i = 0; i < body.Length; i++)
{
if (isComment[i] || char.IsWhiteSpace(body[i])) continue;
builder.Append(body[i]);
}
// 코드가 한 글자라도 있던 줄에만 줄 구분을 남긴다.
if (builder.Length > before) builder.Append('\n');
}
return builder.ToString();
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
/// 내용을 앞에서부터 덩어리째 견준다. 다른 곳을 만나면 바로 멈춘다.
private static bool SameContent(string leftPath, string rightPath)
{
const int BufferSize = 64 * 1024;
try
{
using var left = File.OpenRead(leftPath);
using var right = File.OpenRead(rightPath);
if (left.Length != right.Length) return false;
var a = new byte[BufferSize];
var b = new byte[BufferSize];
while (true)
{
var readA = ReadBlock(left, a);
var readB = ReadBlock(right, b);
if (readA != readB) return false;
if (readA == 0) return true;
for (var i = 0; i < readA; i++)
{
if (a[i] != b[i]) return false;
}
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
private static int ReadBlock(Stream stream, byte[] buffer)
{
var total = 0;
while (total < buffer.Length)
{
var read = stream.Read(buffer, total, buffer.Length - total);
if (read == 0) break;
total += read;
}
return total;
}
private static bool IsFilteredOut(string name, string relative, Regex? include, Regex? exclude)
{
if (exclude is not null && (exclude.IsMatch(name) || exclude.IsMatch(relative))) return true;
if (include is not null && !include.IsMatch(name) && !include.IsMatch(relative)) return true;
return false;
}
/// 규칙이 깨져 있으면 규칙이 없는 것으로 본다. 화면이 예외로 죽는 것보다 낫다.
private static Regex? Compile(string pattern)
{
if (string.IsNullOrWhiteSpace(pattern)) return null;
try
{
return new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
catch (ArgumentException)
{
return null;
}
}
///
/// 크기·수정 시각까지 한 번에 얻는다. Directory.GetFiles 는 이름만 주므로
/// 나중에 파일마다 한 번씩 더 물어야 한다 - 그것이 훑는 시간의 대부분이었다.
///
private static FileInfo[] SafeFiles(string dir)
{
try
{
var info = new DirectoryInfo(dir);
return info.Exists ? info.GetFiles() : Array.Empty();
}
catch (UnauthorizedAccessException) { return Array.Empty(); }
catch (IOException) { return Array.Empty(); }
}
private static DirectoryInfo[] SafeDirs(string dir)
{
try
{
var info = new DirectoryInfo(dir);
return info.Exists ? info.GetDirectories() : Array.Empty();
}
catch (UnauthorizedAccessException) { return Array.Empty(); }
catch (IOException) { return Array.Empty(); }
}
}
}