기획안 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>
1170 lines
58 KiB
C#
1170 lines
58 KiB
C#
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();
|
||
}
|
||
}
|
||
}
|