diff --git a/doc/진행상황.md b/doc/진행상황.md index 757cbf3..5013ac5 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -12,15 +12,17 @@ 기획안(`문서/사내_MergeTool_개발기획안`) 기준으로 **기능 덩어리를 100% 로 채우는 것**이 목표다. 한 덩어리를 깊게 파는 것보다 여러 덩어리를 "있다"고 말할 수 있는 선까지 올린다. -### ④ Git 조작 — 서비스는 이미 있고 화면만 없는 것들 +### ④ Git 조작 — **완료** (2026-09-08) -| 상태 | 항목 | 내용 | 근거 | +서비스에는 이미 있는데 화면에 없던 것들이라, 다섯 가지가 하루가 아니라 반나절에 붙었다. + +| 상태 | 항목 | 내용 | 확인 | |---|---|---|---| -| 대기 | 커밋 이름 변경(amend) | 마지막 커밋 메시지를 고친다 | `GitService.Commit(msg, amend: true)` 구현돼 있음 | -| 대기 | stash 목록 | 저장·적용만 있고 목록/개별 적용/삭제가 없다 | `GitService.GetStashes()` 구현돼 있음 | -| 대기 | 두 커밋 사이 비교 | 지금은 "부모와 비교"만 된다 | `GitService.DiffCommits(a, b)` 구현돼 있음 | -| 대기 | blame (줄별 작성자) | 이 줄을 누가 언제 고쳤나 | `GitService.Blame()` 구현돼 있음 | -| 대기 | 변경분 파일 추출 | 두 커밋 사이 바뀐 파일만 폴더로 뽑는다 | 기획안 명시 항목 · 서비스 없음(신규) | +| **완료** | 커밋 이름 변경(amend) | `커밋 수정` 버튼. 고쳐 쓸 메시지를 다시 치지 않게 `마지막 메시지` 버튼도 함께 뒀다 | 확인: `6e6bb78` → `0d7401f` 로 메시지가 바뀜 | +| **완료** | stash 목록 | 콤보에서 골라 `적용`/`삭제`. 목록이 바뀌면 고른 항목을 다시 잡는다 - 하나 꺼내면 번호가 당겨지는데 옛 항목을 붙들고 있으면 다음 삭제가 엉뚱한 것을 지운다 | 확인: 저장 → 목록에 뜸 → 적용 → 워킹트리 복구 | +| **완료** | 두 커밋 사이 비교 | 이력에서 `여기부터` 로 시작점을 잡으면 그 뒤 고르는 커밋이 끝점이 된다. 안 잡으면 지금까지처럼 부모와 비교 | 확인: 헤더가 `2766bc5 → 6e6bb78 (2개 파일)` | +| **완료** | blame (줄별 작성자) | `Blame` 탭 신설. 변경 목록에서 파일을 고르고 `blame` 을 누르면 경로가 넘어간다 | 확인: 31줄 전부 `커밋·날짜·작성자` 가 붙어 나옴 | +| **완료** | 변경분 파일 추출 | `GitService.ExportChangedFiles` 신설. 저장소 안 경로 구조를 그대로 만들고, 바이트 그대로 옮긴다(텍스트로 다시 쓰면 줄바꿈·인코딩이 바뀐다). 지워진 파일은 뽑을 것이 없어 건너뛴다 | 확인: `2개 파일 → ...Temp (건너뜀 0개)` | ### ③ 3-Way 병합 — "볼 수 있다"에서 "끝낼 수 있다"로 @@ -67,3 +69,4 @@ | 날짜 | 항목 | 커밋 | |---|---|---| | 2026-09-08 | 저장소 개설 (MergeHub · UbisamPlatform 각각) | `7719094` / `36ee902` | +| 2026-09-08 | **④ Git 조작 5가지** — amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출 | 아래 | diff --git a/src/MergeHub/Services/GitService.cs b/src/MergeHub/Services/GitService.cs index 4965375..c3c0b0e 100644 --- a/src/MergeHub/Services/GitService.cs +++ b/src/MergeHub/Services/GitService.cs @@ -971,6 +971,52 @@ namespace MergeHub.Services return BuildFileDiffs(patch); } + /// + /// 두 커밋 사이에 바뀐 파일만 골라 폴더로 뽑는다. + /// + /// 기획안 4p "변경분 파일 추출" - 장비에 소스를 통째로 넣지 못하는 현장에서 + /// 바뀐 파일만 들고 가야 할 때 쓴다. 저장소 안의 경로 구조를 그대로 만든다. + /// + /// 지워진 파일은 뽑을 것이 없으므로 목록에만 남기고 파일은 만들지 않는다. + /// + /// (뽑은 파일 수, 지워져서 건너뛴 파일 수) + public (int Written, int Skipped) ExportChangedFiles(string oldCommittish, string newCommittish, string targetDirectory) + { + var newCommit = ResolveCommit(newCommittish) + ?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {newCommittish}"); + + Directory.CreateDirectory(targetDirectory); + + var written = 0; + var skipped = 0; + + foreach (var diff in DiffCommits(oldCommittish, newCommittish)) + { + // 새 커밋 쪽에 파일이 없다(삭제됨). 뽑을 내용이 없다. + if (newCommit[diff.Path]?.Target is not Blob blob) + { + skipped++; + continue; + } + + var target = Path.Combine(targetDirectory, diff.Path.Replace('/', Path.DirectorySeparatorChar)); + var folder = Path.GetDirectoryName(target); + if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder); + + // 텍스트로 다시 쓰면 줄바꿈과 인코딩이 바뀐다. 바이트 그대로 옮긴다. + using (var source = blob.GetContentStream()) + using (var file = File.Create(target)) + { + source.CopyTo(file); + } + + written++; + } + + Report($"변경분 추출: {written}개 파일 → {targetDirectory} (삭제되어 건너뜀 {skipped}개)"); + return (written, skipped); + } + public IReadOnlyList DiffCommitWithParent(string committish, params string[] paths) { var commit = ResolveCommit(committish) diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs index 482fd83..3f5fe7c 100644 --- a/src/MergeHub/Services/GitWorkspace.cs +++ b/src/MergeHub/Services/GitWorkspace.cs @@ -90,6 +90,9 @@ namespace MergeHub.Services public ObservableCollection Tags { get; } = new ObservableCollection(); public ObservableCollection Submodules { get; } = new ObservableCollection(); + /// 치워 둔 작업(stash) 목록. 새 것이 0번이다. + public ObservableCollection Stashes { get; } = new ObservableCollection(); + // ================= 공유 상태 ================= [ObservableProperty] private string _repositoryPath = string.Empty; @@ -224,6 +227,7 @@ namespace MergeHub.Services var conflicts = Git.GetConflicts(); var tags = Git.GetTags(); var submodules = Git.GetSubmodules(); + var stashes = Git.GetStashes(); var rebase = Git.GetRebaseStatus(); var remotes = Git.GetRemotes(); var mergeInProgress = Git.IsMergeInProgress; @@ -263,6 +267,7 @@ namespace MergeHub.Services Replace(Conflicts, conflicts); Replace(Tags, tags); Replace(Submodules, submodules); + Replace(Stashes, stashes); if (SelectedCommit is null && Commits.Count > 0) SelectedCommit = Commits[0]; }); diff --git a/src/MergeHub/Services/IGitService.cs b/src/MergeHub/Services/IGitService.cs index 0af5fa3..a8b1988 100644 --- a/src/MergeHub/Services/IGitService.cs +++ b/src/MergeHub/Services/IGitService.cs @@ -149,6 +149,9 @@ namespace MergeHub.Services bool WordLevelDiff { get; set; } IReadOnlyList DiffCommits(string oldCommittish, string newCommittish, params string[] paths); + + /// 기획안 4p "변경분 파일 추출" - 두 커밋 사이에 바뀐 파일만 폴더로 뽑는다. + (int Written, int Skipped) ExportChangedFiles(string oldCommittish, string newCommittish, string targetDirectory); IReadOnlyList DiffCommitWithParent(string committish, params string[] paths); IReadOnlyList DiffWorkingDirectory(bool stagedOnly = false, params string[] paths); diff --git a/src/MergeHub/ViewModels/GitViewModel.cs b/src/MergeHub/ViewModels/GitViewModel.cs index 547b7c3..cbcf78a 100644 --- a/src/MergeHub/ViewModels/GitViewModel.cs +++ b/src/MergeHub/ViewModels/GitViewModel.cs @@ -26,6 +26,7 @@ namespace MergeHub.ViewModels { _workspace = workspace; _workspace.Refreshed += () => OnPropertyChanged(nameof(SelectedCommit)); + _workspace.Refreshed += SyncStashSelection; _workspace.StateChanged += RaiseCanExecuteChanged; } @@ -47,13 +48,36 @@ namespace MergeHub.ViewModels [ObservableProperty] private ConflictInfo? _selectedConflict; [ObservableProperty] private TagInfo? _selectedTag; [ObservableProperty] private string _diffText = string.Empty; + [ObservableProperty] private StashInfo? _selectedStash; + [ObservableProperty] private string _blameText = string.Empty; + [ObservableProperty] private string _blamePath = string.Empty; + + /// + /// 커밋 비교의 시작점. 정해 두면 이력에서 고르는 커밋이 "끝점"이 되어 + /// 두 커밋 사이를 보여 준다. 비워 두면 지금까지처럼 "부모와 비교"다. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DiffRangeText))] + private CommitInfo? _diffFromCommit; + + public string DiffRangeText => DiffFromCommit is { } from + ? $"{from.ShortSha} → 고른 커밋" + : "부모 → 고른 커밋"; partial void OnNewBranchNameChanged(string value) => RaiseCanExecuteChanged(); partial void OnTagNameChanged(string value) => RaiseCanExecuteChanged(); partial void OnCommitMessageChanged(string value) => RaiseCanExecuteChanged(); + partial void OnBlamePathChanged(string value) => RaiseCanExecuteChanged(); partial void OnSelectedStatusFileChanged(FileStatusInfo? value) => RaiseCanExecuteChanged(); partial void OnSelectedConflictChanged(ConflictInfo? value) => RaiseCanExecuteChanged(); partial void OnSelectedTagChanged(TagInfo? value) => RaiseCanExecuteChanged(); + partial void OnSelectedStashChanged(StashInfo? value) => RaiseCanExecuteChanged(); + + partial void OnDiffFromCommitChanged(CommitInfo? value) + { + RaiseCanExecuteChanged(); + _ = ShowDiffAsync(SelectedCommit); // 기준이 바뀌었으니 지금 보고 있는 것도 다시 그린다 + } public BranchInfo? SelectedBranch { @@ -305,6 +329,167 @@ namespace MergeHub.ViewModels private Task StashPopAsync() => _workspace.RunAndRefreshAsync("stash 적용", () => _workspace.Git.StashPop()); + // ================= 커밋 수정 (amend) ================= + + private bool CanAmend() => IsOpen() && !string.IsNullOrWhiteSpace(CommitMessage); + + /// + /// 마지막 커밋을 고쳐 쓴다(`git commit --amend`). + /// + /// 오타 난 메시지나 빠뜨린 파일을 새 커밋을 쌓지 않고 바로잡을 때 쓴다. + /// 이미 올린 커밋을 고치면 남의 이력과 갈라지므로, 푸시 전에만 쓰는 것이 원칙이다. + /// + [RelayCommand(CanExecute = nameof(CanAmend))] + private Task AmendAsync() => _workspace.RunAndRefreshAsync("커밋 수정", () => + { + var sha = _workspace.Git.Commit(CommitMessage, amend: true); + _workspace.OnUi(() => + { + CommitMessage = string.Empty; + MessageUtil.ShowSuccessToast($"커밋 수정 완료 ({sha.Substring(0, 7)})"); + }); + }); + + private bool CanLoadLastMessage() => IsOpen() && _workspace.Commits.Count > 0; + + /// 고칠 메시지를 처음부터 다시 치지 않도록 마지막 커밋의 메시지를 가져온다. + [RelayCommand(CanExecute = nameof(CanLoadLastMessage))] + private void LoadLastMessage() => CommitMessage = _workspace.Commits[0].Message.TrimEnd(); + + // ================= stash 목록 ================= + + /// + /// 목록이 바뀌면 고른 stash 를 다시 잡는다. + /// + /// 그냥 두면 두 가지가 어긋난다 - ① 아무것도 안 고른 상태라 닫힌 콤보가 비어 보이고, + /// ② 하나 꺼내 쓴 뒤 번호가 한 칸씩 당겨지는데 옛 항목을 붙들고 있으면 + /// 다음 "삭제"가 엉뚱한 것을 지운다. + /// + private void SyncStashSelection() + { + if (SelectedStash is { } picked && + _workspace.Stashes.Any(s => s.Index == picked.Index && s.Sha == picked.Sha)) return; + + SelectedStash = _workspace.Stashes.FirstOrDefault(); + } + + private bool HasStash() => SelectedStash is not null; + + /// 고른 stash 를 꺼내 적용한다. 목록에서 골라야 하므로 "맨 위"에 갇히지 않는다. + [RelayCommand(CanExecute = nameof(HasStash))] + private Task StashApplySelectedAsync() => _workspace.RunAndRefreshAsync("stash 적용", + () => _workspace.Git.StashPop(SelectedStash!.Index)); + + [RelayCommand(CanExecute = nameof(HasStash))] + private Task StashDropSelectedAsync() => _workspace.RunAndRefreshAsync("stash 삭제", + () => _workspace.Git.StashDrop(SelectedStash!.Index)); + + // ================= 두 커밋 사이 비교 ================= + + /// 지금 고른 커밋을 비교의 시작점으로 잡는다. + [RelayCommand(CanExecute = nameof(HasCommit))] + private void SetDiffFrom() => DiffFromCommit = SelectedCommit; + + private bool HasDiffFrom() => DiffFromCommit is not null; + + [RelayCommand(CanExecute = nameof(HasDiffFrom))] + private void ClearDiffFrom() => DiffFromCommit = null; + + // ================= 변경분 파일 추출 ================= + + private bool CanExportChanges() => HasCommit(); + + /// + /// 두 커밋 사이에 바뀐 파일만 골라 폴더로 뽑는다. + /// + /// 장비에 소스를 통째로 넣지 못하는 현장에서 바뀐 것만 들고 갈 때 쓴다. + /// 비교 시작점을 잡아 두었으면 그 사이를, 아니면 고른 커밋의 부모부터 잰다. + /// + [RelayCommand(CanExecute = nameof(CanExportChanges))] + private Task ExportChangesAsync() + { + using var dialog = new System.Windows.Forms.FolderBrowserDialog + { + Description = "변경분 파일을 뽑아 놓을 폴더", + + // 저장소가 있는 자리에서 시작한다. 이 대화상자는 경로를 칠 칸이 없어 + // 매번 트리를 처음부터 파고 내려가야 하는데, 뽑아 놓을 자리는 보통 그 근처다. + SelectedPath = ParentOf(_workspace.RepositoryPath) + }; + + if (dialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) return Task.CompletedTask; + + var target = dialog.SelectedPath; + var to = SelectedCommit!; + var from = DiffFromCommit?.Sha ?? to.ParentShas.FirstOrDefault() ?? to.Sha; + + return _workspace.RunAsync("변경분 추출", () => + { + var (written, skipped) = _workspace.Git.ExportChangedFiles(from, to.Sha, target); + + _workspace.OnUi(() => MessageUtil.ShowSuccessToast( + skipped > 0 + ? $"{written}개 파일 추출 (삭제되어 건너뜀 {skipped}개)" + : $"{written}개 파일 추출")); + }); + } + + /// 저장소가 놓인 폴더. 대화상자를 그 자리에서 열어 주려고 쓴다. + private static string ParentOf(string path) + { + try + { + var trimmed = path?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (string.IsNullOrEmpty(trimmed)) return string.Empty; + + var parent = Path.GetDirectoryName(trimmed); + return Directory.Exists(parent) ? parent! : string.Empty; + } + catch (ArgumentException) + { + return string.Empty; // 경로가 이상하면 대화상자 기본 자리에서 연다 + } + } + + // ================= blame ================= + + private bool CanBlame() => IsOpen() && !string.IsNullOrWhiteSpace(BlamePath); + + /// + /// 줄마다 "누가 언제 이 줄을 만들었나"를 붙여 보여 준다. + /// + /// 머지 중에 "이 줄 왜 이렇게 됐지"를 묻게 되는데, 그때 커밋 이력을 뒤지는 대신 + /// 줄 옆에서 바로 답이 나오는 편이 빠르다. + /// + [RelayCommand(CanExecute = nameof(CanBlame))] + private Task BlameAsync() + { + var path = BlamePath.Trim().Replace(Path.DirectorySeparatorChar, '/'); + var at = SelectedCommit?.Sha; + + return _workspace.RunAsync("blame", () => + { + var lines = _workspace.Git.Blame(path, at); + + var sb = new StringBuilder(); + foreach (var line in lines) + { + sb.Append(line.ShortSha).Append(" ") + .Append(line.When.ToString("yyyy-MM-dd")).Append(" ") + .Append(line.AuthorName.PadRight(10)).Append(" | ") + .Append(line.LineNumber.ToString("0000")).Append(" ") + .AppendLine(line.Text); + } + + var text = sb.Length == 0 ? "(내용 없음)" : sb.ToString(); + _workspace.OnUi(() => BlameText = text); + }); + } + + /// 변경 목록에서 고른 파일을 blame 대상으로 옮겨 준다 - 경로를 다시 치지 않게. + [RelayCommand(CanExecute = nameof(HasStatusFile))] + private void BlameSelectedFile() => BlamePath = SelectedStatusFile!.Path; + // ================= 태그 / 서브모듈 / 설정 ================= private bool CanCreateTag() => IsOpen() && !string.IsNullOrWhiteSpace(TagName); @@ -385,6 +570,16 @@ namespace MergeHub.ViewModels DeleteTagCommand.NotifyCanExecuteChanged(); UpdateSubmodulesCommand.NotifyCanExecuteChanged(); RegisterMergeToolCommand.NotifyCanExecuteChanged(); + + AmendCommand.NotifyCanExecuteChanged(); + LoadLastMessageCommand.NotifyCanExecuteChanged(); + StashApplySelectedCommand.NotifyCanExecuteChanged(); + StashDropSelectedCommand.NotifyCanExecuteChanged(); + SetDiffFromCommand.NotifyCanExecuteChanged(); + ClearDiffFromCommand.NotifyCanExecuteChanged(); + ExportChangesCommand.NotifyCanExecuteChanged(); + BlameCommand.NotifyCanExecuteChanged(); + BlameSelectedFileCommand.NotifyCanExecuteChanged(); } // ================= diff ================= @@ -395,10 +590,16 @@ namespace MergeHub.ViewModels return _workspace.RunAsync("diff", () => { - var diffs = _workspace.Git.DiffCommitWithParent(commit.Sha); + // 비교 시작점을 잡아 두었으면 두 커밋 "사이", 아니면 지금까지처럼 부모와 견준다. + var from = DiffFromCommit; + var diffs = from is null + ? _workspace.Git.DiffCommitWithParent(commit.Sha) + : _workspace.Git.DiffCommits(from.Sha, commit.Sha); var sb = new StringBuilder(); - sb.AppendLine($"{commit.ShortSha} {commit.MessageShort}"); + sb.AppendLine(from is null + ? $"{commit.ShortSha} {commit.MessageShort}" + : $"{from.ShortSha} → {commit.ShortSha} ({diffs.Count}개 파일)"); sb.AppendLine($"{commit.AuthorName} <{commit.AuthorEmail}> {commit.When:yyyy-MM-dd HH:mm}"); sb.AppendLine(new string('-', 60)); diff --git a/src/MergeHub/Views/GitView.xaml b/src/MergeHub/Views/GitView.xaml index 29ecc7f..a3c7a2f 100644 --- a/src/MergeHub/Views/GitView.xaml +++ b/src/MergeHub/Views/GitView.xaml @@ -114,13 +114,15 @@ - - + + + - + @@ -285,18 +287,62 @@ ToolTip="git checkout -- <file>"/>