ksj09406 5856ca0436 6차 C-1: 결과 로그 한 번, 충돌 목록에 종류와 푼 개수
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:07:56 +09:00

823 lines
38 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 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));
_workspace.Refreshed += SyncStashSelection;
_workspace.StateChanged += RaiseCanExecuteChanged;
// 원격을 이미 쓰고 있으면 펼쳐 둔다 - 접어 두면 쓰던 사람이 사라진 줄 안다.
_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();
}
}
public CommitInfo? SelectedCommit
{
get => _workspace.SelectedCommit;
set
{
if (ReferenceEquals(_workspace.SelectedCommit, value)) return;
_workspace.SelectedCommit = value;
OnPropertyChanged();
_ = ShowDiffAsync(value);
RaiseCanExecuteChanged();
}
}
// ================= 저장소 =================
[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() => _workspace.RunAndRefreshAsync("pull", () =>
{
_workspace.ApplyCredentials();
var result = _workspace.Git.Pull(_workspace.RemoteName);
_workspace.MarkFetched();
// 결과 한 줄은 GitService 가 로그에 적는다(Pull 은 Merge 를 거친다). 여기서 또 적으면 두 번 찍힌다(6차 C-1).
});
[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);
// 머지 주석만 다른 충돌은 사람에게 묻지 않고 푼다. 양쪽 주석을 다 남긴다(6차 B-5).
IReadOnlyList<string> noteOnly = result.HasConflicts && _workspace.Settings.IgnoreMergeNotes
? _workspace.Git.ResolveNoteOnlyConflicts()
: Array.Empty<string>();
var remaining = noteOnly.Count > 0 ? _workspace.Git.GetConflicts().Count : result.Conflicts.Count;
_workspace.OnUi(() =>
{
// result.Message("충돌 N건 - 해결이 필요합니다.")는 GitService 가 이미 로그에 적었다(Progress).
// 여기서 또 적으면 두 번 찍히고, 자동 해결 줄 뒤에 다시 나와 순서도 헷갈렸다(6차 C-1).
if (noteOnly.Count > 0)
_workspace.AppendLog($"머지 주석만 다른 충돌 {noteOnly.Count}개 파일은 자동으로 풀었습니다. 남은 충돌 {remaining}건.");
if (string.IsNullOrWhiteSpace(CommitMessage))
CommitMessage = $"Merge branch '{branch}' into {_workspace.CurrentBranch}";
var autoNote = noteOnly.Count > 0 ? $" (머지 주석만 다른 {noteOnly.Count}건은 자동으로 풀림)" : string.Empty;
if (result.HasConflicts && remaining > 0)
MessageUtil.ShowWarningToast($"충돌 {remaining}건{autoNote} - 충돌 탭에서 파일을 두 번 누르면 파일 비교 탭에서 세 칸으로 풉니다.");
else if (result.HasConflicts)
MessageUtil.ShowSuccessToast($"머지 주석만 다른 충돌 {noteOnly.Count}건을 자동으로 풀었습니다 - 병합 커밋을 할 수 있습니다.");
else
MessageUtil.ShowToast(result.Message);
});
});
private bool CanAbort() => _workspace.IsMergeInProgress || _workspace.IsRebaseInProgress;
[RelayCommand(CanExecute = nameof(CanAbort))]
private Task AbortAsync() => _workspace.RunAndRefreshAsync("작업 취소",
() => _workspace.Git.AbortOperation());
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;
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);
}
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).
_workspace.Git.Revert(SelectedCommit!.Sha);
});
[RelayCommand(CanExecute = nameof(HasCommit))]
private Task CherryPickAsync() => _workspace.RunAndRefreshAsync("cherry-pick", () =>
{
// 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1).
_workspace.Git.CherryPick(SelectedCommit!.Sha);
});
[RelayCommand(CanExecute = nameof(HasCommit))]
private Task ResetAsync() => _workspace.RunAndRefreshAsync($"reset --{SelectedResetKind}",
() => _workspace.Git.Reset(SelectedResetKind, SelectedCommit!.Sha));
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).
_workspace.Git.RebaseStart(SelectedBranch!.FriendlyName);
});
private bool IsRebasing() => _workspace.IsRebaseInProgress;
[RelayCommand(CanExecute = nameof(IsRebasing))]
private Task RebaseContinueAsync() => _workspace.RunAndRefreshAsync("rebase continue", () =>
{
// 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1).
_workspace.Git.RebaseContinue();
});
[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) =================
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()
{
// 저장소가 있는 자리에서 시작한다 - 뽑아 놓을 자리는 보통 그 근처다.
var target = FolderPicker.Pick("변경분 파일을 뽑아 놓을 폴더", ParentOf(_workspace.RepositoryPath));
if (target is null) return Task.CompletedTask;
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);
[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();
AcceptMineCommand.NotifyCanExecuteChanged();
AcceptTheirsCommand.NotifyCanExecuteChanged();
AcceptBothCommand.NotifyCanExecuteChanged();
RevertCommand.NotifyCanExecuteChanged();
CherryPickCommand.NotifyCanExecuteChanged();
ResetCommand.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();
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();
}
}
}