C# 코드 스타일 맞춤(AURORA · Default)과 발표 시연 재료 demo 폴더

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-14 14:05:50 +09:00
parent eb43385e14
commit 6bd2bb7619
39 changed files with 16955 additions and 12 deletions

49
demo/README.md Normal file
View File

@ -0,0 +1,49 @@
# MergeHub 시연 재료
발표 시연에 쓰는 파일과 저장소다. **실제 업무 소스는 쓰지 않는다** - 전부 지어낸 코드다.
## 새로 만들기
```powershell
powershell -ExecutionPolicy Bypass -File demo\make-demo.ps1
```
- 아래 `plain` · `style` · `mergetool` · `big` 을 지우고 다시 만든다.
- 병합 시연 저장소 `D:\MergeHubDemo\repo` 를 지우고 다시 만든다(`-RepoRoot` 로 바꿀 수 있다). 시연 한 번 할 때마다 다시 만들면 된다.
- 만드는 코드는 `tools\DemoMaker` 에 있다. C# 스타일 파일은 MergeHub 의 정리기(`src\MergeHub\Services\CSharpStyle.cs`)로 만든다.
## 들어 있는 것
| 폴더 | 시연 | 볼 것 |
|---|---|---|
| `plain\left` · `plain\right` | 폴더 비교 | 같음 `Same.cs` · 다름 `CodeDiff.cs` · 주석만 다름 `CommentOnly.cs` · 머지 주석만 다름 `MergeNoteOnly.cs` · C# 스타일만 다름 `StyleOnly.cs` · 한쪽만 `LeftOnly.cs` / `RightOnly.cs` · XAML `Views\Recipe.xaml` · CP949 한글 `한글설명.cs` · 이진 `Logo.png` |
| `style\Default.cs` · `style\Aurora.cs` | C# 스타일 맞춤 | 스타일 끔 **남은 차이 39줄** → AURORA 또는 Default **1줄**(`RetryCount` 3 → 5, 진짜 변경) |
| `mergetool\` | `git mergetool` 네 칸 | BASE · LOCAL · REMOTE 세 벌, 결과 `Weld.cs` 는 git 모양 충돌 표시. 충돌 1 · 상대만 고친 줄 1 |
| `big\` | 큰 파일 | 7,479줄. 끝까지 줄 번호 · "마지막 차이" 로 멀리 건너뛰기 |
| `D:\MergeHubDemo\repo` | 병합 | 아래 |
mergetool 네 칸으로 열기:
```powershell
& "D:\Sorce\MergeTool\MergeHub\src\MergeHub\bin\Debug\net48\UbisamBase.Shell.exe" --merge `
--base demo\mergetool\Weld_BASE.cs --local demo\mergetool\Weld_LOCAL.cs `
--remote demo\mergetool\Weld_REMOTE.cs --merged demo\mergetool\Weld.cs
```
## 병합 시연 저장소
`feature/mine` 에 서 있다. MergeHub Git 탭에서 `feature/theirs` 를 골라 [Merge → 현재 브랜치로].
| 파일 | 경우 | MergeHub 에서 |
|---|---|---|
| `Line/Conveyor.cs` | 같은 줄을 양쪽이 다르게 고침 | 충돌 → 네 칸에서 줄 단위로 |
| `Line/Laser.cs` | 코드 줄 위에 서로 다른 머지 주석 줄 | **자동으로 풀림**, 두 주석이 다 남음 |
| `Line/Camera.cs` | 코드 줄 끝에 서로 다른 머지 주석 | **자동으로 풀림**, 한 줄 끝에 두 주석 |
| `Line/Style.cs` | 내 쪽 AURORA + 값 4, 상대 Default + 값 6 | 충돌. C# AURORA 를 고르면 남은 차이 39줄 → 1줄 |
| `Line/NewPanel.cs` | 양쪽이 같은 이름으로 새 파일 | 충돌(BASE 없음) |
| `Line/ToBeDeleted.cs` | 내가 고침 · 상대가 지움 | [파일 남기기] / [파일 지우기] |
| `Line/한글설명.cs` | CP949 한글 파일에서 같은 줄 | 충돌, 저장해도 CP949 유지 |
| `Views/Main.xaml` | XAML 여러 줄 태그 안 | 충돌 |
| `Line/Gripper.cs` · `Line/Heater.cs` | 서로 다른 자리 · 상대만 고침 | 자동 병합 |
git 으로만 병합하면 충돌 8개, MergeHub 로 병합하면 머지 주석만 다른 2개를 스스로 풀어 **6개**가 남는다.

7479
demo/big/Big7479_left.cs Normal file

File diff suppressed because it is too large Load Diff

7479
demo/big/Big7479_right.cs Normal file

File diff suppressed because it is too large Load Diff

20
demo/make-demo.ps1 Normal file
View File

@ -0,0 +1,20 @@
# MergeHub 발표 시연 재료를 새로 만든다. 실제 업무 소스는 쓰지 않는다.
#
# powershell -ExecutionPolicy Bypass -File demo\make-demo.ps1
# powershell -ExecutionPolicy Bypass -File demo\make-demo.ps1 -RepoRoot E:\시연\repo
#
# 만드는 것
# demo\plain\left · right 폴더 비교
# demo\style\Default.cs · Aurora.cs C# 스타일 맞춤
# demo\mergetool\... git mergetool 네 칸
# demo\big\... 7,479줄 큰 파일
# -RepoRoot (기본 D:\MergeHubDemo\repo) 병합 시연 저장소 - 매번 지우고 새로 만든다
param(
[string]$RepoRoot = "D:\MergeHubDemo\repo"
)
$ErrorActionPreference = "Stop"
$project = Join-Path $PSScriptRoot "tools\DemoMaker\DemoMaker.csproj"
dotnet run --project $project -- $PSScriptRoot $RepoRoot
if ($LASTEXITCODE -ne 0) { throw "시연 재료를 만들지 못했습니다 (종료 코드 $LASTEXITCODE)" }

40
demo/mergetool/Weld.cs Normal file
View File

@ -0,0 +1,40 @@
namespace Demo.Weld
{
public sealed class Sequence
{
public int Step1 { get; set; } = 10;
public int Step2 { get; set; } = 20;
public int Step3 { get; set; } = 30;
public int Step4 { get; set; } = 40;
public int Step5 { get; set; } = 50;
public int Step6 { get; set; } = 60;
public int Step7 { get; set; } = 70;
public int Step8 { get; set; } = 80;
public int Step9 { get; set; } = 90;
<<<<<<< HEAD
public int Step10 { get; set; } = 111; // 내 쪽
=======
public int Step10 { get; set; } = 222; // 상대 쪽
>>>>>>> feature/theirs
public int Step11 { get; set; } = 110;
public int Step12 { get; set; } = 120;
public int Step13 { get; set; } = 130;
public int Step14 { get; set; } = 140;
public int Step15 { get; set; } = 150;
public int Step16 { get; set; } = 160;
public int Step17 { get; set; } = 170;
public int Step18 { get; set; } = 180;
public int Step19 { get; set; } = 190;
public int Step20 { get; set; } = 200;
public int Step21 { get; set; } = 210;
public int Step22 { get; set; } = 220;
public int Step23 { get; set; } = 230;
public int Step24 { get; set; } = 240;
public int Step25 { get; set; } = 2525; // 상대만 고침
public int Step26 { get; set; } = 260;
public int Step27 { get; set; } = 270;
public int Step28 { get; set; } = 280;
public int Step29 { get; set; } = 290;
public int Step30 { get; set; } = 300;
}
}

View File

@ -0,0 +1,36 @@
namespace Demo.Weld
{
public sealed class Sequence
{
public int Step1 { get; set; } = 10;
public int Step2 { get; set; } = 20;
public int Step3 { get; set; } = 30;
public int Step4 { get; set; } = 40;
public int Step5 { get; set; } = 50;
public int Step6 { get; set; } = 60;
public int Step7 { get; set; } = 70;
public int Step8 { get; set; } = 80;
public int Step9 { get; set; } = 90;
public int Step10 { get; set; } = 100;
public int Step11 { get; set; } = 110;
public int Step12 { get; set; } = 120;
public int Step13 { get; set; } = 130;
public int Step14 { get; set; } = 140;
public int Step15 { get; set; } = 150;
public int Step16 { get; set; } = 160;
public int Step17 { get; set; } = 170;
public int Step18 { get; set; } = 180;
public int Step19 { get; set; } = 190;
public int Step20 { get; set; } = 200;
public int Step21 { get; set; } = 210;
public int Step22 { get; set; } = 220;
public int Step23 { get; set; } = 230;
public int Step24 { get; set; } = 240;
public int Step25 { get; set; } = 250;
public int Step26 { get; set; } = 260;
public int Step27 { get; set; } = 270;
public int Step28 { get; set; } = 280;
public int Step29 { get; set; } = 290;
public int Step30 { get; set; } = 300;
}
}

View File

@ -0,0 +1,36 @@
namespace Demo.Weld
{
public sealed class Sequence
{
public int Step1 { get; set; } = 10;
public int Step2 { get; set; } = 20;
public int Step3 { get; set; } = 30;
public int Step4 { get; set; } = 40;
public int Step5 { get; set; } = 50;
public int Step6 { get; set; } = 60;
public int Step7 { get; set; } = 70;
public int Step8 { get; set; } = 80;
public int Step9 { get; set; } = 90;
public int Step10 { get; set; } = 111; // 내 쪽
public int Step11 { get; set; } = 110;
public int Step12 { get; set; } = 120;
public int Step13 { get; set; } = 130;
public int Step14 { get; set; } = 140;
public int Step15 { get; set; } = 150;
public int Step16 { get; set; } = 160;
public int Step17 { get; set; } = 170;
public int Step18 { get; set; } = 180;
public int Step19 { get; set; } = 190;
public int Step20 { get; set; } = 200;
public int Step21 { get; set; } = 210;
public int Step22 { get; set; } = 220;
public int Step23 { get; set; } = 230;
public int Step24 { get; set; } = 240;
public int Step25 { get; set; } = 250;
public int Step26 { get; set; } = 260;
public int Step27 { get; set; } = 270;
public int Step28 { get; set; } = 280;
public int Step29 { get; set; } = 290;
public int Step30 { get; set; } = 300;
}
}

View File

@ -0,0 +1,36 @@
namespace Demo.Weld
{
public sealed class Sequence
{
public int Step1 { get; set; } = 10;
public int Step2 { get; set; } = 20;
public int Step3 { get; set; } = 30;
public int Step4 { get; set; } = 40;
public int Step5 { get; set; } = 50;
public int Step6 { get; set; } = 60;
public int Step7 { get; set; } = 70;
public int Step8 { get; set; } = 80;
public int Step9 { get; set; } = 90;
public int Step10 { get; set; } = 222; // 상대 쪽
public int Step11 { get; set; } = 110;
public int Step12 { get; set; } = 120;
public int Step13 { get; set; } = 130;
public int Step14 { get; set; } = 140;
public int Step15 { get; set; } = 150;
public int Step16 { get; set; } = 160;
public int Step17 { get; set; } = 170;
public int Step18 { get; set; } = 180;
public int Step19 { get; set; } = 190;
public int Step20 { get; set; } = 200;
public int Step21 { get; set; } = 210;
public int Step22 { get; set; } = 220;
public int Step23 { get; set; } = 230;
public int Step24 { get; set; } = 240;
public int Step25 { get; set; } = 2525; // 상대만 고침
public int Step26 { get; set; } = 260;
public int Step27 { get; set; } = 270;
public int Step28 { get; set; } = 280;
public int Step29 { get; set; } = 290;
public int Step30 { get; set; } = 300;
}
}

View File

@ -0,0 +1,7 @@
namespace Demo.Weld
{
public sealed class Cooling
{
public int WaitMs { get; set; } = 1500;
}
}

View File

@ -0,0 +1,8 @@
namespace Demo.Weld
{
// 예열 단계
public sealed class Preheat
{
public int Temperature { get; set; } = 180; // 섭씨
}
}

View File

@ -0,0 +1,6 @@
namespace Demo.Weld
{
public sealed class OldSensor
{
}
}

BIN
demo/plain/left/Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 B

View File

@ -0,0 +1,8 @@
namespace Demo.Weld
{
public sealed class Laser
{
public int Power { get; set; } = 150;
public int Focus { get; set; } = 12;
}
}

7
demo/plain/left/Same.cs Normal file
View File

@ -0,0 +1,7 @@
namespace Demo.Weld
{
public static class Units
{
public const double Kilo = 1000.0;
}
}

View File

@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Demo.Weld
{
public enum WeldMode
{
Spot,
Seam
}
public sealed class WeldRecipe
{
private readonly List<WeldStep> _steps = new List<WeldStep>();
public string Name
{
get;
set;
}
public int RetryCount { get; set; } = 3;
public IReadOnlyList<WeldStep> Steps
{
get
{
return _steps;
}
}
public WeldRecipe(string name)
{
Name = name;
}
public void Add(WeldStep step)
{
if (step == null)
{
throw new ArgumentNullException(nameof(step));
}
else if (step.Power <= 0)
{
throw new ArgumentOutOfRangeException(nameof(step));
}
else
{
_steps.Add(step);
}
}
public bool Run(Func<WeldStep, bool> fire)
{
for (var attempt = 0; attempt < RetryCount; attempt++)
{
try
{
if (_steps.All(fire))
{
return true;
}
}
catch (InvalidOperationException ex)
{
Log($"재시도 {attempt + 1}: {ex.Message}");
}
finally
{
Cool();
}
}
return false;
}
public static WeldRecipe Sample()
{
var recipe = new WeldRecipe("기본")
{
RetryCount = 2
};
var steps = new[]
{
new WeldStep { Power = 150, Millis = 40 },
new WeldStep { Power = 180, Millis = 55 }
};
foreach (var step in steps)
{
recipe.Add(step);
}
var summary = new
{
recipe.Name,
Count = steps.Length
};
Log(summary.ToString());
Action done = () =>
{
Log("준비 끝");
};
done();
return recipe;
}
private static void Cool()
{
switch (DateTime.Now.Second % 2)
{
case 0:
Log("공랭");
break;
default:
Log("수랭");
break;
}
}
private static void Log(string text)
{
}
}
public sealed class WeldStep
{
public int Power { get; set; }
public int Millis { get; set; }
}
}

View File

@ -0,0 +1,5 @@
<UserControl x:Class="Demo.Views.Recipe"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Button Content="시작" Width="80"/>
</UserControl>

View File

@ -0,0 +1,8 @@
// 용접 순서
namespace Demo.Weld
{
public static class 순서
{
public const string 첫단계 = "예열";
}
}

View File

@ -0,0 +1,7 @@
namespace Demo.Weld
{
public sealed class Cooling
{
public int WaitMs { get; set; } = 1800;
}
}

View File

@ -0,0 +1,8 @@
namespace Demo.Weld
{
// 예열 단계 - 소재 두께에 따라 바뀜
public sealed class Preheat
{
public int Temperature { get; set; } = 180;
}
}

BIN
demo/plain/right/Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 B

View File

@ -0,0 +1,9 @@
namespace Demo.Weld
{
public sealed class Laser
{
// [김승준] 2026-09-10 출력 통일
public int Power { get; set; } = 150;
public int Focus { get; set; } = 12; // [박지훈] 2026-09-11
}
}

View File

@ -0,0 +1,6 @@
namespace Demo.Weld
{
public sealed class NewSensor
{
}
}

7
demo/plain/right/Same.cs Normal file
View File

@ -0,0 +1,7 @@
namespace Demo.Weld
{
public static class Units
{
public const double Kilo = 1000.0;
}
}

View File

@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Demo.Weld
{
public enum WeldMode
{
Spot,
Seam
}
public sealed class WeldRecipe
{
private readonly List<WeldStep> _steps = new List<WeldStep>();
public string Name {
get;
set;
}
public int RetryCount { get; set; } = 3;
public IReadOnlyList<WeldStep> Steps {
get {
return _steps;
}
}
public WeldRecipe(string name)
{
Name = name;
}
public void Add(WeldStep step)
{
if (step == null) {
throw new ArgumentNullException(nameof(step));
} else if (step.Power <= 0) {
throw new ArgumentOutOfRangeException(nameof(step));
} else {
_steps.Add(step);
}
}
public bool Run(Func<WeldStep, bool> fire)
{
for (var attempt = 0; attempt < RetryCount; attempt++) {
try {
if (_steps.All(fire)) {
return true;
}
} catch (InvalidOperationException ex) {
Log($"재시도 {attempt + 1}: {ex.Message}");
} finally {
Cool();
}
}
return false;
}
public static WeldRecipe Sample()
{
var recipe = new WeldRecipe("기본") {
RetryCount = 2
};
var steps = new[] {
new WeldStep { Power = 150, Millis = 40 },
new WeldStep { Power = 180, Millis = 55 }
};
foreach (var step in steps) {
recipe.Add(step);
}
var summary = new {
recipe.Name,
Count = steps.Length
};
Log(summary.ToString());
Action done = () => {
Log("준비 끝");
};
done();
return recipe;
}
private static void Cool()
{
switch (DateTime.Now.Second % 2) {
case 0:
Log("공랭");
break;
default:
Log("수랭");
break;
}
}
private static void Log(string text)
{
}
}
public sealed class WeldStep
{
public int Power { get; set; }
public int Millis { get; set; }
}
}

View File

@ -0,0 +1,5 @@
<UserControl x:Class="Demo.Views.Recipe"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Button Content="실행" Width="96"/>
</UserControl>

View File

@ -0,0 +1,8 @@
// 용접 순서
namespace Demo.Weld
{
public static class 순서
{
public const string 첫단계 = "가열";
}
}

111
demo/style/Aurora.cs Normal file
View File

@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Demo.Weld
{
public enum WeldMode
{
Spot,
Seam
}
public sealed class WeldRecipe
{
private readonly List<WeldStep> _steps = new List<WeldStep>();
public string Name {
get;
set;
}
public int RetryCount { get; set; } = 5;
public IReadOnlyList<WeldStep> Steps {
get {
return _steps;
}
}
public WeldRecipe(string name)
{
Name = name;
}
public void Add(WeldStep step)
{
if (step == null) {
throw new ArgumentNullException(nameof(step));
} else if (step.Power <= 0) {
throw new ArgumentOutOfRangeException(nameof(step));
} else {
_steps.Add(step);
}
}
public bool Run(Func<WeldStep, bool> fire)
{
for (var attempt = 0; attempt < RetryCount; attempt++) {
try {
if (_steps.All(fire)) {
return true;
}
} catch (InvalidOperationException ex) {
Log($"재시도 {attempt + 1}: {ex.Message}");
} finally {
Cool();
}
}
return false;
}
public static WeldRecipe Sample()
{
var recipe = new WeldRecipe("기본") {
RetryCount = 2
};
var steps = new[] {
new WeldStep { Power = 150, Millis = 40 },
new WeldStep { Power = 180, Millis = 55 }
};
foreach (var step in steps) {
recipe.Add(step);
}
var summary = new {
recipe.Name,
Count = steps.Length
};
Log(summary.ToString());
Action done = () => {
Log("준비 끝");
};
done();
return recipe;
}
private static void Cool()
{
switch (DateTime.Now.Second % 2) {
case 0:
Log("공랭");
break;
default:
Log("수랭");
break;
}
}
private static void Log(string text)
{
}
}
public sealed class WeldStep
{
public int Power { get; set; }
public int Millis { get; set; }
}
}

132
demo/style/Default.cs Normal file
View File

@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Demo.Weld
{
public enum WeldMode
{
Spot,
Seam
}
public sealed class WeldRecipe
{
private readonly List<WeldStep> _steps = new List<WeldStep>();
public string Name
{
get;
set;
}
public int RetryCount { get; set; } = 3;
public IReadOnlyList<WeldStep> Steps
{
get
{
return _steps;
}
}
public WeldRecipe(string name)
{
Name = name;
}
public void Add(WeldStep step)
{
if (step == null)
{
throw new ArgumentNullException(nameof(step));
}
else if (step.Power <= 0)
{
throw new ArgumentOutOfRangeException(nameof(step));
}
else
{
_steps.Add(step);
}
}
public bool Run(Func<WeldStep, bool> fire)
{
for (var attempt = 0; attempt < RetryCount; attempt++)
{
try
{
if (_steps.All(fire))
{
return true;
}
}
catch (InvalidOperationException ex)
{
Log($"재시도 {attempt + 1}: {ex.Message}");
}
finally
{
Cool();
}
}
return false;
}
public static WeldRecipe Sample()
{
var recipe = new WeldRecipe("기본")
{
RetryCount = 2
};
var steps = new[]
{
new WeldStep { Power = 150, Millis = 40 },
new WeldStep { Power = 180, Millis = 55 }
};
foreach (var step in steps)
{
recipe.Add(step);
}
var summary = new
{
recipe.Name,
Count = steps.Length
};
Log(summary.ToString());
Action done = () =>
{
Log("준비 끝");
};
done();
return recipe;
}
private static void Cool()
{
switch (DateTime.Now.Second % 2)
{
case 0:
Log("공랭");
break;
default:
Log("수랭");
break;
}
}
private static void Log(string text)
{
}
}
public sealed class WeldStep
{
public int Power { get; set; }
public int Millis { get; set; }
}
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- 발표 시연 재료를 만든다. 실제 업무 소스는 쓰지 않는다 - 전부 지어낸 코드다. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.cs" />
<!-- C# 스타일 파일은 MergeHub 의 정리기로 만든다. 시연에서 보이는 결과와 같은 규칙이다. -->
<Compile Include="..\..\..\src\MergeHub\Services\CSharpStyle.cs" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,408 @@
using System.Diagnostics;
using System.Text;
using MergeHub.Services;
// MergeHub 발표 시연 재료를 만든다. 실제 업무 소스는 열지 않는다 - 전부 지어낸 코드다.
//
// dotnet run --project demo\tools\DemoMaker -- <demo 폴더> <저장소 경로>
//
// demo 폴더 아래 (저장소에 커밋해 둔다)
// plain\left, plain\right 폴더 비교
// style\Default.cs, style\Aurora.cs C# 스타일 맞춤
// mergetool\Weld_BASE.cs · Weld_LOCAL.cs · Weld_REMOTE.cs · Weld.cs git mergetool 네 칸
// big\Big7479_left.cs · Big7479_right.cs 큰 파일
// 저장소 경로 (기본 D:\MergeHubDemo\repo, 커밋하지 않는다 - 매번 새로 만든다)
// master · feature/mine · feature/theirs. feature/mine 에 서서 feature/theirs 를 병합하면 여러 갈래 충돌이 난다.
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var cp949 = Encoding.GetEncoding(949);
var utf8Bom = new UTF8Encoding(true);
var demoDir = Path.GetFullPath(args.Length > 0 ? args[0] : Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
var repoRoot = Path.GetFullPath(args.Length > 1 ? args[1] : @"D:\MergeHubDemo\repo");
Console.WriteLine($"demo 폴더: {demoDir}");
Console.WriteLine($"저장소: {repoRoot}");
void WriteText(string path, string text, Encoding? encoding = null)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, text, encoding ?? utf8Bom);
}
string Crlf(params string[] rows) => string.Join("\r\n", rows) + "\r\n";
void ResetFolder(string dir)
{
Directory.CreateDirectory(dir);
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) File.SetAttributes(f, FileAttributes.Normal);
foreach (var f in Directory.EnumerateFiles(dir)) File.Delete(f);
foreach (var d in Directory.EnumerateDirectories(dir)) Directory.Delete(d, true);
}
// ================= 1. 폴더 비교 =================
{
var left = Path.Combine(demoDir, "plain", "left");
var right = Path.Combine(demoDir, "plain", "right");
ResetFolder(left);
ResetFolder(right);
var same = Crlf("namespace Demo.Weld", "{", " public static class Units", " {", " public const double Kilo = 1000.0;", " }", "}");
WriteText(Path.Combine(left, "Same.cs"), same);
WriteText(Path.Combine(right, "Same.cs"), same);
WriteText(Path.Combine(left, "CodeDiff.cs"), Crlf("namespace Demo.Weld", "{", " public sealed class Cooling", " {", " public int WaitMs { get; set; } = 1500;", " }", "}"));
WriteText(Path.Combine(right, "CodeDiff.cs"), Crlf("namespace Demo.Weld", "{", " public sealed class Cooling", " {", " public int WaitMs { get; set; } = 1800;", " }", "}"));
WriteText(Path.Combine(left, "CommentOnly.cs"), Crlf("namespace Demo.Weld", "{", " // 예열 단계", " public sealed class Preheat", " {", " public int Temperature { get; set; } = 180; // 섭씨", " }", "}"));
WriteText(Path.Combine(right, "CommentOnly.cs"), Crlf("namespace Demo.Weld", "{", " // 예열 단계 - 소재 두께에 따라 바뀜", " public sealed class Preheat", " {", " public int Temperature { get; set; } = 180;", " }", "}"));
WriteText(Path.Combine(left, "MergeNoteOnly.cs"), Crlf("namespace Demo.Weld", "{", " public sealed class Laser", " {", " public int Power { get; set; } = 150;", " public int Focus { get; set; } = 12;", " }", "}"));
WriteText(Path.Combine(right, "MergeNoteOnly.cs"), Crlf("namespace Demo.Weld", "{", " public sealed class Laser", " {", " // [김승준] 2026-09-10 출력 통일", " public int Power { get; set; } = 150;", " public int Focus { get; set; } = 12; // [박지훈] 2026-09-11", " }", "}"));
var styleDefault = StyleSample();
WriteText(Path.Combine(left, "StyleOnly.cs"), styleDefault);
WriteText(Path.Combine(right, "StyleOnly.cs"), CSharpStyle.Format(styleDefault, CSharpBraceStyle.Aurora));
WriteText(Path.Combine(left, "LeftOnly.cs"), Crlf("namespace Demo.Weld", "{", " public sealed class OldSensor", " {", " }", "}"));
WriteText(Path.Combine(right, "RightOnly.cs"), Crlf("namespace Demo.Weld", "{", " public sealed class NewSensor", " {", " }", "}"));
WriteText(Path.Combine(left, "Views", "Recipe.xaml"), Crlf("<UserControl x:Class=\"Demo.Views.Recipe\"", " xmlns=\"http://schemas.microsoft.com/winfx/2006/xaml/presentation\"", " xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\">", " <Button Content=\"시작\" Width=\"80\"/>", "</UserControl>"));
WriteText(Path.Combine(right, "Views", "Recipe.xaml"), Crlf("<UserControl x:Class=\"Demo.Views.Recipe\"", " xmlns=\"http://schemas.microsoft.com/winfx/2006/xaml/presentation\"", " xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\">", " <Button Content=\"실행\" Width=\"96\"/>", "</UserControl>"));
WriteText(Path.Combine(left, "한글설명.cs"), Crlf("// 용접 순서", "namespace Demo.Weld", "{", " public static class 순서", " {", " public const string 첫단계 = \"예열\";", " }", "}"), cp949);
WriteText(Path.Combine(right, "한글설명.cs"), Crlf("// 용접 순서", "namespace Demo.Weld", "{", " public static class 순서", " {", " public const string 첫단계 = \"가열\";", " }", "}"), cp949);
// 이진 파일: PNG 머리를 가진 작은 그림 두 장(색만 다름)
File.WriteAllBytes(Path.Combine(left, "Logo.png"), TinyPng(0x2E, 0x7D, 0x32));
File.WriteAllBytes(Path.Combine(right, "Logo.png"), TinyPng(0xC6, 0x28, 0x28));
Console.WriteLine(" plain\\left · plain\\right 만듦");
}
// ================= 2. C# 스타일 맞춤 =================
{
var dir = Path.Combine(demoDir, "style");
ResetFolder(dir);
var sample = StyleSample();
WriteText(Path.Combine(dir, "Default.cs"), sample);
// AURORA 스타일로 바꾸고 진짜 변경을 한 줄 넣는다 - 스타일을 맞추면 이 한 줄만 남는다.
var aurora = CSharpStyle.Format(sample, CSharpBraceStyle.Aurora)
.Replace("public int RetryCount { get; set; } = 3;", "public int RetryCount { get; set; } = 5;");
WriteText(Path.Combine(dir, "Aurora.cs"), aurora);
Console.WriteLine($" style\\Default.cs ({sample.Split('\n').Length - 1}줄) · style\\Aurora.cs ({aurora.Split('\n').Length - 1}줄, 진짜 변경 1줄)");
}
// ================= 3. git mergetool 네 칸 =================
{
var dir = Path.Combine(demoDir, "mergetool");
ResetFolder(dir);
var rows = new List<string> { "namespace Demo.Weld", "{", " public sealed class Sequence", " {" };
for (var i = 1; i <= 30; i++) rows.Add($" public int Step{i} {{ get; set; }} = {i * 10};");
rows.AddRange(new[] { " }", "}" });
var local = new List<string>(rows);
local[13] = " public int Step10 { get; set; } = 111; // 내 쪽";
var remote = new List<string>(rows);
remote[13] = " public int Step10 { get; set; } = 222; // 상대 쪽";
remote[28] = " public int Step25 { get; set; } = 2525; // 상대만 고침";
WriteText(Path.Combine(dir, "Weld_BASE.cs"), Crlf(rows.ToArray()));
WriteText(Path.Combine(dir, "Weld_LOCAL.cs"), Crlf(local.ToArray()));
WriteText(Path.Combine(dir, "Weld_REMOTE.cs"), Crlf(remote.ToArray()));
// git 이 결과 파일에 남기는 모양: 충돌 표시(줄끝 LF)
var merged = new List<string>(local.Take(13));
merged.AddRange(new[] { "<<<<<<< HEAD", local[13], "=======", remote[13], ">>>>>>> feature/theirs" });
merged.AddRange(local.Skip(14).Take(14));
merged.Add(remote[28]);
merged.AddRange(local.Skip(29));
WriteText(Path.Combine(dir, "Weld.cs"), string.Join("\n", merged) + "\n");
Console.WriteLine(" mergetool\\Weld_BASE · LOCAL · REMOTE · Weld.cs 만듦");
}
// ================= 4. 큰 파일 =================
{
var dir = Path.Combine(demoDir, "big");
ResetFolder(dir);
var a = new StringBuilder();
var b = new StringBuilder();
for (var i = 1; i <= 7479; i++)
{
var line = $" public int Value{i} {{ get; set; }} = {i};";
a.Append(line).Append("\r\n");
b.Append(i % 997 == 0 || i >= 7470 ? $" public int Value{i} {{ get; set; }} = {i * 2}; // 바뀜" : line).Append("\r\n");
}
WriteText(Path.Combine(dir, "Big7479_left.cs"), a.ToString());
WriteText(Path.Combine(dir, "Big7479_right.cs"), b.ToString());
Console.WriteLine(" big\\Big7479_left.cs · Big7479_right.cs 만듦");
}
// ================= 5. 병합 시연 저장소 =================
MakeRepository(repoRoot);
Console.WriteLine();
Console.WriteLine("다 만들었습니다.");
return 0;
// ---------------------------------------------------------------------------
static string StyleSample()
{
// Visual Studio 기본 스타일(모든 여는 중괄호 새 줄)로 쓴 지어낸 용접 레시피 코드.
string[] rows =
{
"using System;",
"using System.Collections.Generic;",
"using System.Linq;",
"",
"namespace Demo.Weld",
"{",
" public enum WeldMode",
" {",
" Spot,",
" Seam",
" }",
"",
" public sealed class WeldRecipe",
" {",
" private readonly List<WeldStep> _steps = new List<WeldStep>();",
"",
" public string Name",
" {",
" get;",
" set;",
" }",
"",
" public int RetryCount { get; set; } = 3;",
"",
" public IReadOnlyList<WeldStep> Steps",
" {",
" get",
" {",
" return _steps;",
" }",
" }",
"",
" public WeldRecipe(string name)",
" {",
" Name = name;",
" }",
"",
" public void Add(WeldStep step)",
" {",
" if (step == null)",
" {",
" throw new ArgumentNullException(nameof(step));",
" }",
" else if (step.Power <= 0)",
" {",
" throw new ArgumentOutOfRangeException(nameof(step));",
" }",
" else",
" {",
" _steps.Add(step);",
" }",
" }",
"",
" public bool Run(Func<WeldStep, bool> fire)",
" {",
" for (var attempt = 0; attempt < RetryCount; attempt++)",
" {",
" try",
" {",
" if (_steps.All(fire))",
" {",
" return true;",
" }",
" }",
" catch (InvalidOperationException ex)",
" {",
" Log($\"재시도 {attempt + 1}: {ex.Message}\");",
" }",
" finally",
" {",
" Cool();",
" }",
" }",
"",
" return false;",
" }",
"",
" public static WeldRecipe Sample()",
" {",
" var recipe = new WeldRecipe(\"기본\")",
" {",
" RetryCount = 2",
" };",
" var steps = new[]",
" {",
" new WeldStep { Power = 150, Millis = 40 },",
" new WeldStep { Power = 180, Millis = 55 }",
" };",
" foreach (var step in steps)",
" {",
" recipe.Add(step);",
" }",
"",
" var summary = new",
" {",
" recipe.Name,",
" Count = steps.Length",
" };",
" Log(summary.ToString());",
"",
" Action done = () =>",
" {",
" Log(\"준비 끝\");",
" };",
" done();",
" return recipe;",
" }",
"",
" private static void Cool()",
" {",
" switch (DateTime.Now.Second % 2)",
" {",
" case 0:",
" Log(\"공랭\");",
" break;",
" default:",
" Log(\"수랭\");",
" break;",
" }",
" }",
"",
" private static void Log(string text)",
" {",
" }",
" }",
"",
" public sealed class WeldStep",
" {",
" public int Power { get; set; }",
" public int Millis { get; set; }",
" }",
"}",
};
return string.Join("\r\n", rows) + "\r\n";
}
static byte[] TinyPng(byte r, byte g, byte b)
{
// 1x1 PNG. 색 세 바이트만 다르다 - CRC 는 맞추지 않아도 이진 비교 시연에는 충분하다.
var bytes = new List<byte> { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
bytes.AddRange(new byte[] { 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0, 0x90, 0x77, 0x53, 0xDE });
bytes.AddRange(new byte[] { 0, 0, 0, 12, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, r, g, b, 0, 0, 0, 0, 0, 0 });
bytes.AddRange(new byte[] { 0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82 });
return bytes.ToArray();
}
static void MakeRepository(string root)
{
var cp949 = Encoding.GetEncoding(949);
var utf8Bom = new UTF8Encoding(true);
Directory.CreateDirectory(root);
foreach (var f in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) File.SetAttributes(f, FileAttributes.Normal);
foreach (var f in Directory.EnumerateFiles(root)) File.Delete(f);
foreach (var d in Directory.EnumerateDirectories(root)) Directory.Delete(d, true);
void Git(params string[] a)
{
var psi = new ProcessStartInfo("git") { WorkingDirectory = root, RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var x in a) psi.ArgumentList.Add(x);
using var p = Process.Start(psi)!;
var err = p.StandardError.ReadToEnd();
p.StandardOutput.ReadToEnd();
p.WaitForExit();
if (p.ExitCode != 0) throw new Exception($"git {string.Join(' ', a)}: {err}");
}
string Read(string rel) => File.ReadAllText(Path.Combine(root, rel));
void Write(string rel, string text)
{
var path = Path.Combine(root, rel);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, text, utf8Bom);
}
string ReplaceLine(string text, int index, string line)
{
var rows = text.Split("\r\n").ToList();
rows[index] = line;
return string.Join("\r\n", rows);
}
string InsertLine(string text, int index, string line)
{
var rows = text.Split("\r\n").ToList();
rows.Insert(index, line);
return string.Join("\r\n", rows);
}
string Unit(string name, int speed) =>
Crlf("using System;", "", "namespace Demo.Line", "{", $" public sealed class {name}", " {",
" // 속도 설정", $" public int Speed {{ get; set; }} = {speed};", " public int Power { get; set; } = 150;",
" public int Focus { get; set; } = 12;", "", " public void Start()", " {", " Log(\"시작\");", " }",
"", " public void Stop()", " {", " Log(\"정지\");", " }", "",
" private static void Log(string text) { }", " }", "}");
static string Crlf(params string[] rows) => string.Join("\r\n", rows) + "\r\n";
Git("init", "-q", "-b", "master");
Git("config", "user.name", "시연");
Git("config", "user.email", "demo@localhost");
Git("config", "core.autocrlf", "false");
var names = new[] { "Conveyor", "Gripper", "Laser", "Camera", "Cooling", "Heater", "Clamp", "Feeder", "Robot", "Sensor" };
foreach (var (n, i) in names.Select((n, i) => (n, i))) Write($"Line/{n}.cs", Unit(n, 100 + i));
Write("Line/Style.cs", StyleSample());
Write("Line/ToBeDeleted.cs", Unit("OldStation", 50));
Write("Views/Main.xaml", Crlf("<UserControl x:Class=\"Demo.Views.Main\"", " xmlns=\"http://schemas.microsoft.com/winfx/2006/xaml/presentation\">", " <StackPanel>", " <Button Content=\"시작\"", " Width=\"80\"/>", " </StackPanel>", "</UserControl>"));
File.WriteAllText(Path.Combine(root, "Line/한글설명.cs"), Crlf("// 용접 순서", "namespace Demo.Line", "{", " public static class 순서", " {", " public const string 둘째단계 = \"용접\";", " }", "}"), cp949);
Git("add", "-A");
Git("commit", "-q", "-m", "바탕");
// ---- 내 쪽 ----
Git("checkout", "-q", "-b", "feature/mine");
Write("Line/Conveyor.cs", ReplaceLine(Read("Line/Conveyor.cs"), 7, " public int Speed { get; set; } = 120; // 내 쪽")); // 같은 줄 충돌
Write("Line/Gripper.cs", ReplaceLine(Read("Line/Gripper.cs"), 8, " public int Power { get; set; } = 160;")); // 자동 병합(다른 자리)
Write("Line/Laser.cs", InsertLine(Read("Line/Laser.cs"), 8, " // [김승준] 2026-09-10")); // 머지 주석만 다름(줄 위)
Write("Line/Camera.cs", ReplaceLine(Read("Line/Camera.cs"), 9, " public int Focus { get; set; } = 12; // [김승준] 2026-09-10")); // 머지 주석만 다름(줄 끝)
Write("Line/ToBeDeleted.cs", ReplaceLine(Read("Line/ToBeDeleted.cs"), 7, " public int Speed { get; set; } = 55;")); // 내가 고침 · 상대가 지움
Write("Line/NewPanel.cs", Crlf("namespace Demo.Line", "{", " public sealed class NewPanel { public int Mine = 1; }", "}")); // 양쪽 새 파일
Write("Views/Main.xaml", Read("Views/Main.xaml").Replace("Content=\"시작\"", "Content=\"가동\"")); // XAML 충돌
File.WriteAllText(Path.Combine(root, "Line/한글설명.cs"), File.ReadAllText(Path.Combine(root, "Line/한글설명.cs"), cp949).Replace("\"용접\"", "\"본용접\""), cp949); // CP949 충돌
Write("Line/Style.cs", CSharpStyle.Format(StyleSample(), CSharpBraceStyle.Aurora).Replace("RetryCount { get; set; } = 3;", "RetryCount { get; set; } = 4;")); // 스타일 다름 + 값
Git("add", "-A");
Git("commit", "-q", "-m", "내 쪽 작업");
// ---- 상대 쪽 ----
Git("checkout", "-q", "master");
Git("checkout", "-q", "-b", "feature/theirs");
Write("Line/Conveyor.cs", ReplaceLine(Read("Line/Conveyor.cs"), 7, " public int Speed { get; set; } = 140; // 상대 쪽"));
// 내 쪽(8줄)과 떨어진 자리를 고친다 - 맞붙은 줄을 고치면 git 은 자동 병합하지 않고 충돌로 본다.
Write("Line/Gripper.cs", ReplaceLine(Read("Line/Gripper.cs"), 18, " Log(\"정지 - 상대 쪽\");"));
Write("Line/Laser.cs", InsertLine(Read("Line/Laser.cs"), 8, " // [박지훈] 2026-09-11"));
Write("Line/Camera.cs", ReplaceLine(Read("Line/Camera.cs"), 9, " public int Focus { get; set; } = 12; // [박지훈] 2026-09-11"));
File.Delete(Path.Combine(root, "Line/ToBeDeleted.cs"));
Write("Line/NewPanel.cs", Crlf("namespace Demo.Line", "{", " public sealed class NewPanel { public int Theirs = 2; }", "}"));
Write("Views/Main.xaml", Read("Views/Main.xaml").Replace("Content=\"시작\"", "Content=\"운전\""));
File.WriteAllText(Path.Combine(root, "Line/한글설명.cs"), File.ReadAllText(Path.Combine(root, "Line/한글설명.cs"), cp949).Replace("\"용접\"", "\"가접\""), cp949);
Write("Line/Style.cs", StyleSample().Replace("public int RetryCount { get; set; } = 3;", "public int RetryCount { get; set; } = 6;"));
Write("Line/Heater.cs", ReplaceLine(Read("Line/Heater.cs"), 7, " public int Speed { get; set; } = 105; // 상대만 고침"));
Git("add", "-A");
Git("commit", "-q", "-m", "상대 쪽 작업");
Git("checkout", "-q", "feature/mine");
Console.WriteLine($" 저장소 {root} 만듦 - feature/mine 에서 feature/theirs 를 병합하세요");
}

View File

@ -691,3 +691,57 @@ MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라, 두 칸으로는
(자동 조작은 목록이 화면에 보이는 항목만 만들어 두는 탓에 처음에는 10개만 골랐다. 목록 항목을 모두 불러오게 고쳐 15개로 다시 쟀다.) (자동 조작은 목록이 화면에 보이는 항목만 만들어 두는 탓에 처음에는 10개만 골랐다. 목록 항목을 모두 불러오게 고쳐 15개로 다시 쟀다.)
빌드 경고 0 · 오류 0. 빌드 경고 0 · 오류 0.
## E-1. C# 코드 스타일 맞춤 (2026-09-14, 추가 요청, 완료)
사용자 요청: Visual Studio [텍스트 편집기 > C# > 코드 스타일 > 서식 > 줄 바꿈] 옵션 그림과 같은 스타일(AURORA)이 아니면 diff 가 많이 뜬다. C# 이면 그 스타일로 자동으로 맞춰 달라. AURORA 와 Default 를 고를 수 있게.
### 그림에서 읽은 AURORA
| 옵션 | 켬 |
|---|---|
| 여는 중괄호 새 줄: 형식 · 메서드와 로컬 함수 | ✓ |
| 여는 중괄호 새 줄: 속성·인덱서·이벤트 · 접근자 · 익명 메서드 · 제어 블록 · 익명 형식 · 개체/컬렉션/배열 초기화 · 람다 | 끔(같은 줄) |
| "else" · "catch" · "finally" 새 줄 | 끔(`} else {`) |
| 개체 초기화 멤버 · 익명 형식 멤버 · 쿼리 절 새 줄 | ✓ (손대지 않는다) |
Default 는 Visual Studio 기본 - 모두 새 줄.
### 만든 것
| 파일 | 무엇 |
|---|---|
| `Services/CSharpStyle.cs` (새) | 중괄호 · else/catch/finally 의 **줄 위치만** 옮긴다. 주석 · 문자열 · 보간 문자열 · 전처리 줄은 가려서 본다. 중괄호 앞 머리를 거꾸로 읽어 갈래(형식 · 메서드 · 속성 · 접근자 · 익명 메서드 · 제어 · 익명 형식 · 초기화 · 람다)를 정하고 옵션표대로 올리거나 내린다. 앞 줄 끝에 주석이 있으면 올리지 않는다. 판단할 수 없는 블록은 그대로. 바꿀 것이 없으면 받은 글 그대로 |
| `ViewModels/HomeViewModel.cs` | 비교 규칙 칸에 [C# 스타일 끔 / C# AURORA / C# Default]. 고르면 원본부터 다시 읽어 BASE · LOCAL · REMOTE 를 맞춘다(Git 두 칸 · 네 칸 · 파일 두 개 · mergetool). 편집 칸 글이 바뀌면 저장할 것이 있는 상태, 저장하면 그 스타일로 파일에 쓴다. **스타일만 바뀐 편집은 파일을 바꿀 때 묻지 않는다**(묻게 두면 파일마다 창이 뜬다). 상태바 요약에 스타일 |
| `Services/FolderComparer.cs` · `FolderViewModel.cs` | 스타일만 다른 C# 파일은 **같음**. 줄끝이 다르면 가리지 않는다 |
| `MergeHubSettings.cs` | [비교 옵션] "C# 코드 스타일 맞춤", 기본 끔 |
검사: StyleTest(새) **78건** - 두 스타일 사이 왕복, 다시 맞춰도 그대로, CRLF, 주석 · 문자열 · 전처리 속 중괄호, 갈래 판단 36가지, 옵션표, 1,600줄대 글 속도. FolderTest 57 → **62**. 검사 모음 22개 **948건 통과**.
### 확인한 것
만든 시연 재료(`demo\style\Default.cs` 132줄, `Aurora.cs` 는 같은 코드를 AURORA 로 맞추고 값 한 줄 `RetryCount` 3 → 5).
| 확인 | 남은 차이 | 저장 |
|---|---|---|
| 스타일 끔 | **39줄** | 꺼짐 |
| C# AURORA | **1줄** (RetryCount) | 꺼짐 - 편집 칸이 이미 AURORA |
| C# Default | 1줄 | **켜짐** - 편집 칸이 Default 로 바뀜 |
| 다시 끔 | 39줄 | 꺼짐, **확인 창 안 뜸** |
시연 저장소(`D:\MergeHubDemo\repo`)를 MergeHub 로 병합 → `Line/Style.cs`(내 쪽 AURORA + 값 4, 상대 Default + 값 6) 네 칸: 끔 **남은 충돌 1 · 차이 39줄** → AURORA **충돌 1 · 차이 1줄**.
설정 화면에 저장된 값은 `"CSharpBraceStyle": 1`. 확인 뒤 설정 파일은 원래 바이트로 되돌렸다.
### 남긴 것
- 들여쓰기 · 공백 · 그 밖의 서식은 맞추지 않는다. 중괄호 줄 위치만.
- git 병합(libgit2) 자체는 스타일을 모른다. 스타일만 다른 충돌도 git 은 충돌로 내고, 네 칸에서 맞춰 보여 줄 뿐 자동으로 풀지는 않는다.
## E-2. 발표 시연 재료 (2026-09-14, 추가 요청, 완료)
`demo\make-demo.ps1` 한 번이면 `demo\plain` · `style` · `mergetool` · `big` 과 병합 저장소 `D:\MergeHubDemo\repo` 를 새로 만든다(`tools\DemoMaker`, 지어낸 코드). 내용과 쓰는 법은 `demo\README.md`.
시연 저장소를 git 으로 병합하면 충돌 8개(같은 줄 · 머지 주석 줄 위 · 줄 끝 · 스타일 · 양쪽 새 파일 · 한쪽 삭제 · CP949 · XAML), `Gripper.cs` · `Heater.cs` 는 자동 병합. MergeHub 로 병합하면 머지 주석만 다른 2개를 스스로 풀어 6개.
처음 만든 생성기는 CP949 파일을 UTF-8 로 읽어 충돌이 안 났고, `Gripper.cs` 는 맞붙은 줄을 고쳐 충돌이 났다. 둘 다 고쳐 다시 만들었다. `make-demo.ps1` 은 PowerShell 5.1 이 한글을 읽도록 BOM 을 붙였다.

View File

@ -8,6 +8,8 @@
| 날짜 | 항목 | 커밋 | | 날짜 | 항목 | 커밋 |
|---|---|---| |---|---|---|
| 2026-09-14 | **E-1 C# 코드 스타일 맞춤** — AURORA(형식 · 메서드만 새 줄, `} else {`) / Default(모두 새 줄)로 중괄호 줄바꿈을 맞춘 뒤 비교. 두 칸 · 네 칸 · mergetool · 폴더 비교, 설정. 스타일만 바뀐 편집은 파일을 바꿀 때 묻지 않음. StyleTest 78건 | 아래 |
| 2026-09-14 | **E-2 시연 재료** — `demo\make-demo.ps1` · `tools\DemoMaker`. 지어낸 코드로 폴더 비교 · 스타일 · mergetool · 큰 파일 · 병합 저장소(`D:\MergeHubDemo\repo`) | 아래 |
| 2026-09-14 | **6차 B-6** — 충돌 탭에서 여러 파일을 골라(Ctrl · Shift) Accept 를 한 번에. 작업 하나로 풀고 저장소 다시 읽기는 끝에 한 번. 같은 줄 충돌 15개 22.0초 → 1.2초 | 아래 | | 2026-09-14 | **6차 B-6** — 충돌 탭에서 여러 파일을 골라(Ctrl · Shift) Accept 를 한 번에. 작업 하나로 풀고 저장소 다시 읽기는 끝에 한 번. 같은 줄 충돌 15개 22.0초 → 1.2초 | 아래 |
| 2026-09-14 | **6차 B-5** — 머지 주석을 비교에서 무시(주석 줄은 거르고 줄 끝 주석은 떼고 견줌). 주석만 다른 git 충돌은 양쪽 주석을 남겨 자동으로 풂. 정렬이 주석 줄과 코드 줄을 짝지으면 떼어 놓음. 폴더 비교도. 설정 [머지 주석] 에서 끔 | 아래 | | 2026-09-14 | **6차 B-5** — 머지 주석을 비교에서 무시(주석 줄은 거르고 줄 끝 주석은 떼고 견줌). 주석만 다른 git 충돌은 양쪽 주석을 남겨 자동으로 풂. 정렬이 주석 줄과 코드 줄을 짝지으면 떼어 놓음. 폴더 비교도. 설정 [머지 주석] 에서 끔 | 아래 |
| 2026-09-14 | **6차 B-4 확인 중 발견 두 건** — "마지막 차이" 로 멀리 건너뛰면 표시층 보정이 화면 밖 행으로 재져 네 칸이 9줄 위로 밀리던 것을 고침. `git mergetool` 이 넘긴 세 벌을 두 칸이 아닌 네 칸으로 열고 결과를 MERGED 파일에 쓴다 | 아래 | | 2026-09-14 | **6차 B-4 확인 중 발견 두 건** — "마지막 차이" 로 멀리 건너뛰면 표시층 보정이 화면 밖 행으로 재져 네 칸이 9줄 위로 밀리던 것을 고침. `git mergetool` 이 넘긴 세 벌을 두 칸이 아닌 네 칸으로 열고 결과를 MERGED 파일에 쓴다 | 아래 |

View File

@ -113,6 +113,14 @@
| 대기 | B-7 상태바가 병합 중에 믿을 수 없는 값을 보인다 | | 대기 | B-7 상태바가 병합 중에 믿을 수 없는 값을 보인다 |
| 대기 | B-8 병합 커밋 뒤 파일 비교 탭이 어정쩡하게 남는다 | | 대기 | B-8 병합 커밋 뒤 파일 비교 탭이 어정쩡하게 남는다 |
### E. 추가 요청 (2026-09-14)
| 상태 | 항목 |
|---|---|
| **완료** | E-1 C# 코드 스타일 맞춤 (AURORA · Default) — 비교 전에 중괄호 줄바꿈을 맞춤. 두 칸 · 네 칸 · 폴더 비교, 설정. 스타일만 다른 파일 남은 차이 39줄 → 1줄 |
| **완료** | E-2 발표 시연 재료 `demo\` — `make-demo.ps1` 한 번으로 폴더 비교 · 스타일 · mergetool · 큰 파일 · 병합 저장소 |
| **완료** | E-3 웹 문서 — 사용자 매뉴얼 · 발표 자료 · 남은 일 · 개발 현황 · 기획안 최신화 · 시연 대본 · WinMerge 비교 |
### C. 사소한 것 ### C. 사소한 것
| 상태 | 항목 | | 상태 | 항목 |

View File

@ -67,6 +67,15 @@ namespace MergeHub
[DisplayName("줄 필터 정규식 (한 줄에 하나)")] [DisplayName("줄 필터 정규식 (한 줄에 하나)")]
public string LineFilters { get; set; } = string.Empty; public string LineFilters { get; set; } = string.Empty;
/// <summary>
/// C# 파일을 비교하기 전에 중괄호 줄바꿈을 맞출 스타일(6차 추가).
/// AURORA = 형식 · 메서드 중괄호만 새 줄, 나머지와 else/catch/finally 는 같은 줄. Default = Visual Studio 기본(모두 새 줄).
/// 켜면 편집 칸의 결과도 그 스타일로 저장된다.
/// </summary>
[Category("비교 옵션")]
[DisplayName("C# 코드 스타일 맞춤 (None=끔 · Aurora · Default)")]
public MergeHub.Services.CSharpBraceStyle CSharpBraceStyle { get; set; } = MergeHub.Services.CSharpBraceStyle.None;
[Category("비교 옵션")] [Category("비교 옵션")]
[DisplayName("단어 단위 diff")] [DisplayName("단어 단위 diff")]
public bool WordLevelDiff { get; set; } = true; public bool WordLevelDiff { get; set; } = true;

View File

@ -0,0 +1,542 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
namespace MergeHub.Services
{
/// <summary>C# 여는 중괄호 · else/catch/finally 를 어디에 둘지.</summary>
public enum CSharpBraceStyle
{
/// <summary>맞추지 않는다. 파일 그대로 견준다.</summary>
None = 0,
/// <summary>
/// AURORA. Visual Studio [텍스트 편집기 &gt; C# &gt; 코드 스타일 &gt; 서식 &gt; 줄 바꿈] 에서
/// 형식 · 메서드와 로컬 함수의 여는 중괄호만 새 줄, 나머지(속성 · 접근자 · 익명 메서드 · 제어 블록 ·
/// 익명 형식 · 초기화 · 람다)는 같은 줄. else · catch · finally 는 같은 줄(<c>} else {</c>).
/// </summary>
Aurora = 1,
/// <summary>Visual Studio 기본. 여는 중괄호는 모두 새 줄, else · catch · finally 도 새 줄.</summary>
Default = 2,
}
/// <summary>
/// C# 코드의 중괄호 줄바꿈을 한 가지 스타일로 맞춘다(6차 추가 - C# 코드 스타일 맞춤).
///
/// 같은 코드인데 한쪽은 <c>if (x) {</c>, 다른 쪽은 <c>if (x)</c> + 다음 줄 <c>{</c> 로 쓰면
/// 줄 단위 비교에서 그 자리마다 차이가 둘씩 뜬다. 스타일이 다른 사람 코드를 병합할 때 진짜 변경이 묻힌다.
/// 그래서 비교하기 전에 양쪽을 같은 스타일로 맞춘다.
///
/// <b>줄의 위치만</b> 옮긴다 - 여는 중괄호를 앞 줄 끝으로 올리거나 다음 줄로 내리고,
/// <c>}</c> 와 else/catch/finally 를 붙이거나 뗀다. 들여쓰기 · 공백 · 그 밖의 서식은 건드리지 않는다.
/// 주석 · 문자열 · 보간 문자열 · 전처리 줄 안의 중괄호는 코드로 보지 않는다.
/// 앞 줄 끝에 주석이 있으면 올리지 않는다(중괄호가 주석 안으로 들어가 버린다).
/// 스타일을 판단할 수 없는 중괄호(그냥 블록 · 중첩 배열 초기화)는 그대로 둔다.
/// </summary>
public static class CSharpStyle
{
/// <summary>중괄호가 여는 것의 갈래. Visual Studio 옵션의 항목과 같게 나눈다.</summary>
public enum BraceKind
{
/// <summary>판단하지 않는다(그냥 블록 · 알 수 없음). 옮기지 않는다.</summary>
Unknown,
Type,
Method,
Property,
Accessor,
AnonymousMethod,
Control,
AnonymousType,
Initializer,
Lambda,
}
/// <summary>이 파일에 스타일 맞춤을 걸지. C# 파일(.cs)만.</summary>
public static bool Applies(string? path, CSharpBraceStyle style) =>
style != CSharpBraceStyle.None && !string.IsNullOrEmpty(path) &&
string.Equals(Path.GetExtension(path), ".cs", StringComparison.OrdinalIgnoreCase);
public static string Label(CSharpBraceStyle style) => style switch
{
CSharpBraceStyle.Aurora => "AURORA",
CSharpBraceStyle.Default => "Default",
_ => "끔",
};
/// <summary>
/// 글 전체를 스타일에 맞춘다. 바꿀 것이 없으면 <b>받은 글을 그대로</b> 돌려준다(줄끝도 그대로).
/// 바꾸면 줄끝은 글에 CRLF 가 하나라도 있으면 CRLF, 아니면 LF 로 잇는다.
/// </summary>
public static string Format(string text, CSharpBraceStyle style)
{
if (style == CSharpBraceStyle.None || string.IsNullOrEmpty(text)) return text;
var newLine = text.IndexOf("\r\n", StringComparison.Ordinal) >= 0 ? "\r\n" : "\n";
var endsWithNewLine = text.EndsWith("\n", StringComparison.Ordinal);
var lines = new List<string>(text.Replace("\r\n", "\n").Split('\n'));
if (endsWithNewLine) lines.RemoveAt(lines.Count - 1);
var any = false;
for (var pass = 0; pass < 8; pass++)
{
var changed = PlaceKeywords(lines, style);
changed |= PlaceBraces(lines, style);
if (!changed) break;
any = true;
}
if (!any) return text;
var result = string.Join(newLine, lines);
return endsWithNewLine ? result + newLine : result;
}
/// <summary>그 갈래의 여는 중괄호를 새 줄에 둘지.</summary>
public static bool OpenBraceOnNewLine(BraceKind kind, CSharpBraceStyle style) =>
style == CSharpBraceStyle.Default || kind is BraceKind.Type or BraceKind.Method;
// ================= else · catch · finally =================
private static readonly Regex KeywordLine = new Regex(@"^(else|catch|finally)\b", RegexOptions.Compiled);
private static readonly Regex CloseThenKeyword = new Regex(@"^\}\s*(else|catch|finally)\b", RegexOptions.Compiled);
private static bool PlaceKeywords(List<string> lines, CSharpBraceStyle style)
{
var mask = Mask(lines);
var changed = false;
for (var i = lines.Count - 1; i >= 1; i--)
{
if (mask.Inside[i] || mask.Preprocessor[i]) continue;
var code = mask.Code[i].Trim();
if (style == CSharpBraceStyle.Aurora)
{
// } → } else {
// else {
if (!KeywordLine.IsMatch(code)) continue;
var p = i - 1;
if (mask.Inside[p] || mask.Preprocessor[p]) continue;
if (lines[p].Trim() != "}" || mask.Code[p].Trim() != "}") continue;
lines[p] = lines[p].TrimEnd() + " " + lines[i].TrimStart();
lines.RemoveAt(i);
changed = true;
}
else
{
// } else { → }
// else {
if (!CloseThenKeyword.IsMatch(code) || !lines[i].TrimStart().StartsWith("}", StringComparison.Ordinal)) continue;
var indent = Indent(lines[i]);
var rest = lines[i].TrimStart().Substring(1).TrimStart();
lines[i] = indent + "}";
lines.Insert(i + 1, indent + rest);
changed = true;
}
}
return changed;
}
// ================= 여는 중괄호 =================
private static bool PlaceBraces(List<string> lines, CSharpBraceStyle style)
{
var mask = Mask(lines);
var changed = false;
for (var i = lines.Count - 1; i >= 0; i--)
{
if (mask.Inside[i] || mask.Preprocessor[i]) continue;
var code = mask.Code[i];
var first = FirstNonSpace(code);
if (first < 0) continue;
// (가) 줄이 '{' 로 시작한다 → 같은 줄 스타일이면 앞 줄 끝으로 올린다.
if (code[first] == '{')
{
if (i == 0) continue;
var p = i - 1;
if (mask.Inside[p] || mask.Preprocessor[p]) continue;
var prevCode = mask.Code[p].TrimEnd();
if (prevCode.Trim().Length == 0) continue;
// 앞 줄 끝에 주석이 있으면 올리지 않는다 - 원문 끝과 코드 끝이 같아야 한다.
if (lines[p].TrimEnd().Length != prevCode.Length) continue;
var (kind, _) = Classify(mask, i, first);
if (kind == BraceKind.Unknown || OpenBraceOnNewLine(kind, style)) continue;
lines[p] = lines[p].TrimEnd() + " " + lines[i].TrimStart();
lines.RemoveAt(i);
changed = true;
continue;
}
// (나) 코드 뒤 줄 끝에 '{' 가 있다 → 새 줄 스타일이면 다음 줄로 내린다.
var trimmed = code.TrimEnd();
if (trimmed.Length == 0 || trimmed[trimmed.Length - 1] != '{') continue;
var brace = trimmed.Length - 1;
if (code.Substring(0, brace).Trim().Length == 0) continue;
if (code.Substring(0, brace).TrimStart().StartsWith("}", StringComparison.Ordinal)) continue;
var (k, headerLine) = Classify(mask, i, brace);
if (k == BraceKind.Unknown || !OpenBraceOnNewLine(k, style)) continue;
var original = lines[i];
lines[i] = original.Substring(0, brace).TrimEnd();
lines.Insert(i + 1, Indent(lines[headerLine]) + original.Substring(brace));
changed = true;
}
return changed;
}
/// <summary>
/// (line, col) 의 여는 중괄호가 무엇을 여는지. 앞쪽 코드를 거꾸로 읽어 머리(선언 · 제어문 등)를 모은다.
/// 머리가 시작한 줄 번호도 준다 - 중괄호를 내릴 때 그 줄의 들여쓰기를 따른다.
/// </summary>
private static (BraceKind Kind, int HeaderLine) Classify(Masked mask, int line, int col)
{
var chars = new List<char>(128);
int paren = 0, bracket = 0, angle = 0;
var start = line;
var pos = col - 1;
var l = line;
while (chars.Count < 4000)
{
if (pos < 0)
{
l--;
if (l < 0 || mask.Preprocessor[l]) break;
pos = mask.Code[l].Length - 1;
chars.Add(' ');
continue;
}
var text = mask.Code[l];
var ch = text[pos];
if (ch == ')') paren++;
else if (ch == '(') { if (paren == 0) break; paren--; }
else if (ch == ']') bracket++;
else if (ch == '[') { if (bracket == 0) break; bracket--; }
else if (ch == '>' && !(pos > 0 && (text[pos - 1] == '=' || text[pos - 1] == '-')) &&
!(pos + 1 < text.Length && text[pos + 1] == '='))
angle++;
else if (ch == '<') { if (angle > 0) angle--; }
else if (paren == 0 && bracket == 0 && (ch == ';' || ch == '{' || ch == '}')) break;
else if (paren == 0 && bracket == 0 && angle == 0 && ch == ',') break;
if (!char.IsWhiteSpace(ch)) start = l;
chars.Add(ch);
pos--;
}
chars.Reverse();
return (KindOfHeader(new string(chars.ToArray())), start);
}
private static readonly HashSet<string> ControlWords = new HashSet<string>(StringComparer.Ordinal)
{
"if", "else", "for", "foreach", "while", "do", "switch", "try", "catch", "finally",
"using", "lock", "fixed", "unsafe", "checked", "unchecked",
};
private static readonly Regex Spaces = new Regex(@"\s+", RegexOptions.Compiled);
private static readonly Regex CaseLabel = new Regex(@"^(case\b.*|default)\s*:$", RegexOptions.Compiled);
private static readonly Regex AccessorHeader = new Regex(@"^((public|private|protected|internal)\s+)*(get|set|init|add|remove)$", RegexOptions.Compiled);
private static readonly Regex TypeKeyword = new Regex(@"\b(class|struct|interface|enum|record|namespace)\b", RegexOptions.Compiled);
private static readonly Regex WhereClause = new Regex(@"\bwhere\s+\w+\s*:", RegexOptions.Compiled);
/// <summary>중괄호 앞 머리 글로 갈래를 정한다. 검사에서 직접 부를 수 있게 열어 둔다.</summary>
public static BraceKind KindOfHeader(string header)
{
var h = Spaces.Replace(header ?? string.Empty, " ").Trim();
// 앞에 붙은 특성([Test] 등)은 뗀다.
while (h.StartsWith("[", StringComparison.Ordinal))
{
var close = MatchingClose(h, 0, '[', ']');
if (close < 0) break;
h = h.Substring(close + 1).TrimStart();
}
if (h.Length == 0) return BraceKind.Unknown;
if (h.EndsWith("=>", StringComparison.Ordinal)) return BraceKind.Lambda;
if (CaseLabel.IsMatch(h)) return BraceKind.Control;
var firstWord = Regex.Match(h, @"^[A-Za-z_]\w*").Value;
if (ControlWords.Contains(firstWord)) return BraceKind.Control;
if (AccessorHeader.IsMatch(h)) return BraceKind.Accessor;
var paren = h.IndexOf('(');
var type = TypeKeyword.Match(h);
if (type.Success && (paren < 0 || type.Index < paren)) return BraceKind.Type;
if (Regex.IsMatch(h, @"\bdelegate\b")) return BraceKind.AnonymousMethod;
// 제약이 붙은 제네릭 메서드: void M<T>() where T : new()
if (WhereClause.IsMatch(h) && paren >= 0) return BraceKind.Method;
if (h == "new") return BraceKind.AnonymousType;
if (EndsWithCreation(h)) return BraceKind.Initializer;
if (HasAssignment(h)) return BraceKind.Initializer;
if (h.EndsWith("switch", StringComparison.Ordinal)) return BraceKind.Initializer;
if (h.EndsWith(")", StringComparison.Ordinal)) return BraceKind.Method;
var last = h[h.Length - 1];
if (char.IsLetterOrDigit(last) || last == '_' || last == '>' || last == ']' || last == '?') return BraceKind.Property;
return BraceKind.Unknown;
}
/// <summary>
/// 머리가 <c>new 형식</c> · <c>new 형식(인자)</c> · <c>new[]</c> · <c>new()</c> 로 끝나는지 - 개체 · 컬렉션 · 배열 초기화.
/// 멤버 숨기기 한정자(<c>public new void M()</c>)는 new 뒤에 낱말이 둘 이상이라 아니다.
/// </summary>
private static bool EndsWithCreation(string h)
{
var at = LastWord(h, "new");
if (at < 0) return false;
var rest = h.Substring(at + 3).Trim();
// 끝의 (인자) 한 덩어리를 뗀다.
if (rest.EndsWith(")", StringComparison.Ordinal))
{
var open = MatchingOpen(rest, rest.Length - 1, '(', ')');
if (open < 0) return false;
rest = rest.Substring(0, open).Trim();
}
if (rest.Length == 0) return true; // new() · new (...)
// 형식 이름 하나(점 · 제네릭 · 배열 표시 포함), 공백으로 나뉜 낱말이 없어야 한다.
var depth = 0;
foreach (var c in rest)
{
if (c == '<' || c == '[') depth++;
else if (c == '>' || c == ']') depth--;
else if (c == ' ' && depth == 0) return false;
else if (depth == 0 && !(char.IsLetterOrDigit(c) || c == '_' || c == '.' || c == '?')) return false;
}
return true;
}
/// <summary>괄호 밖에 대입 '=' 가 있는지(==, !=, &lt;=, &gt;=, =&gt; 는 빼고).</summary>
private static bool HasAssignment(string h)
{
var depth = 0;
for (var i = 0; i < h.Length; i++)
{
var c = h[i];
if (c == '(' || c == '[') depth++;
else if (c == ')' || c == ']') depth--;
else if (c == '=' && depth == 0)
{
var prev = i > 0 ? h[i - 1] : ' ';
var next = i + 1 < h.Length ? h[i + 1] : ' ';
if (next == '=' || next == '>' || prev == '=' || prev == '!' || prev == '<' || prev == '>') continue;
return true;
}
}
return false;
}
private static int LastWord(string h, string word)
{
var at = h.Length;
while (true)
{
at = h.LastIndexOf(word, at - 1, StringComparison.Ordinal);
if (at < 0) return -1;
var before = at == 0 || !(char.IsLetterOrDigit(h[at - 1]) || h[at - 1] == '_');
var after = at + word.Length >= h.Length || !(char.IsLetterOrDigit(h[at + word.Length]) || h[at + word.Length] == '_');
if (before && after) return at;
if (at == 0) return -1;
}
}
private static int MatchingClose(string s, int open, char o, char c)
{
var depth = 0;
for (var i = open; i < s.Length; i++)
{
if (s[i] == o) depth++;
else if (s[i] == c && --depth == 0) return i;
}
return -1;
}
private static int MatchingOpen(string s, int close, char o, char c)
{
var depth = 0;
for (var i = close; i >= 0; i--)
{
if (s[i] == c) depth++;
else if (s[i] == o && --depth == 0) return i;
}
return -1;
}
private static int FirstNonSpace(string s)
{
for (var i = 0; i < s.Length; i++)
if (!char.IsWhiteSpace(s[i])) return i;
return -1;
}
private static string Indent(string line) => line.Substring(0, line.Length - line.TrimStart().Length);
// ================= 주석 · 문자열 가리기 =================
/// <summary>줄마다 코드만 남긴 글(주석은 공백, 문자열 속은 '_'). 길이는 원문과 같다.</summary>
private sealed class Masked
{
public Masked(string[] code, bool[] inside, bool[] preprocessor)
{
Code = code;
Inside = inside;
Preprocessor = preprocessor;
}
public string[] Code { get; }
/// <summary>줄이 블록 주석 · 여러 줄 문자열 안에서 시작하는지.</summary>
public bool[] Inside { get; }
public bool[] Preprocessor { get; }
}
private enum Mode { Code, LineComment, BlockComment, Str, VerbatimStr, InterpStr, InterpVerbatimStr, Char }
private static Masked Mask(IReadOnlyList<string> lines)
{
var src = string.Join("\n", lines);
var mask = src.ToCharArray();
var modes = new Stack<Mode>();
modes.Push(Mode.Code);
var holes = new Stack<int>(); // 보간 구멍마다 그 안의 중괄호 깊이
var inside = new bool[lines.Count];
var line = 0;
var n = src.Length;
var i = 0;
while (i < n)
{
var ch = src[i];
var next = i + 1 < n ? src[i + 1] : '\0';
var next2 = i + 2 < n ? src[i + 2] : '\0';
if (ch == '\n')
{
var top = modes.Peek();
if (top is Mode.LineComment or Mode.Str or Mode.Char or Mode.InterpStr) { modes.Pop(); top = modes.Peek(); }
line++;
if (line < inside.Length) inside[line] = top != Mode.Code || holes.Count > 0;
i++;
continue;
}
switch (modes.Peek())
{
case Mode.Code:
if (ch == '/' && next == '/') { modes.Push(Mode.LineComment); mask[i] = ' '; mask[i + 1] = ' '; i += 2; continue; }
if (ch == '/' && next == '*') { modes.Push(Mode.BlockComment); mask[i] = ' '; mask[i + 1] = ' '; i += 2; continue; }
if ((ch == '$' && next == '@' && next2 == '"') || (ch == '@' && next == '$' && next2 == '"'))
{ modes.Push(Mode.InterpVerbatimStr); Hide(mask, i, 3, holes); i += 3; continue; }
if (ch == '$' && next == '"') { modes.Push(Mode.InterpStr); Hide(mask, i, 2, holes); i += 2; continue; }
if (ch == '@' && next == '"') { modes.Push(Mode.VerbatimStr); Hide(mask, i, 2, holes); i += 2; continue; }
if (ch == '"') { modes.Push(Mode.Str); Hide(mask, i, 1, holes); i++; continue; }
if (ch == '\'') { modes.Push(Mode.Char); Hide(mask, i, 1, holes); i++; continue; }
if (holes.Count > 0)
{
if (ch == '{') holes.Push(holes.Pop() + 1);
else if (ch == '}')
{
var depth = holes.Pop();
if (depth == 0) { modes.Pop(); mask[i] = '_'; i++; continue; }
holes.Push(depth - 1);
}
mask[i] = '_';
}
i++;
continue;
case Mode.LineComment:
mask[i] = ' ';
i++;
continue;
case Mode.BlockComment:
if (ch == '*' && next == '/') { mask[i] = ' '; mask[i + 1] = ' '; modes.Pop(); i += 2; continue; }
mask[i] = ' ';
i++;
continue;
case Mode.Str:
case Mode.Char:
var quote = modes.Peek() == Mode.Str ? '"' : '\'';
if (ch == '\\') { mask[i] = '_'; if (i + 1 < n && next != '\n') mask[i + 1] = '_'; i += next == '\n' ? 1 : 2; continue; }
if (ch == quote) { modes.Pop(); mask[i] = '_'; i++; continue; }
mask[i] = '_';
i++;
continue;
case Mode.VerbatimStr:
if (ch == '"' && next == '"') { mask[i] = '_'; mask[i + 1] = '_'; i += 2; continue; }
if (ch == '"') modes.Pop();
mask[i] = '_';
i++;
continue;
case Mode.InterpStr:
case Mode.InterpVerbatimStr:
var verbatim = modes.Peek() == Mode.InterpVerbatimStr;
if ((ch == '{' && next == '{') || (ch == '}' && next == '}') || (verbatim && ch == '"' && next == '"'))
{ mask[i] = '_'; mask[i + 1] = '_'; i += 2; continue; }
if (!verbatim && ch == '\\') { mask[i] = '_'; if (next != '\n' && i + 1 < n) mask[i + 1] = '_'; i += next == '\n' ? 1 : 2; continue; }
if (ch == '"') { modes.Pop(); mask[i] = '_'; i++; continue; }
if (ch == '{') { modes.Push(Mode.Code); holes.Push(0); mask[i] = '_'; i++; continue; }
mask[i] = '_';
i++;
continue;
}
}
var code = new string(mask).Split('\n');
var preprocessor = new bool[code.Length];
for (var k = 0; k < code.Length; k++)
preprocessor[k] = !inside[k] && code[k].TrimStart().StartsWith("#", StringComparison.Ordinal);
return new Masked(code, inside, preprocessor);
}
/// <summary>문자열 여는 기호를 가린다. 보간 구멍 안에서 연 문자열이면 원래도 가려져야 할 자리다.</summary>
private static void Hide(char[] mask, int at, int count, Stack<int> holes)
{
for (var k = 0; k < count && at + k < mask.Length; k++) mask[at + k] = '_';
}
}
}

View File

@ -61,6 +61,12 @@ namespace MergeHub.Services
/// </summary> /// </summary>
public bool IgnoreMergeNotes { get; set; } public bool IgnoreMergeNotes { get; set; }
/// <summary>
/// C# 파일을 이 스타일로 맞춘 뒤 견줄지(6차 추가). 중괄호 줄바꿈만 다른 파일은 "같음" 이다.
/// 줄끝 · 인코딩 차이는 가리지 않는다.
/// </summary>
public CSharpBraceStyle CSharpStyle { get; set; }
/// <summary> /// <summary>
/// 수정 시각을 같다고 볼 오차. 파일 시스템마다 정밀도가 달라(FAT 는 2초) 정확히 같기를 /// 수정 시각을 같다고 볼 오차. 파일 시스템마다 정밀도가 달라(FAT 는 2초) 정확히 같기를
/// 기대하면 안 된다. /// 기대하면 안 된다.
@ -399,7 +405,8 @@ namespace MergeHub.Services
{ {
if (TextFile.IsBinary(leftPath) || TextFile.IsBinary(rightPath)) return FolderEntryStatus.Binary; if (TextFile.IsBinary(leftPath) || TextFile.IsBinary(rightPath)) return FolderEntryStatus.Binary;
if (options.IgnoreMergeNotes && OnlyMergeNotesDiffer(leftPath, rightPath, size, options)) if ((options.IgnoreMergeNotes || options.CSharpStyle != CSharpBraceStyle.None) &&
OnlyMergeNotesDiffer(leftPath, rightPath, size, options))
return FolderEntryStatus.Same; return FolderEntryStatus.Same;
return OnlyCommentsDiffer(leftPath, rightPath, size, options) return OnlyCommentsDiffer(leftPath, rightPath, size, options)
@ -451,23 +458,34 @@ namespace MergeHub.Services
{ {
if (size > options.FullCompareLimitBytes) return false; if (size > options.FullCompareLimitBytes) return false;
var left = WithoutMergeNotes(leftPath, out var leftCut); var left = WithoutMergeNotes(leftPath, options, out var leftCut);
if (left is null) return false; if (left is null) return false;
var right = WithoutMergeNotes(rightPath, out var rightCut); var right = WithoutMergeNotes(rightPath, options, out var rightCut);
if (right is null) return false; if (right is null) return false;
return (leftCut || rightCut) && string.Equals(left, right, StringComparison.Ordinal); return (leftCut || rightCut) && string.Equals(left, right, StringComparison.Ordinal);
} }
/// <summary>머지 주석을 걷어낸 글. 읽지 못하면 null. 하나라도 걷어냈으면 <paramref name="cut"/> 이 참.</summary> /// <summary>
private static string? WithoutMergeNotes(string path, out bool cut) /// 머지 주석을 걷어내고(켰으면) C# 스타일을 맞춘(골랐으면) 글. 읽지 못하면 null.
/// 하나라도 바꾼 것이 있으면 <paramref name="cut"/> 이 참.
/// 스타일 맞춤은 줄끝을 그 파일의 줄끝으로 다시 이으므로, 양쪽 줄끝이 다르면 여전히 다르게 나온다.
/// </summary>
private static string? WithoutMergeNotes(string path, FolderCompareOptions options, out bool cut)
{ {
cut = false; cut = false;
try try
{ {
var text = TextFile.Read(path); 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); var builder = new System.Text.StringBuilder(text.Length);
foreach (var raw in text.Split('\n')) foreach (var raw in text.Split('\n'))

View File

@ -334,6 +334,7 @@ namespace MergeHub.ViewModels
IncludePattern = IncludePattern, IncludePattern = IncludePattern,
ExcludePattern = ExcludePattern, ExcludePattern = ExcludePattern,
IgnoreMergeNotes = _workspace.Settings.IgnoreMergeNotes, IgnoreMergeNotes = _workspace.Settings.IgnoreMergeNotes,
CSharpStyle = _workspace.Settings.CSharpBraceStyle,
}; };
var left = LeftFolder; var left = LeftFolder;

View File

@ -198,6 +198,12 @@ namespace MergeHub.ViewModels
// 행을 싣기 전에 틀부터 네 칸으로 바꾼다(LoadFileAsync 와 같은 까닭). // 행을 싣기 전에 틀부터 네 칸으로 바꾼다(LoadFileAsync 와 같은 까닭).
SetToolThreeWay(true); SetToolThreeWay(true);
// C# 스타일 맞춤(6차 추가).
var rawLocalText = localText;
baseText = Styled(baseText);
localText = Styled(localText);
remoteText = Styled(remoteText);
_baseTextSnapshot = baseText; _baseTextSnapshot = baseText;
_mineTextSnapshot = localText; _mineTextSnapshot = localText;
_remoteTextSnapshot = remoteText; _remoteTextSnapshot = remoteText;
@ -226,7 +232,7 @@ namespace MergeHub.ViewModels
_undo.Clear(); _undo.Clear();
_redo.Clear(); _redo.Clear();
HasChanges = false; MarkStyleChange(rawLocalText, localText);
CaretRow = 0; CaretRow = 0;
InvalidateDiffBlocks(); InvalidateDiffBlocks();
@ -584,7 +590,12 @@ namespace MergeHub.ViewModels
partial void OnCommitMessageChanged(string value) => RaiseCanExecuteChanged(); partial void OnCommitMessageChanged(string value) => RaiseCanExecuteChanged();
partial void OnHasChangesChanged(bool value) => RaiseCanExecuteChanged(); partial void OnHasChangesChanged(bool value)
{
// 저장했거나 버렸으면 "스타일 맞춤으로 바뀐 글" 도 끝난 것이다.
if (!value) _styleRawLocalText = null;
RaiseCanExecuteChanged();
}
/// <summary>새로고침으로 목록을 다시 만들 때 선택을 잇는 중이면 재진입을 막는다.</summary> /// <summary>새로고침으로 목록을 다시 만들 때 선택을 잇는 중이면 재진입을 막는다.</summary>
private bool _syncingSelection; private bool _syncingSelection;
@ -1257,6 +1268,12 @@ namespace MergeHub.ViewModels
// 행을 만들기 전에 언어를 정해야 한다 - 블록 주석 이어붙이기가 이 값을 본다. // 행을 만들기 전에 언어를 정해야 한다 - 블록 주석 이어붙이기가 이 값을 본다.
RefreshLanguage(); RefreshLanguage();
// C# 스타일 맞춤(6차 추가). 세 벌 모두 같은 스타일로 맞춘 뒤 견준다.
var rawLocalText = localText;
baseText = Styled(baseText);
localText = Styled(localText);
remoteText = Styled(remoteText);
var rows = threeWay var rows = threeWay
? BuildThreeWayLines(baseText, localText, remoteText) ? BuildThreeWayLines(baseText, localText, remoteText)
: BuildLines(baseText, localText); : BuildLines(baseText, localText);
@ -1329,6 +1346,7 @@ namespace MergeHub.ViewModels
ClearAutoCommitMessage(); ClearAutoCommitMessage();
} }
MarkStyleChange(rawLocalText, localText);
UpdateSummaries(); UpdateSummaries();
RaiseCanExecuteChanged(); RaiseCanExecuteChanged();
}); });
@ -2117,7 +2135,7 @@ namespace MergeHub.ViewModels
if (moveSelection) if (moveSelection)
SelectedLine = Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault(); SelectedLine = Lines.FirstOrDefault(l => l.IsDifferent) ?? Lines.FirstOrDefault();
HasChanges = !string.Equals(localText, _originalLocalText, StringComparison.Ordinal); HasChanges = !string.Equals(localText, _originalLocalText, StringComparison.Ordinal) || _styleRawLocalText is not null;
InvalidateDiffBlocks(); InvalidateDiffBlocks();
SyncEditorFromLines(); SyncEditorFromLines();
@ -2395,6 +2413,11 @@ namespace MergeHub.ViewModels
// 읽어 오는 사이에 Git 모드로 넘어갔으면 버린다(위 LoadFileAsync 와 같은 이유). // 읽어 오는 사이에 Git 모드로 넘어갔으면 버린다(위 LoadFileAsync 와 같은 이유).
if (IsGitMode) return; if (IsGitMode) return;
// C# 스타일 맞춤(6차 추가).
var rawLocalText = localText;
baseText = Styled(baseText);
localText = Styled(localText);
_baseTextSnapshot = baseText; _baseTextSnapshot = baseText;
_remoteTextSnapshot = string.Empty; // 파일 두 개 비교는 늘 2-Way 다 _remoteTextSnapshot = string.Empty; // 파일 두 개 비교는 늘 2-Way 다
_mineTextSnapshot = string.Empty; _mineTextSnapshot = string.Empty;
@ -2421,7 +2444,8 @@ namespace MergeHub.ViewModels
// 새로 읽은 파일에는 고친 것이 없다. 이걸 안 끄면 앞 파일의 상태가 그대로 끌려와 // 새로 읽은 파일에는 고친 것이 없다. 이걸 안 끄면 앞 파일의 상태가 그대로 끌려와
// 저장 단추가 켜진 채로 남고, 다음에 파일을 열 때마다 "저장하지 않은 편집" 을 묻는다. // 저장 단추가 켜진 채로 남고, 다음에 파일을 열 때마다 "저장하지 않은 편집" 을 묻는다.
HasChanges = false; // 스타일 맞춤으로 글이 바뀌었을 때만 저장할 것이 있다.
MarkStyleChange(rawLocalText, localText);
CaretRow = 0; CaretRow = 0;
InvalidateDiffBlocks(); InvalidateDiffBlocks();
@ -2565,6 +2589,10 @@ namespace MergeHub.ViewModels
if (_suspendGuard) return true; if (_suspendGuard) return true;
if (!HasChanges) return true; if (!HasChanges) return true;
// 스타일 맞춤으로만 바뀐 글은 다시 열면 똑같이 만들어진다 - 묻지 않는다.
// 묻게 두면 스타일이 다른 파일을 훑을 때마다 창이 떠서 쓸 수가 없다.
if (OnlyStyleChanged) return true;
// <b>고친 파일</b>의 이름을 댄다(6차 B-2). 파일을 바꿀 때는 이 창이 뜨는 순간 SelectedFile 이 이미 새 파일이라, // <b>고친 파일</b>의 이름을 댄다(6차 B-2). 파일을 바꿀 때는 이 창이 뜨는 순간 SelectedFile 이 이미 새 파일이라,
// 예전에는 그 새 파일 이름을 댔다 - Motion/Unit0.cs 를 고쳤는데 "ToBeDeleted.cs 에 저장하지 않은 편집" 이라고 했다. // 예전에는 그 새 파일 이름을 댔다 - Motion/Unit0.cs 를 고쳤는데 "ToBeDeleted.cs 에 저장하지 않은 편집" 이라고 했다.
// Git 모드는 저장소 안 경로로 적는다. 다른 폴더에 같은 이름 파일이 흔하다. // Git 모드는 저장소 안 경로로 적는다. 다른 폴더에 같은 이름 파일이 흔하다.
@ -2707,6 +2735,14 @@ namespace MergeHub.ViewModels
/// </summary> /// </summary>
public void RecompareIfSettingsChanged() public void RecompareIfSettingsChanged()
{ {
// C# 스타일은 원본부터 다시 읽어야 맞춰진다(편집 칸만 다시 정렬하면 BASE 가 옛 스타일로 남는다).
if (_workspace.Settings.CSharpBraceStyle != _appliedStyle && Lines.Count > 0)
{
OnPropertyChanged(nameof(CSharpStyleIndex));
if (ConfirmDiscard("설정에서 C# 스타일이 바뀌어 다시 엽니다.")) ReloadCurrentSource();
return;
}
var now = SettingsSignature(); var now = SettingsSignature();
if (now == _appliedSettingsSignature) return; if (now == _appliedSettingsSignature) return;
@ -2747,6 +2783,83 @@ namespace MergeHub.ViewModels
ApplyRuleChange(); ApplyRuleChange();
} }
// ================= C# 코드 스타일 맞춤 (6차 추가) =================
/// <summary>콤보에 보이는 이름. 순서가 <see cref="CSharpBraceStyle"/> 값과 같다.</summary>
public IReadOnlyList<string> CSharpStyleNames { get; } = new[] { "C# 스타일 끔", "C# AURORA", "C# Default" };
/// <summary>
/// 고른 스타일. 바꾸면 지금 파일을 <b>원본부터 다시 읽어</b> 그 스타일로 맞춘다.
/// 공백 · 대소문자 규칙과 달리 편집 칸 글만 다시 정렬해서는 안 된다 - BASE · REMOTE 도 다시 맞춰야 한다.
/// </summary>
public int CSharpStyleIndex
{
get => (int)_workspace.Settings.CSharpBraceStyle;
set
{
var style = (CSharpBraceStyle)Math.Max(0, Math.Min(2, value));
if (style == _workspace.Settings.CSharpBraceStyle) return;
if (!ConfirmDiscard($"C# 스타일을 {CSharpStyle.Label(style)} 로 바꿔 다시 엽니다."))
{
// 콤보는 제 선택 처리 중에 되돌린 값을 무시한다 - 다음 차례에 알린다(6차 B-2 와 같다).
Dispatcher.CurrentDispatcher.BeginInvoke(
new Action(() => OnPropertyChanged(nameof(CSharpStyleIndex))), DispatcherPriority.Background);
return;
}
_workspace.Settings.CSharpBraceStyle = style;
_workspace.SaveSettings();
OnPropertyChanged();
ReloadCurrentSource();
}
}
/// <summary>스타일 맞춤을 걸기 전의 편집 칸 글. 맞춤으로 글이 바뀌었을 때만 있다.</summary>
private string? _styleRawLocalText;
/// <summary>지금 불러온 글에 적용한 스타일. 설정 화면에서 바꾸고 돌아왔는지 알아보려고 둔다.</summary>
private CSharpBraceStyle _appliedStyle;
/// <summary>고친 것이 스타일 맞춤뿐인지(사람이 손대지 않았다).</summary>
private bool OnlyStyleChanged =>
_styleRawLocalText is not null && string.Equals(BuildLocalText(), _originalLocalText, StringComparison.Ordinal);
/// <summary>C# 파일이고 스타일을 골랐으면 그 스타일로 맞춘 글. 아니면 그대로.</summary>
private string Styled(string text) =>
CSharpStyle.Applies(CurrentFilePath, _workspace.Settings.CSharpBraceStyle)
? CSharpStyle.Format(text, _workspace.Settings.CSharpBraceStyle)
: text;
/// <summary>
/// 불러오기를 마칠 때 부른다. 스타일 맞춤으로 편집 칸 글이 바뀌었으면 <b>저장할 것이 있는</b> 상태로 둔다 -
/// 저장하면 그 스타일이 파일에 반영된다. 안 바뀌었으면 고친 것 없음.
/// </summary>
private void MarkStyleChange(string raw, string styled)
{
_appliedStyle = _workspace.Settings.CSharpBraceStyle;
_styleRawLocalText = null;
HasChanges = false;
RefreshRuleLabels(); // 상태바 "무시: …" 요약에 지금 스타일을 적는다
if (string.Equals(raw, styled, StringComparison.Ordinal)) return;
_styleRawLocalText = raw;
HasChanges = true;
var before = raw.Split('\n').Length;
var after = styled.Split('\n').Length;
_workspace.AppendLog($"C# 스타일({CSharpStyle.Label(_appliedStyle)})로 맞춤: 편집 칸 {before}줄 → {after}줄. 저장하면 파일에 반영됩니다.");
}
/// <summary>지금 보고 있는 파일을 원본부터 다시 읽는다. Git · 파일 두 개 · mergetool 중 지금 길로.</summary>
private void ReloadCurrentSource()
{
if (IsGitMode) _ = LoadFileAsync();
else if (_mergeTool is { } tool) _ = LoadMergeToolAsync(tool);
else if (!string.IsNullOrWhiteSpace(LeftFile) || !string.IsNullOrWhiteSpace(RightFile)) _ = LoadPlainFilesAsync();
}
/// <summary>규칙이 바뀌면 지금 보고 있는 내용을 그대로 다시 정렬한다.</summary> /// <summary>규칙이 바뀌면 지금 보고 있는 내용을 그대로 다시 정렬한다.</summary>
private void ApplyRuleChange() private void ApplyRuleChange()
{ {
@ -2765,7 +2878,11 @@ namespace MergeHub.ViewModels
OnPropertyChanged(nameof(WhitespaceLabel)); OnPropertyChanged(nameof(WhitespaceLabel));
OnPropertyChanged(nameof(IgnoreCaseLabel)); OnPropertyChanged(nameof(IgnoreCaseLabel));
OnPropertyChanged(nameof(LineFilterLabel)); OnPropertyChanged(nameof(LineFilterLabel));
RulesSummary = Rules.Describe() ?? string.Empty; var style = _workspace.Settings.CSharpBraceStyle;
var summary = Rules.Describe() ?? string.Empty;
RulesSummary = style == CSharpBraceStyle.None
? summary
: string.IsNullOrEmpty(summary) ? $"C# {CSharpStyle.Label(style)}" : $"{summary} · C# {CSharpStyle.Label(style)}";
} }
// ================= 차이 탐색 · 이동 · 북마크 ================= // ================= 차이 탐색 · 이동 · 북마크 =================
@ -3069,7 +3186,7 @@ namespace MergeHub.ViewModels
private void AfterEdit() private void AfterEdit()
{ {
HasChanges = _undo.Count > 0; HasChanges = _undo.Count > 0 || _styleRawLocalText is not null;
RefreshAppliedFlags(); RefreshAppliedFlags();
RefreshBlockCommentState(Lines); RefreshBlockCommentState(Lines);
InvalidateDiffBlocks(); InvalidateDiffBlocks();

View File

@ -1665,6 +1665,11 @@
MinHeight="28" FontSize="11" Margin="2,4,2,0" MinHeight="28" FontSize="11" Margin="2,4,2,0"
ToolTip="긴 줄을 접어서 보여줍니다 (가로 스크롤 대신)"/> ToolTip="긴 줄을 접어서 보여줍니다 (가로 스크롤 대신)"/>
</UniformGrid> </UniformGrid>
<!-- C# 코드 스타일 맞춤(6차 추가). 중괄호 줄바꿈만 달라 차이가 많이 뜨는 것을 막는다. -->
<ComboBox ItemsSource="{Binding CSharpStyleNames}" SelectedIndex="{Binding CSharpStyleIndex}"
MinHeight="26" FontSize="11" Margin="2,4,2,0"
Visibility="{Binding IsRulesOpen, Converter={StaticResource BoolToVis}}"
ToolTip="C# 파일의 여는 중괄호 · else/catch/finally 줄바꿈을 이 스타일로 맞춘 뒤 비교합니다.&#x0a;AURORA: 형식 · 메서드 중괄호만 새 줄, 나머지는 같은 줄, } else {&#x0a;Default: Visual Studio 기본 - 모두 새 줄&#x0a;편집 칸 글이 바뀌면 저장할 때 그 스타일로 저장됩니다."/>
<Button Content="{Binding ReadOnlyLabel}" Click="ToggleReadOnly_Click" <Button Content="{Binding ReadOnlyLabel}" Click="ToggleReadOnly_Click"
MinHeight="28" FontSize="11" Margin="2,4,2,0" MinHeight="28" FontSize="11" Margin="2,4,2,0"
Visibility="{Binding IsRulesOpen, Converter={StaticResource BoolToVis}}" Visibility="{Binding IsRulesOpen, Converter={StaticResource BoolToVis}}"