ksj09406 588f3d75e9 11차 C-2: 이력 줄 우클릭으로 마지막이 아닌 커밋의 메시지를 바꾼다
기획안 1차 범위 "커밋 우클릭 -> 이름 변경". 사용자 결정대로 좁은 범위에서만 한다.

HEAD 가 아닌 커밋의 메시지를 바꾸면 그 커밋부터 HEAD 까지가 새 해시로 다시
써진다. 고른 커밋의 메시지만 갈고 뒤 커밋은 트리 · 작성자 · 작성 시각 ·
부모 관계를 그대로 두어(LibGit2Sharp Refs.RewriteHistory) 해시만 바뀐다.
바꾸기 전 자리는 refs/original/날짜-시각/ 에 남긴다.

막는 자리(메뉴는 켜 두고 눌렀을 때 까닭과 푸는 길을 알림으로):
작업 중 · 커밋 안 한 변경 · 지금 브랜치 조상 아님 · 원격에 올라감 ·
다른 로컬 브랜치도 품음 · 구간에 태그 · 사이에 병합 커밋.
파일 비교 탭의 저장 안 한 편집은 우클릭해 줄을 고를 때 기존 창으로 묻는다.

- CommitReword (새): 막을 까닭 · 확인 글 · 완료 알림 · 새 메시지 다듬기
- GitService.InspectReword / RewordCommit, RewordInfo
- RewordWindow (새): 여러 줄 입력, [바꾸기] 는 기본 단추가 아님
- GitView: MergeHub 첫 우클릭 메뉴, 우클릭한 줄을 먼저 고른다
- 선택이 고친 커밋의 새 해시를 따라간다
- 화면에서 찾아 고침: 새 메시지가 끝 줄바꿈 없이 저장됐다 -> git -m 처럼
  다듬는다(뒤 커밋까지 다듬지 않게 새 메시지 하나만)

하네스 MergeSaveTest +40 (272건, 22벌 1,247건) · 빌드 경고 0 · 오류 0.
화면 확인: 되는 자리 1(트리 · 작성자 · 작성 시각 같음, 되돌리기 확인) ·
막는 자리 8 모두 막힘.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:34:58 +09:00

1170 lines
58 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MergeHub.Models;
using MergeHub.Services;
using MergeHub.Views;
using UbisamBase.Core.Messaging;
namespace MergeHub.ViewModels
{
/// <summary>
/// Git 탭 - 저장소 조작 전부.
/// 열기/클론 · 원격 · 브랜치 · 병합 · 이력 조작(revert/cherry-pick/rebase/reset) ·
/// 스테이징/커밋 · 태그 · 서브모듈 · mergetool 등록.
/// </summary>
public sealed partial class GitViewModel : ObservableObject
{
private readonly GitWorkspace _workspace;
public GitViewModel(GitWorkspace workspace)
{
_workspace = workspace;
_workspace.Refreshed += () => OnPropertyChanged(nameof(SelectedCommit));
// 파일 비교 탭에서 고른 커밋을 이력 선택 표시 · 커밋 내용 · 단추(Revert · Cherry-pick · Reset)가 따라간다(10차 B-1).
// 커밋 내용(diff)은 여기서 바로 그리지 않는다 - 파일 비교 탭이 그 커밋의 목록 · 본문을 읽는 중이라,
// 같은 작업 게이트를 먼저 잡으면 그쪽 읽기가 건너뛰어진다. 이 탭이 다시 보일 때 그린다.
_workspace.CommitChosen += chooser =>
{
if (ReferenceEquals(chooser, this)) return;
OnPropertyChanged(nameof(SelectedCommit));
RaiseCanExecuteChanged();
_diffPending = true;
};
_workspace.Refreshed += SyncStashSelection;
_workspace.StateChanged += RaiseCanExecuteChanged;
_workspace.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(GitWorkspace.MergeResolutionBody)) RefreshMergeMessageBody();
};
// 원격을 이미 쓰고 있으면 펼쳐 둔다 - 접어 두면 쓰던 사람이 사라진 줄 안다.
_isRemoteOpen = _workspace.Settings.GitRemoteExpanded ||
!string.IsNullOrWhiteSpace(_workspace.RemoteUrl);
}
// ================= 원격 칸 접고 펴기 =================
/// <summary>
/// 원격 칸(원격 URL · Gitea 계정 · Fetch/Pull/Push)을 펼쳐 두었는지.
///
/// <b>왜 접나</b> - 이 도구는 로컬에서 쓰는 머지툴이 우선이고 아직 원격 저장소가 없다.
/// 그런데 화면에서는 원격이 위쪽 4분의 1을 차지한 채 전부 비활성이었다.
/// <b>지우지는 않는다</b> - 사내 Gitea 가 생기면 다시 필요하다.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(RemoteToggleLabel))]
private bool _isRemoteOpen;
/// <summary>
/// 단추 글자. <b>무엇이 숨어 있고 누르면 무슨 일이 나는지</b>를 함께 적는다.
///
/// 처음에는 `▸ 원격 · 계정` 이라고만 했는데 <b>사용자가 못 찾았다</b> -
/// 화살표 하나로는 "여기 접힌 것이 있다" 가 전해지지 않는다. 동사를 붙였다.
/// </summary>
public string RemoteToggleLabel =>
IsRemoteOpen ? "▾ 원격 저장소 · 계정 접기" : "▸ 원격 저장소 · 계정 펴기";
[RelayCommand]
private void ToggleRemote()
{
IsRemoteOpen = !IsRemoteOpen;
_workspace.Settings.GitRemoteExpanded = IsRemoteOpen;
_workspace.SaveSettings();
}
public GitWorkspace Workspace => _workspace;
private bool IsOpen() => _workspace.IsRepositoryOpen;
// ================= 입력 =================
public IReadOnlyList<ResetKind> ResetKinds { get; } =
new[] { ResetKind.Soft, ResetKind.Mixed, ResetKind.Hard };
[ObservableProperty] private ResetKind _selectedResetKind = ResetKind.Mixed;
[ObservableProperty] private string _newBranchName = string.Empty;
[ObservableProperty] private string _tagName = string.Empty;
[ObservableProperty] private string _tagMessage = string.Empty;
[ObservableProperty] private string _commitMessage = string.Empty;
[ObservableProperty] private FileStatusInfo? _selectedStatusFile;
[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
{
get => _workspace.SelectedBranch;
set
{
if (ReferenceEquals(_workspace.SelectedBranch, value)) return;
_workspace.SelectedBranch = value;
OnPropertyChanged();
RaiseCanExecuteChanged();
}
}
/// <summary>파일 비교 탭에서 커밋을 골라, 이 탭의 커밋 내용이 아직 옛 커밋 것인지(10차 B-1).</summary>
private bool _diffPending;
/// <summary>이 탭이 다시 보일 때 부른다. 미뤄 둔 커밋 내용을 게이트가 빈 뒤에 그린다.</summary>
public void ShowPendingDiff()
{
if (!_diffPending) return;
_diffPending = false;
_ = ShowDiffWhenIdleAsync();
}
private async Task ShowDiffWhenIdleAsync()
{
await _workspace.WhenIdleAsync();
await ShowDiffAsync(SelectedCommit);
}
public CommitInfo? SelectedCommit
{
get => _workspace.SelectedCommit;
set
{
if (ReferenceEquals(_workspace.SelectedCommit, value)) return;
// 사람이 다른 커밋을 고른 것이면 파일 비교 탭도 따라간다(10차 B-1, 사용자 결정).
// 저장 안 한 편집은 목록이 선택을 바꾸기 <b>전에</b> 화면이 물었다(ConfirmChooseCommit). 목록이 비워지며 들어온 null 은 고른 것이 아니다.
var chosen = CommitSelection.IsOtherCommit(_workspace.SelectedCommit?.Sha, value?.Sha);
_workspace.SelectedCommit = value;
OnPropertyChanged();
_ = ShowDiffAsync(value);
RaiseCanExecuteChanged();
if (chosen) _workspace.NotifyCommitChosen(this);
}
}
/// <summary>
/// 이력 목록이 선택을 바꾸기 <b>전에</b> 화면이 부른다(마우스 누름 · 방향키). 파일 비교 탭에 저장 안 한 편집이 있으면
/// 파일 비교 탭 이력에서 고를 때와 같은 창으로 묻고, [취소] 면 false - 화면이 그 누름을 막는다(10차 B-1).
///
/// 선택이 바뀐 <b>뒤에</b> 묻고 되돌리면, 창이 닫힌 뒤 목록이 누름 처리를 이어 가 [취소] 해도 다른 커밋이 선택된 채로 남았다.
/// </summary>
/// <param name="target">옮겨 갈 항목.</param>
/// <returns>null = 창 없이 계속(목록이 알아서 고른다) · true = 물었고 계속(누름 도중에 창이 떠서 목록이 못 고르니 화면이 고른다) · false = [취소].</returns>
public bool? ConfirmChooseCommit(CommitInfo? target)
{
if (target is null || !CommitSelection.IsOtherCommit(SelectedCommit?.Sha, target.Sha)) return null;
return _workspace.ConfirmLeaveCommit?.Invoke();
}
// ================= 저장소 =================
[RelayCommand]
private Task OpenAsync() => _workspace.RunAndRefreshAsync("저장소 열기",
() => _workspace.Git.Open(_workspace.RepositoryPath));
private bool CanClone() => !string.IsNullOrWhiteSpace(_workspace.RemoteUrl);
[RelayCommand(CanExecute = nameof(CanClone))]
private Task CloneAsync() => _workspace.RunAndRefreshAsync("클론", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.Clone(_workspace.RemoteUrl, _workspace.RepositoryPath);
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task AddRemoteAsync() => _workspace.RunAndRefreshAsync("원격 추가", () =>
{
if (string.IsNullOrWhiteSpace(_workspace.RemoteUrl))
throw new InvalidOperationException("원격 URL 을 입력하세요.");
_workspace.ApplyCredentials();
_workspace.Git.AddRemote(_workspace.RemoteName, _workspace.RemoteUrl);
_workspace.Git.Fetch(_workspace.RemoteName);
_workspace.MarkFetched();
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task RemoveRemoteAsync() => _workspace.RunAndRefreshAsync("원격 삭제",
() => _workspace.Git.RemoveRemote(_workspace.RemoteName));
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task FetchAsync() => _workspace.RunAndRefreshAsync("fetch", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.Fetch(_workspace.RemoteName);
_workspace.MarkFetched();
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task PullAsync()
{
// 목록은 화면 스레드의 것이라 작업에 들어가기 전에 읽어 둔다.
var tracked = _workspace.Branches.FirstOrDefault(b => b.IsCurrentHead)?.TrackedBranch;
return _workspace.RunAndRefreshAsync("pull", () =>
{
_workspace.ApplyCredentials();
var result = _workspace.Git.Pull(_workspace.RemoteName);
_workspace.MarkFetched();
// pull 도 병합이다. 머지 주석 자동 풀기 · 알림 · 병합 커밋 메시지를 [Merge] 와 같은 길로(7차 C-1).
// 예전에는 여기서 아무것도 안 해 주석만 다른 충돌까지 사람에게 물었고, 알림도 커밋 메시지도 없었다.
AfterMerge(result, MergeNames.PullMergeMessage(tracked, _workspace.CurrentBranch));
});
}
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task PushAsync() => _workspace.RunAndRefreshAsync("push", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.Push(remoteName: _workspace.RemoteName);
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task RefreshAsync() => _workspace.RefreshAsync();
// ================= 브랜치 =================
private bool HasBranch() => SelectedBranch is not null;
[RelayCommand(CanExecute = nameof(HasBranch))]
private Task CheckoutAsync() => _workspace.RunAndRefreshAsync("체크아웃",
() => _workspace.Git.Checkout(SelectedBranch!.FriendlyName));
private bool CanCreateBranch() => IsOpen() && !string.IsNullOrWhiteSpace(NewBranchName);
[RelayCommand(CanExecute = nameof(CanCreateBranch))]
private Task CreateBranchAsync() => _workspace.RunAndRefreshAsync("브랜치 생성", () =>
{
_workspace.Git.CreateBranch(NewBranchName, SelectedCommit?.Sha, checkout: true);
_workspace.OnUi(() => NewBranchName = string.Empty);
});
private bool CanDeleteBranch() => SelectedBranch is { IsRemote: false, IsCurrentHead: false };
[RelayCommand(CanExecute = nameof(CanDeleteBranch))]
private Task DeleteBranchAsync() => _workspace.RunAndRefreshAsync("브랜치 삭제",
() => _workspace.Git.DeleteBranch(SelectedBranch!.FriendlyName));
// ================= 병합 =================
private bool CanMerge() => SelectedBranch is not null && !SelectedBranch.IsCurrentHead;
[RelayCommand(CanExecute = nameof(CanMerge))]
private Task MergeAsync() => _workspace.RunAndRefreshAsync("병합", () =>
{
var branch = SelectedBranch!.FriendlyName;
var result = _workspace.Git.Merge(branch, commitOnSuccess: false);
AfterMerge(result, MergeNames.BranchMergeMessage(branch, _workspace.CurrentBranch));
});
/// <summary>
/// 병합(또는 pull) 결과를 받은 뒤 할 일. 작업 스레드에서 부른다(7차 C-1 에서 [Merge] 와 pull 이 함께 쓰게 뺐다).
/// </summary>
/// <param name="defaultMessage">커밋 메시지 칸이 비어 있고 병합 커밋이 남았을 때 채울 메시지.</param>
private void AfterMerge(MergeResultInfo result, string defaultMessage) =>
// 병합 커밋이 남는 경우에만 메시지를 채운다 - 이미 최신이거나 앞으로 감기면 커밋할 병합이 없다.
AfterConflicts(result.HasConflicts, result.Conflicts.Count, result.Message,
result.HasConflicts || result.Outcome == MergeOutcome.Merged ? defaultMessage : null,
GitOperationKind.Merge);
/// <summary>
/// 충돌이 날 수 있는 작업(병합 · pull · rebase · cherry-pick · revert) 뒤에 할 일. 작업 스레드에서 부른다.
/// 7차 C-2 에서 rebase · cherry-pick · revert 도 같은 길을 쓰게 했다 - 머지 주석 자동 풀기 · 알림 · 커밋 메시지.
/// </summary>
/// <param name="fillMessage">커밋 메시지 칸이 비어 있으면 채울 글. 없으면 null.</param>
private void AfterConflicts(bool hasConflicts, int conflictCount, string resultMessage, string? fillMessage, GitOperationKind kind)
{
// 머지 주석만 다른 충돌은 사람에게 묻지 않고 푼다. 양쪽 주석을 다 남긴다(6차 B-5).
IReadOnlyList<string> noteOnly = hasConflicts && _workspace.Settings.IgnoreMergeNotes
? _workspace.Git.ResolveNoteOnlyConflicts()
: Array.Empty<string>();
var remaining = noteOnly.Count > 0 ? _workspace.Git.GetConflicts().Count : conflictCount;
var finish = OperationLabels.FinishHint(kind);
_workspace.OnUi(() =>
{
// 결과 문장은 GitService 가 이미 로그에 적었다(Progress). 여기서 또 적으면 두 번 찍힌다(6차 C-1).
if (noteOnly.Count > 0)
_workspace.AppendLog($"머지 주석만 다른 충돌 {noteOnly.Count}개 파일은 자동으로 풀었습니다. 남은 충돌 {remaining}건.");
if (string.IsNullOrWhiteSpace(CommitMessage) && !string.IsNullOrWhiteSpace(fillMessage))
{
CommitMessage = fillMessage!;
// 병합 메시지는 우리가 채운 것이다. 해결 내역이 쌓일 때마다 본문을 새로 붙이려고 기억해 둔다(8차 F-1).
if (kind == GitOperationKind.Merge)
{
_autoMergeMessage = MergeResolutionLog.WithBody(fillMessage, _workspace.MergeResolutionBody);
CommitMessage = _autoMergeMessage;
}
}
var autoNote = noteOnly.Count > 0 ? $" (머지 주석만 다른 {noteOnly.Count}건은 자동으로 풀림)" : string.Empty;
var then = kind == GitOperationKind.Merge ? string.Empty : $" 다 풀면 {finish}.";
if (hasConflicts && remaining > 0)
MessageUtil.ShowWarningToast($"충돌 {remaining}건{autoNote} - 충돌 탭에서 파일을 두 번 누르면 파일 비교 탭에서 세 칸으로 풉니다.{then}");
else if (hasConflicts)
MessageUtil.ShowSuccessToast($"머지 주석만 다른 충돌 {noteOnly.Count}건을 자동으로 풀었습니다 - {finish}.");
else
MessageUtil.ShowToast(resultMessage);
});
}
/// <summary>메시지 칸에 우리가 채운 병합 메시지(본문 포함). 사람이 고치면 이것과 달라져 더는 건드리지 않는다.</summary>
private string _autoMergeMessage = string.Empty;
/// <summary>
/// 충돌을 하나 풀 때마다 메시지 칸의 해결 내역을 새로 붙인다(8차 F-1). 사람이 칸을 고쳤으면 두고,
/// 커밋할 때 <see cref="Services.GitService.CommitMerge"/> 가 본문을 붙인다 - 어느 쪽이든 기록은 남는다.
/// </summary>
private void RefreshMergeMessageBody()
{
if (_autoMergeMessage.Length == 0) return;
if (!_workspace.IsMergeInProgress)
{
_autoMergeMessage = string.Empty;
return;
}
if (!string.Equals(CommitMessage, _autoMergeMessage, StringComparison.Ordinal)) return;
var updated = MergeResolutionLog.WithBody(_autoMergeMessage, _workspace.MergeResolutionBody);
if (string.Equals(updated, CommitMessage, StringComparison.Ordinal)) return;
_autoMergeMessage = updated;
CommitMessage = updated;
}
/// <summary>충돌 탭 Accept 가 무엇으로 푼 것인지. BASE 로 되돌리기는 이 단추에 없어 적지 않는다.</summary>
private static MergeResolutionKind? ToResolutionKind(ConflictResolution resolution) => resolution switch
{
ConflictResolution.Mine => MergeResolutionKind.Mine,
ConflictResolution.Theirs => MergeResolutionKind.Theirs,
ConflictResolution.Both => MergeResolutionKind.Both,
_ => null
};
private bool CanAbort() =>
_workspace.IsOperationInProgress || _workspace.IsMergeInProgress || _workspace.IsRebaseInProgress;
[RelayCommand(CanExecute = nameof(CanAbort))]
private Task AbortAsync() => _workspace.RunAndRefreshAsync("작업 취소",
() => _workspace.Git.AbortOperation());
/// <summary>충돌 탭의 끝내기 단추 글자 — 병합 커밋 · 계속 · 커밋(7차 C-2).</summary>
public string FinishOperationLabel => OperationLabels.FinishButtonLabel(_workspace.OperationState.Kind);
private bool CanFinishOperation()
{
if (!_workspace.OperationState.IsInProgress || _workspace.Conflicts.Count > 0) return false;
// cherry-pick · revert 는 커밋할 것이 있어야 끝낼 수 있다. 이미 들어 있는 커밋을 가져오면 없다(8차 B-2).
return _workspace.OperationState.Kind is not (GitOperationKind.CherryPick or GitOperationKind.Revert)
|| _workspace.PendingHasChanges;
}
/// <summary>
/// 충돌을 다 푼 작업을 끝낸다(7차 C-2). 병합은 병합 커밋, rebase 는 계속, cherry-pick · revert 는 원래 메시지로 커밋.
/// 예전에는 [병합 커밋] 하나뿐이라 cherry-pick 은 충돌이 0 이 되면 끝낼 단추가 없었다.
/// </summary>
[RelayCommand(CanExecute = nameof(CanFinishOperation))]
private Task FinishOperationAsync()
{
var kind = _workspace.OperationState.Kind;
if (kind == GitOperationKind.Rebase) return RebaseContinueAsync();
if (kind == GitOperationKind.Merge) return CommitMergeAsync();
var name = kind == GitOperationKind.CherryPick ? "cherry-pick" : "revert";
return _workspace.RunAndRefreshAsync($"{name} 커밋", () =>
{
var message = string.IsNullOrWhiteSpace(CommitMessage) ? null : CommitMessage;
var sha = _workspace.Git.CommitPendingOperation(message);
_workspace.OnUi(() =>
{
CommitMessage = string.Empty;
MessageUtil.ShowSuccessToast($"{name} 커밋 완료 ({sha.Substring(0, 7)})");
});
});
}
private bool CanCommitMerge() => _workspace.IsMergeInProgress && _workspace.Conflicts.Count == 0;
[RelayCommand(CanExecute = nameof(CanCommitMerge))]
private Task CommitMergeAsync() => _workspace.RunAndRefreshAsync("병합 커밋", () =>
{
var message = string.IsNullOrWhiteSpace(CommitMessage) ? null : CommitMessage;
var sha = _workspace.Git.CommitMerge(message);
_workspace.OnUi(() =>
{
CommitMessage = string.Empty;
_autoMergeMessage = string.Empty;
MessageUtil.ShowSuccessToast($"병합 커밋 완료 ({sha.Substring(0, 7)})");
});
});
/// <summary>
/// 충돌 목록에서 고른 파일들(6차 B-6). Ctrl · Shift 로 여러 개를 고를 수 있다.
/// ListBox.SelectedItems 는 바인딩이 안 돼 화면이 고를 때마다 넘겨준다.
/// </summary>
private IReadOnlyList<ConflictInfo> _selectedConflicts = Array.Empty<ConflictInfo>();
public void SetSelectedConflicts(IReadOnlyList<ConflictInfo> conflicts)
{
_selectedConflicts = conflicts ?? Array.Empty<ConflictInfo>();
OnPropertyChanged(nameof(ConflictSelectionText));
RaiseCanExecuteChanged();
}
/// <summary>Accept 가 적용될 파일들. 여러 개를 골랐으면 그것들, 아니면 고른 하나.</summary>
private IReadOnlyList<ConflictInfo> ConflictTargets =>
_selectedConflicts.Count > 0 ? _selectedConflicts
: SelectedConflict is { } one ? new[] { one } : Array.Empty<ConflictInfo>();
/// <summary>여러 개를 골랐을 때 단추 옆에 띄우는 한 줄.</summary>
public string ConflictSelectionText =>
ConflictTargets.Count > 1 ? $"{ConflictTargets.Count}개 고름 - Accept 는 고른 파일 모두에 한 번에 적용됩니다" : string.Empty;
private bool HasConflict() => ConflictTargets.Count > 0;
[RelayCommand(CanExecute = nameof(HasConflict))]
private Task AcceptMineAsync() => ResolveAsync(ConflictResolution.Mine);
[RelayCommand(CanExecute = nameof(HasConflict))]
private Task AcceptTheirsAsync() => ResolveAsync(ConflictResolution.Theirs);
[RelayCommand(CanExecute = nameof(HasConflict))]
private Task AcceptBothAsync()
{
// 파일 단위 "둘 다" 는 파일 두 벌을 통째로 이어 붙인다. 양쪽이 같은 이름으로 새로 만든 파일이면
// 같은 형식이 두 번 나와 빌드가 안 된다(5차 C-1 에서 그랬다). 누르기 전에 무엇이 일어나는지 알린다(6차 A-5).
var targets = ConflictTargets;
var path = targets.Count <= 1
? targets.FirstOrDefault()?.Path ?? string.Empty
: $"{targets.Count}개 파일 ({targets[0].Path} 외 {targets.Count - 1}개)";
var go = MessageUtil.ShowYesNo(
"파일 두 벌을 이어 붙입니다",
$"{path}\n\n내 것 다음에 상대 것을 한 파일로 이어 붙입니다. 내 파일의 인코딩과 줄끝으로 씁니다.\n\n" +
"양쪽이 같은 이름으로 새로 만든 파일이면 같은 형식이 두 번 나와 빌드가 안 될 수 있습니다.\n" +
"줄마다 골라 둘 다 넣으려면 파일 비교 탭의 3분할에서 하세요.\n\n이어 붙일까요?");
return go ? ResolveAsync(ConflictResolution.Both) : Task.CompletedTask;
}
/// <summary>
/// 고른 충돌 파일을 파일 비교 탭의 3분할로 연다(6차 B-1). 목록을 두 번 누르거나 Enter 로도 온다.
/// 켜짐/꺼짐을 따로 두지 않는다 - 고른 것이 없으면 아무 일도 안 한다. 이진 파일은 줄 단위로 볼 수 없어 알린다.
/// </summary>
[RelayCommand]
private void OpenConflictInCompare()
{
var conflict = SelectedConflict;
if (conflict is null) return;
if (conflict.IsBinary)
{
MessageUtil.ShowWarningToast($"이진 파일이라 줄 단위로 볼 수 없습니다. 아래 Accept 단추로 한쪽을 고르세요: {conflict.Path}");
return;
}
_workspace.RequestOpenConflict(conflict.Path);
}
/// <summary>
/// 고른 충돌 파일들을 한 작업으로 푼다(6차 B-6).
///
/// 예전에는 파일 하나마다 작업 하나였고, 작업이 끝날 때마다 저장소 전체를 다시 읽었다(한 개 약 1.5초).
/// 이제는 고른 파일을 모두 풀고 <b>다시 읽기는 끝에 한 번</b>이다.
/// 한 파일이 실패해도 나머지는 계속 풀고, 실패한 것은 로그와 알림으로 알린다.
/// </summary>
private Task ResolveAsync(ConflictResolution resolution)
{
var targets = ConflictTargets.ToList();
if (targets.Count == 0) return Task.CompletedTask;
var label = targets.Count == 1 ? $"충돌 해결({resolution})" : $"충돌 해결({resolution}) {targets.Count}개";
return _workspace.RunAndRefreshAsync(label, () =>
{
var failed = new List<string>();
var watch = Stopwatch.StartNew();
foreach (var conflict in targets)
{
try
{
_workspace.Git.ResolveConflict(conflict.Path, resolution);
if (ToResolutionKind(resolution) is { } kind) _workspace.Git.RecordResolution(conflict.Path, kind);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
failed.Add($"{conflict.Path}: {ex.Message}");
}
}
watch.Stop();
_workspace.OnUi(() =>
{
if (targets.Count > 1)
_workspace.AppendLog($"충돌 {targets.Count - failed.Count}개를 {resolution} 로 한 번에 풀었습니다 " +
$"({watch.ElapsedMilliseconds}ms · 저장소 다시 읽기는 끝에 한 번)");
foreach (var line in failed) _workspace.AppendLog("충돌 해결 실패: " + line);
if (failed.Count > 0) MessageUtil.ShowWarningToast($"{failed.Count}개는 풀지 못했습니다 - 로그를 보세요");
});
});
}
// ================= 이력 조작 =================
private bool HasCommit() => SelectedCommit is not null;
[RelayCommand(CanExecute = nameof(HasCommit))]
private Task RevertAsync() => _workspace.RunAndRefreshAsync("revert", () =>
{
// 결과 한 줄은 GitService 가 로그에 적는다. 여기서 또 적으면 두 번 찍힌다(6차 C-1). 뒷일은 병합과 같은 길로(7차 C-2).
var result = _workspace.Git.Revert(SelectedCommit!.Sha);
AfterConflicts(result.HasConflicts, result.Conflicts.Count, result.Message,
_workspace.Git.GetPendingCommitMessage(), GitOperationKind.Revert);
});
[RelayCommand(CanExecute = nameof(HasCommit))]
private Task CherryPickAsync() => _workspace.RunAndRefreshAsync("cherry-pick", () =>
{
// 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). 뒷일은 병합과 같은 길로(7차 C-2).
var result = _workspace.Git.CherryPick(SelectedCommit!.Sha);
AfterConflicts(result.HasConflicts, result.Conflicts.Count, result.Message,
_workspace.Git.GetPendingCommitMessage(), GitOperationKind.CherryPick);
});
[RelayCommand(CanExecute = nameof(HasCommit))]
private Task ResetAsync() => _workspace.RunAndRefreshAsync($"reset --{SelectedResetKind}",
() => _workspace.Git.Reset(SelectedResetKind, SelectedCommit!.Sha));
/// <summary>우클릭 메뉴 [메시지 바꾸기...] 에 걸어 둘 설명.</summary>
public string RewordHint => CommitReword.MenuHint;
/// <summary>
/// 이력에서 고른 커밋의 메시지를 바꾼다(11차 C-2, `git rebase -i` 의 reword).
///
/// HEAD 가 아니면 <b>그 커밋부터 HEAD 까지가 새 해시로 다시 써진다</b>. 그래서 좁은 범위에서만 한다 -
/// 막을 까닭은 <see cref="CommitReword.BlockReason"/> 가 정하고, 여기서는 눌렀을 때 <b>왜 안 되는지 말해 준다</b>.
/// 메뉴를 꺼 두지 않는 까닭: 꺼 두면 왜 안 되는지 알 길이 없고, 까닭은 저장소를 읽어 봐야 안다.
///
/// 저장 안 한 편집은 <see cref="GitWorkspace.RunAndRefreshAsync"/> 가 지금까지의 Git 작업과 같은 창으로 묻는다.
/// </summary>
[RelayCommand(CanExecute = nameof(HasCommit))]
private async Task RewordAsync()
{
var commit = SelectedCommit!;
var info = _workspace.Git.InspectReword(commit.Sha);
var blocked = CommitReword.BlockReason(
info.Exists, AmendBlockedByOperation, !_workspace.IsWorkingTreeClean,
info.IsAncestorOfHead, info.PushedTo, info.OtherBranches, info.TagsInRange, info.MergeCommits);
if (blocked is not null)
{
MessageUtil.ShowWarningToast($"메시지를 바꿀 수 없습니다 - {blocked}");
return;
}
var target = $"{commit.ShortSha} · {commit.AuthorName} · {commit.When.LocalDateTime:yyyy-MM-dd HH:mm}";
var typed = RewordWindow.Ask(target, commit.Message,
info.RewriteCount <= 1
? "이 커밋이 새 해시로 다시 써집니다"
: $"이 커밋과 그 뒤 {info.RewriteCount - 1}개, 모두 {info.RewriteCount}개가 새 해시로 다시 써집니다");
if (typed is null) return;
if (!CommitReword.IsUsableMessage(typed))
{
MessageUtil.ShowWarningToast("메시지가 비어 있어 바꾸지 않았습니다");
return;
}
if (!CommitReword.Changed(commit.Message, typed))
{
MessageUtil.ShowToast("메시지가 그대로라 다시 쓰지 않았습니다");
return;
}
if (!MessageUtil.ShowYesNo("커밋 메시지 바꾸기",
CommitReword.Confirm(commit.ShortSha, commit.MessageShort, info.RewriteCount, _workspace.HeadSha)))
return;
var newTarget = string.Empty;
var done = await _workspace.RunAndRefreshAsync("커밋 메시지 바꾸기", () =>
{
var result = _workspace.Git.RewordCommit(commit.Sha, typed);
newTarget = result.NewTarget;
_workspace.OnUi(() =>
MessageUtil.ShowSuccessToast(CommitReword.Done(result.OldHead, result.NewHead, result.Rewritten)));
});
// 고른 커밋을 <b>새 해시로</b> 따라간다. 그냥 두면 옛 해시가 사라져 선택이 HEAD 로 떨어진다(9차 A-1 · A-2).
if (!done || newTarget.Length == 0) return;
if (_workspace.Commits.FirstOrDefault(c => string.Equals(c.Sha, newTarget, StringComparison.Ordinal)) is { } moved)
SelectedCommit = moved;
}
private bool CanRebase() =>
SelectedBranch is not null && !SelectedBranch.IsCurrentHead && !_workspace.IsRebaseInProgress;
[RelayCommand(CanExecute = nameof(CanRebase))]
private Task RebaseAsync() => _workspace.RunAndRefreshAsync("rebase", () =>
{
// 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). 뒷일은 병합과 같은 길로(7차 C-2).
var result = _workspace.Git.RebaseStart(SelectedBranch!.FriendlyName);
AfterConflicts(result.Outcome == RebaseOutcome.Conflicts, result.Conflicts.Count, result.Message,
null, GitOperationKind.Rebase);
});
private bool IsRebasing() => _workspace.IsRebaseInProgress;
[RelayCommand(CanExecute = nameof(IsRebasing))]
private Task RebaseContinueAsync() => _workspace.RunAndRefreshAsync("rebase continue", () =>
{
// 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). 다음 단계에서 또 충돌하면 병합과 같은 뒷일(7차 C-2).
var result = _workspace.Git.RebaseContinue();
AfterConflicts(result.Outcome == RebaseOutcome.Conflicts, result.Conflicts.Count, result.Message,
null, GitOperationKind.Rebase);
});
[RelayCommand(CanExecute = nameof(IsRebasing))]
private Task RebaseAbortAsync() => _workspace.RunAndRefreshAsync("rebase abort",
() => _workspace.Git.RebaseAbort());
// ================= 스테이징 / 커밋 =================
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task StageAllAsync() => _workspace.RunAndRefreshAsync("전체 스테이징",
() => _workspace.Git.StageAll());
private bool HasStatusFile() => SelectedStatusFile is not null;
[RelayCommand(CanExecute = nameof(HasStatusFile))]
private Task StageSelectedAsync() => _workspace.RunAndRefreshAsync("스테이징",
() => _workspace.Git.Stage(SelectedStatusFile!.Path));
[RelayCommand(CanExecute = nameof(HasStatusFile))]
private Task UnstageSelectedAsync() => _workspace.RunAndRefreshAsync("언스테이징",
() => _workspace.Git.Unstage(SelectedStatusFile!.Path));
[RelayCommand(CanExecute = nameof(HasStatusFile))]
private Task RestoreSelectedAsync() => _workspace.RunAndRefreshAsync("변경 취소",
() => _workspace.Git.RestoreFiles(new[] { SelectedStatusFile!.Path }));
private bool CanCommit() => IsOpen() && !string.IsNullOrWhiteSpace(CommitMessage);
[RelayCommand(CanExecute = nameof(CanCommit))]
private Task CommitAsync() => _workspace.RunAndRefreshAsync("커밋", () =>
{
var sha = _workspace.Git.Commit(CommitMessage);
_workspace.OnUi(() =>
{
CommitMessage = string.Empty;
MessageUtil.ShowSuccessToast($"커밋 완료 ({sha.Substring(0, 7)})");
});
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task StashSaveAsync() => _workspace.RunAndRefreshAsync("stash 저장",
() => _workspace.Git.StashSave($"MergeHub {DateTime.Now:yyyy-MM-dd HH:mm}"));
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task StashPopAsync() => _workspace.RunAndRefreshAsync("stash 적용",
() => _workspace.Git.StashPop());
// ================= 커밋 수정 (amend) =================
/// <summary>
/// [커밋 수정] 을 켤 조건. <b>작업 중에는 끈다</b>(11차 C-1) - 병합 · rebase · cherry-pick · revert 가
/// 도는 중에 누르면 "cannot create a tree from a not fully merged index" 로 실패했다.
/// </summary>
private bool CanAmend() =>
CommitSelection.CanAmend(IsOpen(), !string.IsNullOrWhiteSpace(CommitMessage), AmendBlockedByOperation);
/// <summary>병합 · rebase · cherry-pick · revert 가 도는 중인가.</summary>
private bool AmendBlockedByOperation =>
_workspace.IsMergeInProgress || _workspace.IsRebaseInProgress || _workspace.IsOperationInProgress;
/// <summary>단추 설명. 작업 중이면 왜 꺼졌는지 말한다.</summary>
public string AmendHint => CommitSelection.AmendHint(AmendBlockedByOperation);
/// <summary>스테이징해 둔 파일 수 - 고치면 이 파일들도 함께 들어간다(충돌 중인 것은 빼고 센다).</summary>
private int StagedFileCount => _workspace.StatusFiles.Count(f => f.IsStaged && !f.IsConflicted);
/// <summary>지금 브랜치가 따라가는 원격 브랜치 이름(없으면 null).</summary>
private string? HeadTrackedBranch => _workspace.Branches.FirstOrDefault(b => b.IsCurrentHead)?.TrackedBranch;
/// <summary>
/// HEAD 가 <b>이미 원격에 올라가 있는가</b> - 추적 브랜치가 있고 앞선 커밋이 0개면 올라간 것으로 본다.
/// 이 수치는 마지막 fetch 시점 기준이라, fetch 전에 남이 올린 것까지는 알 수 없다(그래서 막지 않고 묻기만 한다).
/// </summary>
private bool IsHeadAlreadyPushed
{
get
{
var head = _workspace.Branches.FirstOrDefault(b => b.IsCurrentHead);
return head?.TrackedBranch is not null && (head.AheadBy ?? 0) == 0;
}
}
/// <summary>
/// 마지막 커밋을 고쳐 쓴다(`git commit --amend`).
///
/// 오타 난 메시지나 빠뜨린 파일을 <b>새 커밋을 쌓지 않고</b> 바로잡을 때 쓴다.
/// 이미 올린 커밋을 고치면 남의 이력과 갈라지므로, <b>푸시 전에만</b> 쓰는 것이 원칙이다.
///
/// 고치기 전 해시를 알림에 남긴다(11차 C-1) - 예전에는 새 해시만 알려 되돌릴 실마리가 없었다.
/// 스테이징해 둔 파일이 있거나 이미 올린 커밋이면 <b>먼저 묻는다</b>(11차 C-1, 2026-09-18 사용자 결정).
/// </summary>
[RelayCommand(CanExecute = nameof(CanAmend))]
private Task AmendAsync()
{
var oldSha = _workspace.HeadFullSha;
// 누르기 전에 물을 것. [취소] 하면 커밋도 메시지 칸도 그대로 둔다.
var warning = CommitSelection.AmendWarning(StagedFileCount, IsHeadAlreadyPushed, HeadTrackedBranch);
if (warning is not null && !MessageUtil.ShowYesNo("커밋 수정", warning))
return Task.CompletedTask;
return _workspace.RunAndRefreshAsync("커밋 수정", () =>
{
var staged = _workspace.Git.GetStatus().StagedCount;
var sha = _workspace.Git.Commit(CommitMessage, amend: true);
_workspace.OnUi(() =>
{
CommitMessage = string.Empty;
MessageUtil.ShowSuccessToast(CommitSelection.AmendDone(oldSha, sha, staged));
});
});
}
private bool CanLoadLastMessage() => IsOpen() && _workspace.Commits.Count > 0;
/// <summary>
/// 고칠 메시지를 처음부터 다시 치지 않도록 <b>HEAD 커밋</b>의 메시지를 가져온다.
///
/// 예전에는 이력 목록의 맨 위(<c>Commits[0]</c>)를 가져왔다. [모든 브랜치] 를 켜면 맨 위가 다른 브랜치의
/// 더 새 커밋이라, 그대로 [커밋 수정] 하면 <b>HEAD 제목이 남의 커밋 메시지로 바뀌었다</b>(11차 C-1).
/// </summary>
[RelayCommand(CanExecute = nameof(CanLoadLastMessage))]
private void LoadLastMessage()
{
var pairs = _workspace.Commits.Select(c => (c.Sha, c.Message)).ToList();
var message = CommitSelection.MessageOfHead(pairs, _workspace.HeadFullSha);
if (message is null)
{
MessageUtil.ShowWarningToast("HEAD 커밋이 이력 목록에 없어 메시지를 가져오지 못했습니다.");
return;
}
CommitMessage = 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()
{
// 저장소가 있는 자리에서 시작한다 - 뽑아 놓을 자리는 보통 그 근처다.
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;
// 고른 폴더에 바로 쏟지 않고 <b>그 아래 새 폴더</b>를 만든다(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);
var author = MergeNote.AuthorOf(_workspace.AuthorName);
return _workspace.RunAsync("변경분 추출", () =>
{
var (written, skipped, items) = _workspace.Git.ExportChangedFiles(from, to.Sha, target);
// 뽑은 폴더 <b>옆</b>에 안내 글을 둔다(11차 A-2, 사용자 결정) - 폴더를 장비에 통째로 넣어도
// 소스 틈에 안내 파일이 섞이지 않는다. 지운 파일 · 이름 바뀐 옛 경로가 여기에만 남는다.
var notePath = ExportReport.PathFor(target);
var noteWritten = true;
try
{
TextFile.Write(notePath,
ExportReport.Build(_workspace.RepositoryPath, target, fromLabel, toLabel, items, DateTime.Now, author));
}
catch (Exception ex)
{
// 파일은 다 뽑았는데 안내만 못 쓴 것이라 추출 자체를 실패로 만들지 않는다.
noteWritten = false;
_workspace.AppendLog($"변경분 안내를 쓰지 못했습니다: {ex.Message}");
}
if (noteWritten) _workspace.AppendLog($"변경분 안내: {notePath}");
var folderName = Path.GetFileName(target);
_workspace.OnUi(() => MessageUtil.ShowSuccessToast(
skipped > 0
? $"{folderName} 에 {written}개 (지워야 할 파일 {skipped}개는 안내에)"
: $"{folderName} 에 {written}개 파일 추출"));
});
}
/// <summary>
/// 안내 글에 적을 커밋 한 줄. 이력에 있는 커밋이면 제목과 날짜까지 적고, 없으면 해시만 적는다.
/// 시작점을 안 잡았을 때는 "부모" 라는 것을 드러낸다 - 폴더만 보고 무엇과 견줬는지 알 수 있어야 한다.
/// </summary>
private string RangeLabel(string sha, CommitInfo? known, bool isParentFallback)
{
var info = known ?? _workspace.Commits.FirstOrDefault(c => c.Sha == sha);
var head = info is null
? (sha.Length > 7 ? sha.Substring(0, 7) : sha)
: $"{info.ShortSha} {info.MessageShort} ({info.When:yyyy-MM-dd HH:mm})";
return isParentFallback ? head + " (고른 커밋의 부모)" : head;
}
/// <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);
[RelayCommand(CanExecute = nameof(CanCreateTag))]
private Task CreateTagAsync() => _workspace.RunAndRefreshAsync("태그 생성", () =>
{
var message = string.IsNullOrWhiteSpace(TagMessage) ? null : TagMessage;
_workspace.Git.CreateTag(TagName, SelectedCommit?.Sha ?? "HEAD", message);
_workspace.OnUi(() => { TagName = string.Empty; TagMessage = string.Empty; });
});
private bool HasTag() => SelectedTag is not null;
[RelayCommand(CanExecute = nameof(HasTag))]
private Task DeleteTagAsync() => _workspace.RunAndRefreshAsync("태그 삭제",
() => _workspace.Git.DeleteTag(SelectedTag!.Name));
private bool CanUpdateSubmodules() => IsOpen() && _workspace.Submodules.Count > 0;
[RelayCommand(CanExecute = nameof(CanUpdateSubmodules))]
private Task UpdateSubmodulesAsync() => _workspace.RunAndRefreshAsync("서브모듈 업데이트", () =>
{
_workspace.ApplyCredentials();
_workspace.Git.UpdateAllSubmodules();
});
[RelayCommand(CanExecute = nameof(IsOpen))]
private Task RegisterMergeToolAsync() => _workspace.RunAsync("mergetool 등록", () =>
{
// net48 에는 Environment.ProcessPath 가 없다. 호스트는 UbisamBase.Shell.exe.
var exe = Process.GetCurrentProcess().MainModule?.FileName
?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UbisamBase.Shell.exe");
_workspace.Git.RegisterAsMergeTool("mergehub", exe, global: true);
_workspace.OnUi(() => MessageUtil.ShowSuccessToast("git config 에 mergehub 등록 완료"));
});
// ================= 탐색기 우클릭 =================
/// <summary>지금 우클릭 메뉴에 걸려 있는지. 버튼 이름을 이 값으로 바꾼다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShellIntegrationLabel))]
private bool _isShellRegistered = ShellIntegration.IsRegistered;
public string ShellIntegrationLabel =>
IsShellRegistered ? "탐색기 보내기 해제" : "탐색기 보내기 등록";
/// <summary>
/// 탐색기 <b>보내기</b> 메뉴에 이 프로그램을 넣고 뺀다.
///
/// 우클릭 메뉴에 바로 넣는 레지스트리 방식은 이 PC 에서 뜨지 않았다
/// (자세한 이유는 ShellIntegration 설명 참고). 보내기는 바로가기 파일 하나라
/// 권한도 필요 없고, 여러 개를 골라 한 번에 보낼 수 있다.
/// </summary>
[RelayCommand]
private void ToggleShellIntegration()
{
try
{
if (IsShellRegistered)
{
ShellIntegration.Unregister();
MessageUtil.ShowToast("탐색기 보내기 메뉴에서 뺐습니다");
}
else
{
ShellIntegration.Register(ShellIntegration.CurrentExecutable());
MessageUtil.ShowSuccessToast("우클릭 → 보내기 → " + ShellIntegration.MenuLabel + " 로 엽니다");
}
IsShellRegistered = ShellIntegration.IsRegistered;
}
catch (Exception ex)
{
MessageUtil.ShowErrorToast("보내기 등록 실패: " + ex.Message);
}
}
/// <summary>CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않아 직접 알려야 한다.</summary>
private void RaiseCanExecuteChanged()
{
OpenCommand.NotifyCanExecuteChanged();
CloneCommand.NotifyCanExecuteChanged();
AddRemoteCommand.NotifyCanExecuteChanged();
RemoveRemoteCommand.NotifyCanExecuteChanged();
FetchCommand.NotifyCanExecuteChanged();
PullCommand.NotifyCanExecuteChanged();
PushCommand.NotifyCanExecuteChanged();
RefreshCommand.NotifyCanExecuteChanged();
CheckoutCommand.NotifyCanExecuteChanged();
CreateBranchCommand.NotifyCanExecuteChanged();
DeleteBranchCommand.NotifyCanExecuteChanged();
MergeCommand.NotifyCanExecuteChanged();
AbortCommand.NotifyCanExecuteChanged();
CommitMergeCommand.NotifyCanExecuteChanged();
FinishOperationCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(FinishOperationLabel));
AcceptMineCommand.NotifyCanExecuteChanged();
AcceptTheirsCommand.NotifyCanExecuteChanged();
AcceptBothCommand.NotifyCanExecuteChanged();
RevertCommand.NotifyCanExecuteChanged();
CherryPickCommand.NotifyCanExecuteChanged();
ResetCommand.NotifyCanExecuteChanged();
RewordCommand.NotifyCanExecuteChanged();
RebaseCommand.NotifyCanExecuteChanged();
RebaseContinueCommand.NotifyCanExecuteChanged();
RebaseAbortCommand.NotifyCanExecuteChanged();
StageAllCommand.NotifyCanExecuteChanged();
StageSelectedCommand.NotifyCanExecuteChanged();
UnstageSelectedCommand.NotifyCanExecuteChanged();
RestoreSelectedCommand.NotifyCanExecuteChanged();
CommitCommand.NotifyCanExecuteChanged();
StashSaveCommand.NotifyCanExecuteChanged();
StashPopCommand.NotifyCanExecuteChanged();
CreateTagCommand.NotifyCanExecuteChanged();
DeleteTagCommand.NotifyCanExecuteChanged();
UpdateSubmodulesCommand.NotifyCanExecuteChanged();
RegisterMergeToolCommand.NotifyCanExecuteChanged();
AmendCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(AmendHint)); // 작업이 시작 · 끝나면 단추 설명도 바뀐다(11차 C-1)
LoadLastMessageCommand.NotifyCanExecuteChanged();
StashApplySelectedCommand.NotifyCanExecuteChanged();
StashDropSelectedCommand.NotifyCanExecuteChanged();
SetDiffFromCommand.NotifyCanExecuteChanged();
ClearDiffFromCommand.NotifyCanExecuteChanged();
ExportChangesCommand.NotifyCanExecuteChanged();
BlameCommand.NotifyCanExecuteChanged();
BlameSelectedFileCommand.NotifyCanExecuteChanged();
}
// ================= diff =================
private Task ShowDiffAsync(CommitInfo? commit)
{
if (commit is null) return Task.CompletedTask;
return _workspace.RunAsync("diff", () =>
{
// 비교 시작점을 잡아 두었으면 두 커밋 "사이", 아니면 지금까지처럼 부모와 견준다.
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(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));
foreach (var d in diffs)
{
sb.AppendLine($"[{d.ChangeKind}] {d.Path} (+{d.LinesAdded} / -{d.LinesDeleted})");
if (d.IsBinary) { sb.AppendLine(" (binary)"); continue; }
foreach (var line in d.Lines)
{
var marker = line.Kind switch
{
DiffLineKind.Added => '+',
DiffLineKind.Deleted => '-',
DiffLineKind.HunkHeader => '@',
_ => ' '
};
sb.Append(marker).Append(' ').AppendLine(RenderLine(line));
}
sb.AppendLine();
}
var text = sb.ToString();
_workspace.OnUi(() => DiffText = text);
});
}
/// <summary>단어 단위 diff 가 켜져 있으면 바뀐 구간을 〔〕로 감싼다.</summary>
private static string RenderLine(DiffLine line)
{
if (line.Words is null || line.Words.Count == 0) return line.Text;
var sb = new StringBuilder();
foreach (var word in line.Words)
{
if (word.Kind == WordDiffKind.Unchanged) sb.Append(word.Text);
else sb.Append('〔').Append(word.Text).Append('〕');
}
return sb.ToString();
}
}
}