Git 탭에 다섯 기능 추가 (amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출)

기획안 4p 의 "Git 조작" 항목 중 화면에 없던 것들을 채웠다. 다섯 중 넷은
GitService 에 이미 구현돼 있었고 버튼만 없던 것이라, 서비스를 새로 만든 것은
변경분 추출 하나뿐이다.

- 커밋 수정(amend): 오타 난 메시지나 빠뜨린 파일을 새 커밋을 쌓지 않고 바로잡는다.
  고쳐 쓸 메시지를 처음부터 다시 치지 않게 "마지막 메시지" 버튼을 곁들였다.
- stash 목록: 저장/적용만 있어 "맨 위 것"에만 손이 닿았다. 골라서 적용·삭제한다.
  하나 꺼내면 번호가 한 칸씩 당겨지므로, 목록이 바뀌면 고른 항목을 다시 잡는다 -
  안 그러면 다음 삭제가 엉뚱한 것을 지운다.
- 두 커밋 사이 비교: "여기부터" 로 시작점을 잡으면 그 뒤 고르는 커밋이 끝점이 된다.
  안 잡으면 지금까지처럼 부모와 비교한다(기존 동작 유지).
- blame: 머지 중에 "이 줄 왜 이렇게 됐지"를 묻게 되는데, 이력을 뒤지는 것보다 빠르다.
- 변경분 파일 추출: 장비에 소스를 통째로 넣지 못하는 현장에서 바뀐 것만 들고 갈 때 쓴다.
  텍스트로 다시 쓰면 줄바꿈과 인코딩이 바뀌므로 바이트 그대로 옮긴다.

곁들여 고친 것: 상태 배지 줄의 ItemHeight 가 24 로 묶여 있어 "mergetool 등록"
버튼이 반쯤 잘려 보였다. 줄 높이는 내용이 정하게 두었다.

실행 확인(데모 저장소): 메시지 수정으로 sha 가 바뀜 · stash 저장→목록→적용으로
워킹트리 복구 · 헤더가 "2766bc5 → 6e6bb78 (2개 파일)" · blame 31줄 전부 표시 ·
변경분 2개 파일 추출.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-08 10:35:45 +09:00
parent a0ec7174a1
commit 9956567bf8
6 changed files with 363 additions and 14 deletions

View File

@ -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 목록 | 콤보에서 골라 `적용`/`삭제`. 목록이 바뀌면 고른 항목을 다시 잡는다 - 하나 꺼내면 번호가 당겨지는데 옛 항목을 붙들고 있으면 <b>다음 삭제가 엉뚱한 것을 지운다</b> | 확인: 저장 → 목록에 뜸 → 적용 → 워킹트리 복구 |
| **완료** | 두 커밋 사이 비교 | 이력에서 `여기부터` 로 시작점을 잡으면 그 뒤 고르는 커밋이 끝점이 된다. 안 잡으면 지금까지처럼 부모와 비교 | 확인: 헤더가 `2766bc5 → 6e6bb78 (2개 파일)` |
| **완료** | blame (줄별 작성자) | `Blame` 탭 신설. 변경 목록에서 파일을 고르고 `blame` 을 누르면 경로가 넘어간다 | 확인: 31줄 전부 `커밋·날짜·작성자` 가 붙어 나옴 |
| **완료** | 변경분 파일 추출 | `GitService.ExportChangedFiles` 신설. 저장소 안 경로 구조를 그대로 만들고, <b>바이트 그대로</b> 옮긴다(텍스트로 다시 쓰면 줄바꿈·인코딩이 바뀐다). 지워진 파일은 뽑을 것이 없어 건너뛴다 | 확인: `2개 파일 → ...Temp (건너뜀 0개)` |
### ③ 3-Way 병합 — "볼 수 있다"에서 "끝낼 수 있다"로
@ -67,3 +69,4 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
| 2026-09-08 | 저장소 개설 (MergeHub · UbisamPlatform 각각) | `7719094` / `36ee902` |
| 2026-09-08 | **④ Git 조작 5가지** — amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출 | 아래 |

View File

@ -971,6 +971,52 @@ namespace MergeHub.Services
return BuildFileDiffs(patch);
}
/// <summary>
/// 두 커밋 사이에 바뀐 파일만 골라 폴더로 뽑는다.
///
/// 기획안 4p "변경분 파일 추출" - 장비에 소스를 통째로 넣지 못하는 현장에서
/// <b>바뀐 파일만</b> 들고 가야 할 때 쓴다. 저장소 안의 경로 구조를 그대로 만든다.
///
/// 지워진 파일은 뽑을 것이 없으므로 목록에만 남기고 파일은 만들지 않는다.
/// </summary>
/// <returns>(뽑은 파일 수, 지워져서 건너뛴 파일 수)</returns>
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<FileDiff> DiffCommitWithParent(string committish, params string[] paths)
{
var commit = ResolveCommit(committish)

View File

@ -90,6 +90,9 @@ namespace MergeHub.Services
public ObservableCollection<TagInfo> Tags { get; } = new ObservableCollection<TagInfo>();
public ObservableCollection<SubmoduleInfo> Submodules { get; } = new ObservableCollection<SubmoduleInfo>();
/// <summary>치워 둔 작업(stash) 목록. 새 것이 0번이다.</summary>
public ObservableCollection<StashInfo> Stashes { get; } = new ObservableCollection<StashInfo>();
// ================= 공유 상태 =================
[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];
});

View File

@ -149,6 +149,9 @@ namespace MergeHub.Services
bool WordLevelDiff { get; set; }
IReadOnlyList<FileDiff> DiffCommits(string oldCommittish, string newCommittish, params string[] paths);
/// <summary>기획안 4p "변경분 파일 추출" - 두 커밋 사이에 바뀐 파일만 폴더로 뽑는다.</summary>
(int Written, int Skipped) ExportChangedFiles(string oldCommittish, string newCommittish, string targetDirectory);
IReadOnlyList<FileDiff> DiffCommitWithParent(string committish, params string[] paths);
IReadOnlyList<FileDiff> DiffWorkingDirectory(bool stagedOnly = false, params string[] paths);

View File

@ -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;
/// <summary>
/// 커밋 비교의 <b>시작점</b>. 정해 두면 이력에서 고르는 커밋이 "끝점"이 되어
/// 두 커밋 <b>사이</b>를 보여 준다. 비워 두면 지금까지처럼 "부모와 비교"다.
/// </summary>
[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);
/// <summary>
/// 마지막 커밋을 고쳐 쓴다(`git commit --amend`).
///
/// 오타 난 메시지나 빠뜨린 파일을 <b>새 커밋을 쌓지 않고</b> 바로잡을 때 쓴다.
/// 이미 올린 커밋을 고치면 남의 이력과 갈라지므로, <b>푸시 전에만</b> 쓰는 것이 원칙이다.
/// </summary>
[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;
/// <summary>고칠 메시지를 처음부터 다시 치지 않도록 마지막 커밋의 메시지를 가져온다.</summary>
[RelayCommand(CanExecute = nameof(CanLoadLastMessage))]
private void LoadLastMessage() => CommitMessage = _workspace.Commits[0].Message.TrimEnd();
// ================= stash 목록 =================
/// <summary>
/// 목록이 바뀌면 고른 stash 를 다시 잡는다.
///
/// 그냥 두면 두 가지가 어긋난다 - ① 아무것도 안 고른 상태라 닫힌 콤보가 비어 보이고,
/// ② 하나 꺼내 쓴 뒤 <b>번호가 한 칸씩 당겨지는데</b> 옛 항목을 붙들고 있으면
/// 다음 "삭제"가 엉뚱한 것을 지운다.
/// </summary>
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;
/// <summary>고른 stash 를 꺼내 적용한다. 목록에서 골라야 하므로 "맨 위"에 갇히지 않는다.</summary>
[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));
// ================= 두 커밋 사이 비교 =================
/// <summary>지금 고른 커밋을 비교의 시작점으로 잡는다.</summary>
[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();
/// <summary>
/// 두 커밋 사이에 바뀐 파일만 골라 폴더로 뽑는다.
///
/// 장비에 소스를 통째로 넣지 못하는 현장에서 <b>바뀐 것만</b> 들고 갈 때 쓴다.
/// 비교 시작점을 잡아 두었으면 그 사이를, 아니면 고른 커밋의 부모부터 잰다.
/// </summary>
[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}개 파일 추출"));
});
}
/// <summary>저장소가 놓인 폴더. 대화상자를 그 자리에서 열어 주려고 쓴다.</summary>
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);
/// <summary>
/// 줄마다 "누가 언제 이 줄을 만들었나"를 붙여 보여 준다.
///
/// 머지 중에 "이 줄 왜 이렇게 됐지"를 묻게 되는데, 그때 커밋 이력을 뒤지는 대신
/// 줄 옆에서 바로 답이 나오는 편이 빠르다.
/// </summary>
[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);
});
}
/// <summary>변경 목록에서 고른 파일을 blame 대상으로 옮겨 준다 - 경로를 다시 치지 않게.</summary>
[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));

View File

@ -114,13 +114,15 @@
</Border>
<!-- ===== 상태 배지 ===== -->
<WrapPanel Grid.Row="1" Margin="2,0,0,6" ItemHeight="24">
<TextBlock Text="HEAD" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<!-- ItemHeight 를 24 로 묶어 두면 그보다 큰 버튼이 잘린다("mergetool 등록"이 반쯤 잘려 보였다).
줄 높이는 내용이 정하게 두고, 글자만 세로 가운데로 맞춘다. -->
<WrapPanel Grid.Row="1" Margin="2,0,0,6">
<TextBlock Text="HEAD" Style="{StaticResource Muted}" Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Workspace.CurrentBranch}" FontWeight="Bold" Margin="0,0,6,0"
Foreground="{DynamicResource Ubisam.Brush.Accent}" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Workspace.HeadSha}" FontFamily="Consolas" Margin="0,0,16,0"
Foreground="{DynamicResource Ubisam.Brush.Warning}" VerticalAlignment="Center"/>
<TextBlock Text="Working Tree" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<TextBlock Text="Working Tree" Style="{StaticResource Muted}" Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Workspace.WorkingTreeSummary}" Margin="0,0,16,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Workspace.PendingOperationText}" FontWeight="Bold" Margin="0,0,16,0"
Foreground="{DynamicResource Ubisam.Brush.Danger}" VerticalAlignment="Center"/>
@ -285,18 +287,62 @@
ToolTip="git checkout -- &lt;file&gt;"/>
<Button Content="전체 스테이징" Command="{Binding StageAllCommand}" MinWidth="96"/>
<Button Content="stash 저장" Command="{Binding StashSaveCommand}"/>
<Button Content="stash 적용" Command="{Binding StashPopCommand}"/>
<Button Content="blame" Command="{Binding BlameSelectedFileCommand}"
ToolTip="고른 파일을 'Blame' 탭으로 보냅니다"/>
</WrapPanel>
<!-- 치워 둔 작업(stash). 목록이 없으면 맨 위 것만 꺼낼 수 있어
"어제 치워 둔 것"에 손이 닿지 않는다. -->
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="stash" Style="{StaticResource Muted}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding Workspace.Stashes}"
SelectedItem="{Binding SelectedStash}"
ToolTip="치워 둔 작업 목록. 새 것이 위에 옵니다">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Index}" FontFamily="Consolas" FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Warning}"
Margin="0,0,8,0"/>
<TextBlock Text="{Binding Message}" FontSize="11"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="2" Content="적용" Command="{Binding StashApplySelectedCommand}"
ToolTip="고른 stash 를 꺼내 적용합니다 (목록에서 사라집니다)"/>
<Button Grid.Column="3" Content="삭제" Command="{Binding StashDropSelectedCommand}"
ToolTip="고른 stash 를 버립니다"/>
</Grid>
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="커밋 메시지" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" Text="{Binding CommitMessage, UpdateSourceTrigger=PropertyChanged}"/>
<Button Grid.Column="2" Content="커밋" Command="{Binding CommitCommand}"/>
<Button Grid.Column="2" Content="마지막 메시지" Command="{Binding LoadLastMessageCommand}"
ToolTip="고쳐 쓰려는 메시지를 처음부터 다시 치지 않게, 마지막 커밋의 메시지를 가져옵니다"/>
<Button Grid.Column="3" Content="커밋" Command="{Binding CommitCommand}"/>
<Button Grid.Column="4" Content="커밋 수정" Command="{Binding AmendCommand}"
ToolTip="마지막 커밋을 고쳐 씁니다 (git commit --amend). 푸시하기 전에만 쓰세요"/>
</Grid>
</StackPanel>
</Grid>
</TabItem>
@ -344,7 +390,52 @@
Width="80" Margin="8,2,2,2"/>
<Button Content="Reset" Command="{Binding ResetCommand}"
ToolTip="선택 커밋으로 HEAD 이동"/>
<!-- 두 커밋 "사이"를 보려면 시작점을 따로 잡아야 한다.
잡지 않으면 지금까지처럼 부모와 견준다. -->
<Border Width="1" Margin="10,2" Background="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="{Binding DiffRangeText}" Style="{StaticResource Muted}"
VerticalAlignment="Center" Margin="0,0,6,0"
ToolTip="지금 무엇과 무엇을 견주고 있는지"/>
<Button Content="여기부터" Command="{Binding SetDiffFromCommand}"
ToolTip="고른 커밋을 비교의 시작점으로 잡습니다"/>
<Button Content="해제" Command="{Binding ClearDiffFromCommand}"
ToolTip="다시 '부모와 비교'로 돌아갑니다"/>
<Button Content="변경분 추출" Command="{Binding ExportChangesCommand}" MinWidth="96"
ToolTip="이 범위에서 바뀐 파일만 골라 폴더로 뽑습니다"/>
</WrapPanel>
</Grid>
</TabItem>
<!--
Blame - 줄마다 "누가 언제 이 줄을 만들었나".
머지 중에 "이 줄 왜 이렇게 됐지"를 묻게 되는데, 이력을 뒤지는 것보다 빠르다.
-->
<TabItem Header="Blame">
<Grid Margin="6">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="파일 경로" Style="{StaticResource Muted}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" Text="{Binding BlamePath, UpdateSourceTrigger=PropertyChanged}"
ToolTip="저장소 안의 상대 경로 (예: src/MergeHub/AppSetup.cs). '변경 · 커밋' 탭에서 파일을 고르고 [blame] 을 누르면 자동으로 채워집니다"/>
<Button Grid.Column="2" Content="실행" Command="{Binding BlameCommand}"
ToolTip="이력에서 커밋을 골라 두면 그 시점 기준으로 봅니다"/>
</Grid>
<TextBox Grid.Row="1" Style="{StaticResource Code}"
Text="{Binding BlameText, Mode=OneWay}"/>
</Grid>
</TabItem>