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}개 파일 추출")); }); }