사용자가 시험 묶음 측정 화면에서 "같은 것만" 에 내용이 다른 파일이 보인다고 알림. 비교 판정은 설계대로(코드 값이 다르면 어느 설정에서도 다름)였고, 화면에서 - F-1: 같은 파일을 품은 폴더 줄이 폴더 자신의 상태 "다름"(주황)으로 보였다 - F-2: 설정(C# 스타일 맞춤 · 머지 주석 무시)으로 같음이 된 파일이 크기가 달라도 그냥 "같음" - FolderComparer: SameReason 을 남기고 FolderStatusText 로 글자를 정함 - FolderViewModel: 같은 것만 · 왼쪽에만 · 오른쪽에만이면 폴더 줄을 "같음 122개" 처럼 걸린 수와 그 갈래 색으로 - FolderView: 줄 색이 DisplayStatus, 상태 칸 90 → 200 - 사용자 결정: 같음으로 두고 "같음(스타일만 다름)" · "같음(머지 주석만 다름)" 으로 표시 - 하네스 FolderTest +15 (77건), 22벌 1,146건 통과, 빌드 경고 0 오류 0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
750 lines
33 KiB
C#
750 lines
33 KiB
C#
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
|
|
{
|
|
/// <summary>폴더 비교에서 한 항목이 어떤 상태인지.</summary>
|
|
public enum FolderEntryStatus
|
|
{
|
|
/// <summary>양쪽이 같다.</summary>
|
|
Same,
|
|
/// <summary>양쪽에 있고 내용이 다르다.</summary>
|
|
Different,
|
|
/// <summary>왼쪽에만 있다.</summary>
|
|
LeftOnly,
|
|
/// <summary>오른쪽에만 있다.</summary>
|
|
RightOnly,
|
|
/// <summary>내용이 다르지만 이진 파일이라 줄 단위로 볼 수 없다.</summary>
|
|
Binary,
|
|
|
|
/// <summary>
|
|
/// <b>코드는 같고 주석·공백만</b> 다르다.
|
|
///
|
|
/// 따로 두는 이유 - 봐야 할 성격이 다르다. 주석만 손댄 파일은 눈으로 훑고 넘길 수 있는데
|
|
/// 다른 것과 같은 색으로 섞여 있으면 전부 열어 봐야 한다.
|
|
/// </summary>
|
|
CommentOnly
|
|
}
|
|
|
|
/// <summary>
|
|
/// 바이트는 다른데 설정 때문에 "같음" 으로 본 까닭(10차 F-2).
|
|
///
|
|
/// 설정 [C# 코드 스타일 맞춤] 이 켜져 있으면 중괄호 자리만 다른 파일, [비교 · 병합에서 머지 주석 무시] 가 켜져 있으면
|
|
/// 머지 주석 줄만 다른 파일을 같음으로 본다(6차 B-5 · E-1). 그런데 화면에는 그냥 "같음" 이라 크기가 다른 파일이
|
|
/// "같은 것만" 목록에 섞여 내용이 다른 파일처럼 보였다. 같음은 그대로 두고 왜 같은지를 따로 적는다(사용자 결정).
|
|
/// </summary>
|
|
public enum SameReason
|
|
{
|
|
/// <summary>바이트가 같다(또는 빠른 비교에서 크기 · 수정일이 같다).</summary>
|
|
None,
|
|
/// <summary>C# 중괄호 자리만 다르다.</summary>
|
|
Style,
|
|
/// <summary>머지 주석 줄(또는 줄 끝 머지 주석)만 다르다.</summary>
|
|
MergeNotes,
|
|
/// <summary>둘 다를 맞춰야 같다.</summary>
|
|
StyleAndMergeNotes
|
|
}
|
|
|
|
/// <summary>폴더 비교 목록의 상태 글자. 화면과 검사가 같은 글을 쓴다(10차 F-1 · F-2).</summary>
|
|
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
|
|
};
|
|
|
|
/// <summary>
|
|
/// "같은 것만 · 왼쪽에만 · 오른쪽에만" 으로 걸러 볼 때 폴더 줄에 적을 글. 걸린 파일 수를 적는다.
|
|
/// 폴더 자신의 상태(안에 다른 것이 하나라도 있으면 "다름")를 적으면 "같은 것만" 목록에 주황 "다름" 줄이 섞여
|
|
/// 내용이 다른 것이 보이는 것처럼 읽혔다(10차 F-1).
|
|
/// </summary>
|
|
public static string FilteredFolder(FolderEntryStatus kind, int count) => kind switch
|
|
{
|
|
FolderEntryStatus.Same => $"같음 {count}개",
|
|
FolderEntryStatus.LeftOnly => $"왼쪽만 {count}개",
|
|
FolderEntryStatus.RightOnly => $"오른쪽만 {count}개",
|
|
_ => $"{count}개"
|
|
};
|
|
}
|
|
|
|
/// <summary>폴더 비교 규칙.</summary>
|
|
public sealed class FolderCompareOptions
|
|
{
|
|
/// <summary>
|
|
/// 빠른 비교. 내용을 읽지 않고 <b>크기와 수정 시각</b>만 본다.
|
|
/// 네트워크 드라이브나 수만 개짜리 폴더에서 몇 분이 몇 초로 줄어든다.
|
|
/// </summary>
|
|
public bool QuickCompare { get; set; }
|
|
|
|
/// <summary>
|
|
/// 이 크기를 넘는 파일은 빠른 비교로 돌린다.
|
|
/// 전체 비교를 골랐더라도 수백 MB 짜리를 바이트로 훑는 것은 득보다 실이 크다.
|
|
/// </summary>
|
|
public long FullCompareLimitBytes { get; set; } = 8L * 1024 * 1024;
|
|
|
|
/// <summary>포함할 파일 정규식. 비우면 전부 포함.</summary>
|
|
public string IncludePattern { get; set; } = string.Empty;
|
|
|
|
/// <summary>제외할 파일·폴더 정규식. 비우면 제외 없음. 포함 규칙보다 우선한다.</summary>
|
|
public string ExcludePattern { get; set; } = string.Empty;
|
|
|
|
/// <summary>하위 폴더까지 훑을지.</summary>
|
|
public bool IncludeSubfolders { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// 머지 주석(<c>// [이름] 날짜</c>)만 다른 파일을 같은 파일로 볼지(6차 B-5).
|
|
/// 주석 줄을 빼고 줄 끝 주석을 뗀 글이 같으면 "같음" 이다. 줄끝 · 인코딩 차이는 가리지 않는다.
|
|
/// </summary>
|
|
public bool IgnoreMergeNotes { get; set; }
|
|
|
|
/// <summary>
|
|
/// C# 파일을 이 스타일로 맞춘 뒤 견줄지(6차 추가). 중괄호 줄바꿈만 다른 파일은 "같음" 이다.
|
|
/// 줄끝 · 인코딩 차이는 가리지 않는다.
|
|
/// </summary>
|
|
public CSharpBraceStyle CSharpStyle { get; set; }
|
|
|
|
/// <summary>
|
|
/// 수정 시각을 같다고 볼 오차. 파일 시스템마다 정밀도가 달라(FAT 는 2초) 정확히 같기를
|
|
/// 기대하면 안 된다.
|
|
/// </summary>
|
|
public TimeSpan TimeTolerance { get; set; } = TimeSpan.FromSeconds(2);
|
|
}
|
|
|
|
/// <summary>비교 결과 한 항목. 폴더면 Children 이 채워진다.</summary>
|
|
public sealed class FolderCompareItem
|
|
{
|
|
public FolderCompareItem(string name, string relativePath, bool isDirectory)
|
|
{
|
|
Name = name;
|
|
RelativePath = relativePath;
|
|
IsDirectory = isDirectory;
|
|
}
|
|
|
|
public string Name { get; }
|
|
|
|
/// <summary>두 뿌리 폴더 기준의 상대 경로. 양쪽 실제 경로를 만들 때 쓴다.</summary>
|
|
public string RelativePath { get; }
|
|
|
|
public bool IsDirectory { get; }
|
|
public FolderEntryStatus Status { get; set; }
|
|
|
|
/// <summary>설정 때문에 같음으로 본 까닭. 같음이 아니거나 바이트가 같으면 None(10차 F-2).</summary>
|
|
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<FolderCompareItem> Children { get; } = new List<FolderCompareItem>();
|
|
|
|
public bool IsDifferent => Status != FolderEntryStatus.Same;
|
|
}
|
|
|
|
public sealed class FolderCompareResult
|
|
{
|
|
public List<FolderCompareItem> Items { get; } = new List<FolderCompareItem>();
|
|
|
|
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; }
|
|
|
|
/// <summary>그중 코드는 같고 주석만 다른 것. "다름" 안에 든 수다.</summary>
|
|
public int CommentOnlyCount { get; set; }
|
|
|
|
/// <summary>항목 상한에 걸려 도중에 멈췄는지.</summary>
|
|
public bool Truncated { get; set; }
|
|
|
|
public string Summary =>
|
|
$"전체 {TotalFiles}개 · 다름 {DifferentCount}"
|
|
+ (CommentOnlyCount > 0 ? $"(주석만 {CommentOnlyCount})" : string.Empty)
|
|
+ $" · 왼쪽만 {LeftOnlyCount} · 오른쪽만 {RightOnlyCount} · 같음 {SameCount}"
|
|
+ (Truncated ? " (너무 많아 일부만 봄)" : string.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 폴더 두 개를 재귀로 견준다.
|
|
///
|
|
/// 파일 하나하나를 여는 것이 비용의 전부라서, 열기 전에 걸러낼 수 있는 것은 모두 먼저 거른다 -
|
|
/// 크기가 다르면 내용을 볼 필요가 없고, 빠른 비교면 아예 열지 않는다.
|
|
/// </summary>
|
|
public static class FolderComparer
|
|
{
|
|
/// <summary>이 개수를 넘으면 멈춘다. 실수로 C:\ 를 고른 경우에 화면이 굳는 것을 막는다.</summary>
|
|
private const int MaxEntries = 50_000;
|
|
|
|
/// <param name="cancel">
|
|
/// 큰 폴더를 잘못 골랐을 때 기다리는 수밖에 없으면 안 된다.
|
|
/// 항목 하나를 볼 때마다 확인하므로 누르는 즉시 멈춘다.
|
|
/// </param>
|
|
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);
|
|
|
|
// 세 걸음으로 나눈다.
|
|
// ① 훑기 - 폴더를 돌며 트리를 세운다. 파일은 "아직 안 가린 것"으로 모아 둔다.
|
|
// ② 가리기 - 모아 둔 것을 여러 갈래로 나눠 가린다. 여기가 파일을 여는 자리다.
|
|
// ③ 세기 - 트리 차례대로 폴더 상태를 정하고 개수를 센다.
|
|
//
|
|
// 예전에는 훑으면서 그 자리에서 가렸다. 그러면 <b>파일을 여는 동안 다음 폴더로 못 간다.</b>
|
|
// 나눠 놓으면 가리는 일만 여러 갈래로 돌릴 수 있고, <b>결과 차례는 트리가 정하므로
|
|
// 몇 갈래로 돌리든 똑같이 나온다</b> - 나눠 돌린 것이 화면에 티가 나면 안 된다.
|
|
var pending = new List<Pending>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 여기부터 여러 갈래로 나눈다. 그 아래는 나눠 봐야 <b>갈래를 세우는 값이 더 든다.</b>
|
|
/// </summary>
|
|
private const int ParallelThreshold = 64;
|
|
|
|
/// <summary>아직 무엇인지 가리지 않은 파일 하나. 경로를 다시 만들지 않으려고 같이 들고 다닌다.</summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>모아 둔 파일을 가린다. 몇 개 안 되면 그냥 차례로 한다.</summary>
|
|
private static void ClassifyAll(List<Pending> 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 트리를 차례대로 돌며 폴더 상태를 정하고 개수를 센다.
|
|
/// <b>세는 일은 반드시 한 갈래로 한다</b> - 여러 갈래로 더하면 값이 어긋난다.
|
|
/// </summary>
|
|
private static void Tally(List<FolderCompareItem> 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 폴더 하나를 훑어 트리를 세운다. <b>파일은 아직 가리지 않고</b> <paramref name="pending"/> 에 모아 둔다.
|
|
///
|
|
/// 이름만 얻고 크기·시각을 다시 묻는 대신 <see cref="DirectoryInfo"/> 로 훑는다.
|
|
/// 목록을 얻을 때 크기와 수정 시각이 <b>딸려 오기 때문</b>이다. 예전에는 파일 하나마다
|
|
/// <c>File.Exists</c> 한 번과 <c>new FileInfo(...)</c> 한 번을 양쪽에 물었다 -
|
|
/// 4,500개면 그것만 만 팔천 번이고, 실제로 훑는 시간의 열에 아홉이 여기였다.
|
|
/// </summary>
|
|
private static void Walk(string leftRoot, string rightRoot, string relative,
|
|
FolderCompareOptions options, Regex? include, Regex? exclude,
|
|
List<FolderCompareItem> into, List<Pending> 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<string, SidePair>(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<string>(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++;
|
|
}
|
|
}
|
|
|
|
/// <summary>같은 이름의 양쪽 파일. 없는 쪽은 null.</summary>
|
|
private readonly struct SidePair
|
|
{
|
|
public SidePair(FileInfo? left, FileInfo? right)
|
|
{
|
|
Left = left;
|
|
Right = right;
|
|
}
|
|
|
|
public FileInfo? Left { get; }
|
|
public FileInfo? Right { get; }
|
|
}
|
|
|
|
/// <summary>폴더 상태는 아래에 담긴 것들로 정한다.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 다른 파일을 어떤 "다름"으로 볼지.
|
|
///
|
|
/// 이진이면 줄 단위 비교 화면을 열어도 볼 것이 없으니 따로 표시한다.
|
|
/// 글 파일이면 <b>코드는 같고 주석만 다른지</b>를 한 번 더 본다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 코드는 같고 <b>주석·공백만</b> 다른가.
|
|
///
|
|
/// 판단은 주석 토큰을 걷어내고 공백을 지운 뒤 견주는 것이다. 주석 기호는 언어마다 다르므로
|
|
/// 확장자로 고른 언어표를 따른다. 언어를 모르는 파일(평문·표 등)은 이 판단을 하지 않는다 -
|
|
/// 주석이라는 것이 없다.
|
|
///
|
|
/// 줄 구분은 <b>코드가 있는 줄에만</b> 남긴다. 그래야 주석 한 줄을 통째로 지운 경우에도
|
|
/// 같다고 볼 수 있고, 반대로 코드를 다른 줄로 옮긴 것은 다르다고 본다.
|
|
///
|
|
/// 큰 파일은 하지 않는다. 전체 비교에서 이미 걸러 둔 한도를 그대로 쓴다 -
|
|
/// 폴더 하나에 수천 개가 있을 때 이 판단만으로 몇 분이 더 들면 안 된다.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 머지 주석만 다른가(6차 B-5). 주석만 있는 줄은 빼고, 줄 끝 주석은 떼고 견준다.
|
|
///
|
|
/// 어느 쪽에서도 주석을 뗀 것이 없으면 볼 것도 없다 - 바이트가 다른 까닭이 주석이 아니다.
|
|
/// 줄끝(<c>\r</c>)은 그대로 남겨 견준다. 주석을 핑계로 줄끝 바꿈까지 "같음" 이 되면 안 된다.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// 설정으로 같음이 된 까닭(10차 F-2). 한 가지만 켜서도 같은지 차례로 본다 - 스타일만으로 같으면 스타일,
|
|
/// 머지 주석만으로 같으면 머지 주석, 둘 다 맞춰야 같으면 둘 다. 같음이 된 파일만 이 값을 치르므로 비교 시간에는 거의 안 든다.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 머지 주석을 걷어내고(켰으면) C# 스타일을 맞춘(골랐으면) 글. 읽지 못하면 null.
|
|
/// 하나라도 바꾼 것이 있으면 <paramref name="cut"/> 이 참.
|
|
/// 스타일 맞춤은 줄끝을 그 파일의 줄끝으로 다시 이으므로, 양쪽 줄끝이 다르면 여전히 다르게 나온다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>주석과 공백을 걷어낸 글. 읽지 못하면 null.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>내용을 앞에서부터 덩어리째 견준다. 다른 곳을 만나면 바로 멈춘다.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>규칙이 깨져 있으면 규칙이 없는 것으로 본다. 화면이 예외로 죽는 것보다 낫다.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 크기·수정 시각까지 한 번에 얻는다. <c>Directory.GetFiles</c> 는 이름만 주므로
|
|
/// 나중에 파일마다 한 번씩 더 물어야 한다 - 그것이 훑는 시간의 대부분이었다.
|
|
/// </summary>
|
|
private static FileInfo[] SafeFiles(string dir)
|
|
{
|
|
try
|
|
{
|
|
var info = new DirectoryInfo(dir);
|
|
return info.Exists ? info.GetFiles() : Array.Empty<FileInfo>();
|
|
}
|
|
catch (UnauthorizedAccessException) { return Array.Empty<FileInfo>(); }
|
|
catch (IOException) { return Array.Empty<FileInfo>(); }
|
|
}
|
|
|
|
private static DirectoryInfo[] SafeDirs(string dir)
|
|
{
|
|
try
|
|
{
|
|
var info = new DirectoryInfo(dir);
|
|
return info.Exists ? info.GetDirectories() : Array.Empty<DirectoryInfo>();
|
|
}
|
|
catch (UnauthorizedAccessException) { return Array.Empty<DirectoryInfo>(); }
|
|
catch (IOException) { return Array.Empty<DirectoryInfo>(); }
|
|
}
|
|
}
|
|
}
|