diff --git a/doc/history/2026-09-18-11차.md b/doc/history/2026-09-18-11차.md
index e9e60ae..e6ba3ea 100644
--- a/doc/history/2026-09-18-11차.md
+++ b/doc/history/2026-09-18-11차.md
@@ -96,6 +96,37 @@ A-1 에서 값으로 확인한 것 — 지운 파일은 "삭제되어 건너뜀
- 목록에서 스크롤이 필요한 줄은 **누를 때마다 자리를 다시 읽어야** 한다. 옛 자리로 누르면 조용히 빗나가고, 그 뒤 [여기부터] 가 엉뚱한 커밋(HEAD)으로 잡혀 범위가 거꾸로 됐다.
- PowerShell 변수는 대소문자를 안 가린다 — `$S`(경로)와 `$s`(파일 글)가 겹쳐 경로가 깨졌다(9차 A-2 와 같은 종류).
+## A-3. 이미 파일이 있는 폴더 · 쓰다 막힐 때 (2026-09-18, 완료)
+
+사용자 결정(2026-09-18): **고른 폴더 아래 새 폴더를 만든다** · 쓰다 막히면 **먼저 보고 시작한다**(하나라도 못 쓰면 아무것도 쓰지 않는다).
+
+### 고친 것
+
+| 자리 | 한 일 |
+|---|---|
+| `Services/ExportPlan.cs` (새 파일) | `FolderName` — `변경분_4eb9ece..cbcad8a_2026-09-18` 처럼 범위와 날짜가 들어간 이름. `UniquePath` — 같은 이름이 있으면 `_2` · `_3`. `Blocked` — 쓰기 전에 읽기 전용 · 다른 프로그램이 잡고 있는 파일을 걸러 목록으로. `BlockedMessage` — 사람에게 보일 글(앞 다섯 개 + "외 N개") |
+| `Services/GitService.cs` | 뽑기 전에 `ExportPlan.Blocked` 로 **먼저 본다**. 하나라도 막히면 폴더도 만들지 않고 `IOException` 으로 멈춘다 — 예전에는 앞 순서만 써진 반쯤 뽑힌 폴더가 남았다(A-1 경우 9) |
+| `ViewModels/GitViewModel.cs` | 고른 폴더에 바로 쏟지 않고 **그 아래 새 폴더**를 만들어 거기에 뽑는다. 안내 글은 그 폴더 옆(= 고른 폴더 안)에 붙는다. 알림도 폴더 이름을 앞에 — "변경분_4eb9ece..cbcad8a_2026-09-18 에 3개 (지워야 할 파일 1개는 안내에)" |
+| 하네스 `ReportTest` | **+12건(45 → 57)** — 폴더 이름 · `_2` · `_3` · 읽기 전용은 막힘 · 아직 없는 파일은 막힘 아님 · 열려 있는 파일은 막힘 · 보일 글의 개수와 줄임 |
+
+### 확인한 것 — 화면에서
+
+이미 파일이 있는 폴더(사람이 넣어 둔 `사용자메모.txt` · 옛 폴더 `옛자료`)를 골라 같은 범위를 **두 번** 뽑았다.
+
+| 때 | 결과 |
+|---|---|
+| 첫 번째 | `…\a3\변경분_4eb9ece..cbcad8a_2026-09-18` 에 3개 · 안내는 그 옆에 |
+| 두 번째 | `…_2` 폴더가 새로 생기고 거기에 3개 · 안내도 `…_2_변경분안내.txt` |
+| 고른 폴더 안 | 새 폴더 둘 · 안내 둘 · **사용자메모.txt 와 옛자료 그대로** — 섞이지 않았다 |
+
+- **먼저 보기**(`Blocked`)는 하네스 12건으로 확인했다. 화면에서는 재현되지 않는데, 뽑을 때마다 **비어 있는 새 폴더**를 만들기 때문이다 — A-1 경우 9(반쯤 뽑힌 폴더)가 애초에 생기지 않는다. 남겨 둔 까닭은 폴더가 이미 있는 드문 경우(다른 프로그램이 같은 이름을 방금 만든 경우)와, 나중에 폴더를 다시 쓰게 고칠 때를 위한 것이다.
+- 하네스 22벌 **1,185건** 통과(1,173 → +12) · 빌드 경고 0 · 오류 0.
+
+### 남은 것
+
+- A-4(ZIP)는 아직 정하지 않았다.
+- 매뉴얼 · 웹 문서의 변경분 추출 설명(새 폴더 · 안내 파일)은 E-2 에서 함께 고친다.
+
## F-12. C# 코드 스타일 맞춤의 기본값을 AURORA 로 (2026-09-18, 완료)
사용자: "기본값은 원본형태를 따르고 C#인 경우에 AURORA 스타일로 하자" → 물어서 정한 것은 **제품 기본값 AURORA · C# 파일만 · 저장 동작은 지금 그대로**(켜져 있으면 저장도 그 스타일로 쓴다).
diff --git a/doc/history/changelog.md b/doc/history/changelog.md
index e3af49f..b5f0ae3 100644
--- a/doc/history/changelog.md
+++ b/doc/history/changelog.md
@@ -8,6 +8,7 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
+| 2026-09-18 | **11차 A-3** — 변경분을 고른 폴더에 바로 쏟지 않고 **그 아래 새 폴더**(`변경분_범위_날짜`, 같은 이름이면 `_2`)를 만들어 뽑는다(사용자 결정). 예전에는 묻지 않고 덮어써 지난번에 뽑은 것과 사람이 넣어 둔 파일이 섞였다. 또 뽑기 전에 읽기 전용 · 열려 있는 파일을 **먼저 보고** 하나라도 막히면 아무것도 쓰지 않는다 — 반쯤 뽑힌 폴더가 남지 않는다. 하네스 ReportTest +12(57건) | 아래 |
| 2026-09-18 | **11차 A-2** — 변경분을 뽑으면 **폴더 옆에 안내 파일**(`…_변경분안내.txt`)을 함께 만든다(사용자 결정: 폴더 밖 · 지울 파일은 목록으로만). A-1 에서 지운 파일 · 이름 바뀐 옛 경로 · 뽑은 범위가 결과에 안 남는 것을 확인했기 때문. 안내에 범위(커밋 제목 · 날짜) · 뽑은 파일(추가 · 수정 · 이름 바뀜은 옛 이름 병기) · **장비에서 지워야 할 파일**을 적는다. 추출이 무엇을 했는지 목록으로 돌려주도록 `ExportChangedFiles` 를 고치고, 안내를 못 써도 추출은 성공으로 둔다. 하네스 ReportTest +14(45건) | 아래 |
| 2026-09-18 | **11차 F-12 (추가 요청)** — C# 코드 스타일 맞춤의 제품 기본값을 끔 → **AURORA** 로(사용자 결정). 사내 코드가 이 스타일이라 설정을 켜지 않아도 중괄호 자리 차이를 안 보게 한다. C/C++ · XAML 은 원래부터 확장자 `.cs` 에만 걸려 손대지 않음. 저장 동작은 그대로. 설정 없는 첫 실행에서 AURORA 확인, C# 짝 "차이 없음" · C++ 짝 "남은 차이 4줄". 하네스 StyleTest +12(90건) | 아래 |
| 2026-09-18 | **11차 A-1** — 변경분 추출을 화면에서 아홉 경우로 뽑아 git 과 대조(고치지 않음). 파일 고르기 · 바이트 보존(이진 · 한글 · 깊은 경로) · 범위 표시는 맞았다. 확정된 문제 넷: 지운 파일 · 이름 바뀐 옛 경로가 결과에 안 남음, 무엇과 무엇 사이를 뽑았는지 안 남음(병합 커밋은 첫 부모와만 · 거꾸로 범위도 말없이 됨), 비어 있지 않은 폴더에 묻지 않고 덮어써 옛 추출이 섞임, 쓰다 막히면 반쯤 뽑힌 채 멈춤. A-2 · A-3 으로 넘김. 제품 코드 변경 없음 | 아래 |
diff --git a/doc/진행상황.md b/doc/진행상황.md
index 4ed4fe2..e420fef 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -336,7 +336,7 @@
|---|---|
| **완료** | A-1 먼저 뽑아 보고 적는다 — 고치지 않음. 아홉 경우를 화면에서 뽑아 git 과 대조: 파일 고르기 · 바이트 보존(이진 · 한글 · 깊은 경로) · 범위 표시는 맞음. 확정된 문제 넷 — 지운 파일 · 이름 바뀐 옛 경로가 안 남음, 무엇과 무엇 사이인지 안 남음(병합은 첫 부모 · 거꾸로 범위도 말없이), 비어 있지 않은 폴더에 묻지 않고 덮어써 옛 파일이 섞임, 쓰다 막히면 반쯤 뽑힌 채 멈춤 |
| **완료** | A-2 뽑은 폴더 **옆**에 안내 파일(`…_변경분안내.txt`) — 사용자 결정대로 폴더 밖에 두고 지울 파일은 목록으로만. 범위(커밋 제목 · 날짜) · 뽑은 파일(추가 · 수정 · 이름 바뀜은 옛 이름) · 장비에서 지워야 할 파일(지워짐 · 옛 경로). 화면에서 뽑아 git 과 대조 · 하네스 ReportTest +14(45건) |
-| 대기 | A-3 이미 파일이 있는 폴더에 뽑을 때 — 묻기 · 새 폴더 · 그대로 중 정함 |
+| **완료** | A-3 고른 폴더 아래 **새 폴더**(`변경분_4eb9ece..cbcad8a_2026-09-18`, 같은 이름이면 `_2`)를 만들어 뽑는다 · 뽑기 전에 쓸 수 있는지 **먼저 보고** 하나라도 막히면 아무것도 쓰지 않는다(사용자 결정). 화면에서 두 번 뽑아 기존 파일이 섞이지 않는 것 확인 · 하네스 ReportTest +12(57건) |
| 대기 | A-4 ZIP 으로 뽑기 — 할지 먼저 정함 |
### B. 사내 베타 전에 깨지는 것
diff --git a/src/MergeHub/Services/ExportPlan.cs b/src/MergeHub/Services/ExportPlan.cs
new file mode 100644
index 0000000..6a1cd2b
--- /dev/null
+++ b/src/MergeHub/Services/ExportPlan.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace MergeHub.Services
+{
+ ///
+ /// 변경분을 어디에 어떻게 뽑을지 먼저 정하는 자리(11차 A-3).
+ ///
+ /// A-1 에서 값으로 본 두 가지를 막는다.
+ /// ① 이미 파일이 있는 폴더에 뽑으면 묻지 않고 덮어써, 지난번에 뽑은 것과 사람이 넣어 둔 파일이 섞였다.
+ /// → 사용자 결정(2026-09-18): 고른 폴더 아래 새 폴더를 만든다. 매번 깨끗하고, 여러 번 뽑아 모아 둘 수 있다.
+ /// ② 쓰다 막히면(읽기 전용 · 다른 프로그램이 열어 둔 파일) 앞 순서는 이미 써진 채 멈췄다.
+ /// → 사용자 결정: 먼저 보고 시작한다. 하나라도 못 쓰면 아무것도 쓰지 않고 목록을 보여 준다.
+ ///
+ /// 파일을 실제로 옮기는 일은 가 한다.
+ ///
+ public static class ExportPlan
+ {
+ /// 새로 만들 폴더 이름. 범위와 날짜가 들어가 폴더만 봐도 무엇인지 안다.
+ public static string FolderName(string fromShort, string toShort, DateTime when)
+ {
+ var from = Short(fromShort);
+ var to = Short(toShort);
+ return $"변경분_{from}..{to}_{when:yyyy-MM-dd}";
+ }
+
+ ///
+ /// 같은 이름이 이미 있으면 _2 · _3 을 붙여 비어 있는 이름을 고른다.
+ /// 하루에 같은 범위를 두 번 뽑아도 앞의 것을 건드리지 않는다.
+ ///
+ public static string UniquePath(string parent, string name)
+ {
+ var path = Path.Combine(parent, name);
+ if (!Directory.Exists(path) && !File.Exists(path)) return path;
+
+ for (var n = 2; n < 1000; n++)
+ {
+ var next = Path.Combine(parent, $"{name}_{n}");
+ if (!Directory.Exists(next) && !File.Exists(next)) return next;
+ }
+
+ return Path.Combine(parent, $"{name}_{DateTime.Now:HHmmss}");
+ }
+
+ ///
+ /// 뽑기 전에 쓸 수 있는지 본다. 못 쓰는 파일의 경로(저장소 안 경로)를 돌려준다.
+ ///
+ /// 새 폴더에 뽑으면 보통 비어 있지만, 같은 이름이 남아 있거나 폴더째 읽기 전용인 경우가 있다.
+ /// 읽기 전용 · 다른 프로그램이 잡고 있는 파일을 여기서 걸러 한 글자도 쓰기 전에 알린다.
+ ///
+ public static IReadOnlyList Blocked(string targetDirectory, IEnumerable relativePaths)
+ {
+ var blocked = new List();
+
+ foreach (var relative in relativePaths ?? Enumerable.Empty())
+ {
+ var target = Path.Combine(targetDirectory, relative.Replace('/', Path.DirectorySeparatorChar));
+ if (!File.Exists(target)) continue; // 없으면 새로 만들 자리다
+
+ try
+ {
+ if ((File.GetAttributes(target) & FileAttributes.ReadOnly) != 0)
+ {
+ blocked.Add(relative);
+ continue;
+ }
+
+ // 다른 프로그램이 잡고 있으면 여기서 막힌다. 열어 보기만 하고 바로 닫는다.
+ using var probe = File.Open(target, FileMode.Open, FileAccess.Write, FileShare.None);
+ }
+ catch (IOException)
+ {
+ blocked.Add(relative);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ blocked.Add(relative);
+ }
+ }
+
+ return blocked;
+ }
+
+ /// 못 쓰는 파일이 있을 때 사람에게 보일 글. 너무 길지 않게 앞의 몇 개만 든다.
+ public static string BlockedMessage(IReadOnlyList blocked, int show = 5)
+ {
+ var head = string.Join("\n ", blocked.Take(show));
+ var more = blocked.Count > show ? $"\n … 외 {blocked.Count - show}개" : string.Empty;
+ return $"쓸 수 없는 파일이 {blocked.Count}개 있어 아무것도 뽑지 않았습니다:\n {head}{more}";
+ }
+
+ private static string Short(string sha)
+ {
+ var value = (sha ?? string.Empty).Trim();
+ return value.Length > 7 ? value.Substring(0, 7) : value;
+ }
+ }
+}
diff --git a/src/MergeHub/Services/GitService.cs b/src/MergeHub/Services/GitService.cs
index 9e5407d..17c9f3f 100644
--- a/src/MergeHub/Services/GitService.cs
+++ b/src/MergeHub/Services/GitService.cs
@@ -1337,13 +1337,21 @@ namespace MergeHub.Services
var newCommit = ResolveCommit(newCommittish)
?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {newCommittish}");
+ var diffs = DiffCommits(oldCommittish, newCommittish);
+
+ // 쓰기 전에 쓸 수 있는지 먼저 본다(11차 A-3, 사용자 결정). 하나라도 막히면
+ // 한 글자도 쓰지 않고 멈춘다 - 예전에는 앞 순서만 써진 반쯤 뽑힌 폴더가 남았다.
+ var willWrite = diffs.Where(d => newCommit[d.Path]?.Target is Blob).Select(d => d.Path).ToList();
+ var blocked = ExportPlan.Blocked(targetDirectory, willWrite);
+ if (blocked.Count > 0) throw new IOException(ExportPlan.BlockedMessage(blocked));
+
Directory.CreateDirectory(targetDirectory);
var written = 0;
var skipped = 0;
var items = new List();
- foreach (var diff in DiffCommits(oldCommittish, newCommittish))
+ foreach (var diff in diffs)
{
// 새 커밋 쪽에 파일이 없다(삭제됨). 뽑을 내용이 없다.
if (newCommit[diff.Path]?.Target is not Blob blob)
diff --git a/src/MergeHub/ViewModels/GitViewModel.cs b/src/MergeHub/ViewModels/GitViewModel.cs
index ba9d427..dac82f1 100644
--- a/src/MergeHub/ViewModels/GitViewModel.cs
+++ b/src/MergeHub/ViewModels/GitViewModel.cs
@@ -724,12 +724,17 @@ namespace MergeHub.ViewModels
private Task ExportChangesAsync()
{
// 저장소가 있는 자리에서 시작한다 - 뽑아 놓을 자리는 보통 그 근처다.
- var target = FolderPicker.Pick("변경분 파일을 뽑아 놓을 폴더", ParentOf(_workspace.RepositoryPath));
- if (target is null) return Task.CompletedTask;
+ var picked = FolderPicker.Pick("변경분 파일을 뽑아 놓을 폴더", ParentOf(_workspace.RepositoryPath));
+ if (picked is null) return Task.CompletedTask;
var to = SelectedCommit!;
var from = DiffFromCommit?.Sha ?? to.ParentShas.FirstOrDefault() ?? to.Sha;
+ // 고른 폴더에 바로 쏟지 않고 그 아래 새 폴더를 만든다(11차 A-3, 사용자 결정).
+ // 예전에는 이미 있던 파일과 지난번에 뽑은 것이 말없이 섞였다. 이름에 범위와 날짜가 들어가
+ // 여러 번 뽑아도 서로 밟지 않는다. 안내 글은 그 폴더 옆(= 고른 폴더 안)에 붙는다.
+ var target = ExportPlan.UniquePath(picked, ExportPlan.FolderName(from, to.Sha, DateTime.Now));
+
// 안내 글에 적을 "무엇에서 무엇까지" - 이력에 있는 커밋이면 제목 · 날짜까지 적는다.
var fromLabel = RangeLabel(from, DiffFromCommit, isParentFallback: DiffFromCommit is null);
var toLabel = RangeLabel(to.Sha, to, isParentFallback: false);
@@ -757,10 +762,11 @@ namespace MergeHub.ViewModels
if (noteWritten) _workspace.AppendLog($"변경분 안내: {notePath}");
+ var folderName = Path.GetFileName(target);
_workspace.OnUi(() => MessageUtil.ShowSuccessToast(
skipped > 0
- ? $"{written}개 파일 추출 (지워야 할 파일 {skipped}개는 안내에)"
- : $"{written}개 파일 추출 · 안내 {Path.GetFileName(notePath)}"));
+ ? $"{folderName} 에 {written}개 (지워야 할 파일 {skipped}개는 안내에)"
+ : $"{folderName} 에 {written}개 파일 추출"));
});
}