using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using MergeHub.Models;
using UbisamBase.Core.Logging;
using UbisamBase.Core.Messaging;
using UbisamBase.Core.SettingItems;
namespace MergeHub.Services
{
///
/// 줄 단위로 견줄 파일 한 짝. 폴더 비교가 파일 비교로 넘기는 목록의 단위다.
///
public sealed class ComparePair
{
public ComparePair(string left, string right, string name)
{
Left = left ?? string.Empty;
Right = right ?? string.Empty;
Name = name ?? string.Empty;
}
public string Left { get; }
public string Right { get; }
/// 화면에 적을 이름. 대개 상대 경로다.
public string Name { get; }
}
///
/// 홈(머지툴) 탭과 Git 탭이 함께 쓰는 저장소 상태.
/// IGitService 는 스레드 안전하지 않으므로 모든 호출을 여기서 한 줄로 직렬화한다.
/// DI 싱글턴으로 등록되어 두 화면이 같은 인스턴스를 본다.
///
public sealed partial class GitWorkspace : ObservableObject
{
private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1);
private readonly SynchronizationContext? _uiContext;
private readonly Logger _logger = new Logger("MergeHub");
public GitWorkspace(IGitService git, ISettingsService settingsService)
{
Git = git;
_uiContext = SynchronizationContext.Current;
_settingsService = settingsService;
Settings = settingsService.RegisterSettings(nameof(MergeHubSettings), new MergeHubSettings());
RepositoryPath = Settings.DefaultRepositoryPath;
RemoteName = string.IsNullOrWhiteSpace(Settings.DefaultRemoteName) ? "origin" : Settings.DefaultRemoteName;
RemoteUrl = Settings.GiteaUrl;
UserName = Settings.GiteaUserName;
LoadToken(); // 이 서버에 맡겨 둔 토큰이 있으면 가져온다(11차 F-11)
Git.IgnoreWhitespace = Settings.IgnoreWhitespace;
Git.WordLevelDiff = Settings.WordLevelDiff;
// 저장할 때 백업(11차 F-9). Git 이 워킹트리 파일을 쓰는 길(병합 중 Save · 파일 단위 Accept)도 같은 것을 부른다.
Backup = new FileBackup(FileBackup.DefaultRoot(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot),
() => Settings.BackupBeforeSave);
Backup.Kept += (original, backup) => AppendLog($"백업: {original} → {backup}");
Git.BeforeOverwrite = path => Backup.Keep(path);
// 작성자 이름은 여기서 한 번 정해 둔다.
// 저장소를 읽을 때(ReadState)만 정하면 Git 없이 파일 두 개를 비교할 때 이름이 비고,
// 그러면 주석에 등록한 이름 대신 윈도우 계정이 찍힌다.
AuthorName = ResolveAuthorName();
// 최근 목록은 켤 때 한 번 읽고, 바뀔 때마다 바로 쓴다.
// 프로그램이 갑자기 꺼져도 목록이 남게 하려는 것이다 - 몇 줄짜리 글이라 쓰는 값이 싸다.
Recent.Load(RecentPath);
Recent.Changed += (_, _) => Recent.Save(RecentPath);
// `git mergetool` · 탐색기 우클릭이 넘긴 것을 화면으로 보낸다.
// 여기서 하는 이유 - 이 객체는 첫 화면이 만들어질 때 함께 만들어지므로
// 요청을 남겨 둘 자리로 가장 이르다.
RouteStartupArgs();
// 보내기 바로가기가 옛 자리를 가리키면 지금 실행 파일로 고친다(11차 F-3). 등록한 사람만, 레지스트리는 안 건드린다.
try
{
if (ShellIntegration.RepairIfMoved(ShellIntegration.CurrentExecutable()) is { } old)
AppendLog($"보내기 바로가기가 옛 자리({old})를 가리켜 지금 실행 파일로 고쳤습니다");
}
catch (Exception ex)
{
_logger.W($"보내기 바로가기 확인 실패: {ex.Message}");
}
Git.Progress += (_, message) =>
{
_logger.I(message);
OnUi(() => AppendLog(message));
};
}
public IGitService Git { get; }
///
/// 최근에 견준 짝. 파일 비교와 폴더 비교가 함께 본다.
///
/// 여기에 두는 까닭 - 두 화면이 서로를 모르는 채로 같은 목록을 봐야 하고,
/// 이 객체가 이미 그 통로 노릇을 하고 있다.
///
public RecentPairs Recent { get; } = new RecentPairs();
///
/// 최근 목록을 두는 자리. 플랫폼 설정 폴더 아래에 둔다 - 장비마다 한 자리에 모인다.
/// 설정 화면에는 내놓지 않는다. 사람이 손으로 고칠 값이 아니고, 설정 목록만 어지럽힌다.
///
private static string RecentPath => Path.Combine(
UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot, "Settings", "MergeHub.Recent.txt");
public MergeHubSettings Settings { get; }
/// 저장할 때 백업(11차 F-9). 설정이 꺼져 있으면 아무것도 하지 않는다.
public FileBackup Backup { get; }
private readonly ISettingsService _settingsService;
///
/// 지금 설정을 파일에 쓴다. 화면에서 켜고 끈 것이 다음에 켤 때도 남게 하려는 것이다.
/// 설정 화면에서 고치는 값은 그쪽이 알아서 쓰므로 여기를 부를 일이 없다.
///
public void SaveSettings() => _settingsService.Save(nameof(MergeHubSettings));
// ================= 원격 주소 · 계정 · 토큰 (11차 F-11) =================
/// 토큰을 맡겨 두는 곳 - 윈도 자격 증명 관리자(사용자 결정). 설정 파일에는 토큰 글자가 남지 않는다.
private readonly GitCredentialStore _tokens = new GitCredentialStore();
/// 주소 · 계정을 설정에 적는 것을 잠깐 모아 둔다 - 글자 하나마다 파일을 쓰지 않으려고.
private System.Threading.Timer? _remoteSaveTimer;
partial void OnRemoteUrlChanged(string value)
{
QueueRemoteSave();
LoadToken(); // 서버가 바뀌면 그 서버에 맡겨 둔 토큰으로
}
partial void OnUserNameChanged(string value) => QueueRemoteSave();
private void QueueRemoteSave()
{
_remoteSaveTimer?.Dispose();
_remoteSaveTimer = new System.Threading.Timer(_ => OnUi(SaveRemoteSettings), null, 800, System.Threading.Timeout.Infinite);
}
/// Git 탭 원격 칸에 적은 주소 · 계정을 설정에 남긴다(11차 F-11 - 껐다 켜도 남는다).
private void SaveRemoteSettings()
{
var url = RemoteUrl ?? string.Empty;
var user = UserName ?? string.Empty;
if (Settings.GiteaUrl == url && Settings.GiteaUserName == user) return;
Settings.GiteaUrl = url;
Settings.GiteaUserName = user;
SaveSettings();
}
/// 지금 토큰 칸에 든 것이 어느 서버에서 가져온 것인지. 주소를 바꾸면 남의 서버로 보내지 않게 치운다.
private string? _tokenFrom;
///
/// 이 서버에 맡겨 둔 계정 · 토큰을 가져온다. 사람이 직접 적어 둔 토큰은 건드리지 않는다 -
/// 다른 서버에서 가져왔던 토큰만 치운다(다른 서버로 보내면 안 된다).
///
public void LoadToken()
{
var key = GitCredentialKey.For(RemoteUrl);
if (_tokenFrom is not null && _tokenFrom != key)
{
Password = string.Empty; // 앞 서버에서 가져온 토큰이다
_tokenFrom = null;
}
HasSavedToken = false;
var (user, token) = _tokens.Load(RemoteUrl);
if (string.IsNullOrEmpty(token)) return;
Password = token!;
_tokenFrom = key;
if (string.IsNullOrWhiteSpace(UserName) && !string.IsNullOrWhiteSpace(user)) UserName = user!;
HasSavedToken = true;
}
/// 원격 일(fetch · pull · push · 클론)이 잘 끝났다 - 그때의 계정 · 토큰을 맡겨 둔다.
public void RememberToken()
{
if (string.IsNullOrEmpty(Password)) return;
if (!_tokens.Save(RemoteUrl, UserName, Password)) return;
HasSavedToken = true;
_tokenFrom = GitCredentialKey.For(RemoteUrl);
AppendLog($"토큰을 윈도 자격 증명 관리자에 저장했습니다({GitCredentialKey.For(RemoteUrl)}). 다음에 켤 때 다시 넣지 않아도 됩니다.");
}
/// 맡겨 둔 토큰을 지운다. 화면의 토큰 칸도 비운다.
public bool ForgetToken()
{
var had = _tokens.Forget(RemoteUrl);
Password = string.Empty;
HasSavedToken = false;
_tokenFrom = null;
if (had) AppendLog($"저장한 토큰을 지웠습니다({GitCredentialKey.For(RemoteUrl)}).");
return had;
}
/// 이 서버의 토큰을 맡겨 두었는지. 화면의 안내 · [저장한 토큰 지우기] 단추가 본다.
[ObservableProperty] private bool _hasSavedToken;
/// 저장소 상태가 다시 읽힐 때마다 발생. 화면들이 자기 패널을 갱신한다.
public event Action? Refreshed;
///
/// 작업이 끝나 커맨드의 실행 가능 여부가 달라졌을 수 있음을 알린다.
/// CommunityToolkit 의 RelayCommand 는 CommandManager 를 구독하지 않아
/// 각 ViewModel 이 직접 NotifyCanExecuteChanged 를 불러야 한다.
///
public event Action? StateChanged;
// ================= 공유 목록 =================
public ObservableCollection Branches { get; } = new ObservableCollection();
public ObservableCollection Commits { get; } = new ObservableCollection();
/// 커밋 + 브랜치 그래프 레인. 홈 화면의 커밋 트리가 이걸 바인딩한다.
public ObservableCollection CommitNodes { get; } = new ObservableCollection();
public ObservableCollection StatusFiles { get; } = new ObservableCollection();
public ObservableCollection Conflicts { get; } = new ObservableCollection();
/// 이번 병합에서 푼 파일 - [다시 충돌로] 로 되돌릴 수 있는 것(11차 F-8). 병합 중이 아니면 빈다.
public ObservableCollection ResolvedConflicts { get; } = new ObservableCollection();
public ObservableCollection Tags { get; } = new ObservableCollection();
public ObservableCollection Submodules { get; } = new ObservableCollection();
/// 치워 둔 작업(stash) 목록. 새 것이 0번이다.
public ObservableCollection Stashes { get; } = new ObservableCollection();
// ================= 공유 상태 =================
[ObservableProperty] private string _repositoryPath = string.Empty;
[ObservableProperty] private string _remoteUrl = string.Empty;
[ObservableProperty] private string _remoteName = "origin";
[ObservableProperty] private string _userName = string.Empty;
[ObservableProperty] private string _password = string.Empty;
[ObservableProperty] private string _repositoryName = "-";
[ObservableProperty] private string _currentBranch = "-";
[ObservableProperty] private string _headSha = "-";
/// HEAD 의 전체 해시. 이력 목록()의 Sha 와 맞춰 볼 때 쓴다(11차 C-1).
public string? HeadFullSha { get; private set; }
[ObservableProperty] private string _workingTreeSummary = "저장소 없음";
[ObservableProperty] private bool _isWorkingTreeClean = true;
[ObservableProperty] private bool _isRepositoryOpen;
[ObservableProperty] private bool _isRemoteConnected;
/// 원격 등록 여부만 나타낸다 - 서버 접속 확인은 Fetch 를 눌러야 이뤄진다.
[ObservableProperty] private string _remoteStatusText = "원격 없음";
/// 현재 원격의 주소(clone/push 대상).
[ObservableProperty] private string _remoteUrlText = "-";
/// 추적 브랜치와 비교한 앞섬/뒤처짐. 마지막 fetch 시점 기준이다.
[ObservableProperty] private string _syncStatusText = "-";
/// 추적 브랜치와 완전히 같은 상태인지.
[ObservableProperty] private bool _isUpToDate;
[ObservableProperty] private string _lastFetchText = "fetch 이력 없음";
/// 머지 주석에 남길 작성자. 설정값 > git config user.name > 윈도우 계정 순.
[ObservableProperty] private string _authorName = Environment.UserName;
[ObservableProperty] private bool _isMergeInProgress;
/// 이번 병합에서 처음 난 충돌 파일 수(6차 C-1). 병합 중이 아니거나 모르면 null.
[ObservableProperty] private int? _mergeConflictTotal;
/// 지금 병합에서 충돌을 어떻게 풀었는지 - 병합 커밋 본문에 붙는 글(8차 F-1). 병합 중이 아니거나 적힌 것이 없으면 null.
[ObservableProperty] private string? _mergeResolutionBody;
///
/// 충돌 탭 목록 위의 진행 한 줄(6차 C-1). 예전에는 몇 개를 풀었는지 알 수 없었다.
/// 처음 충돌 수는 병합할 때 .git 에 적어 두므로 앱을 다시 켜도 이어진다.
/// MergeHub 밖에서 시작한 병합이라 처음 수를 모르면 남은 수만 적는다.
///
public string ConflictProgressText =>
// 작업마다 다 푼 뒤 할 일이 다르다 - 병합 커밋 · [계속] · [커밋](7차 C-2). 병합의 글은 예전과 같다.
OperationLabels.ProgressText(OperationState.Kind, Conflicts.Count, MergeConflictTotal, PendingHasChanges);
/// 진행 중인 작업이 커밋할 것을 들고 있는가(8차 B-2). 없으면 끝낼 수 없고 [작업 취소] 로 나간다.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ConflictProgressText))]
private bool _pendingHasChanges = true;
/// 진행 중인 git 작업과 두 쪽 이름(7차 C-2). 상태를 다시 읽을 때마다 갱신한다.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsOperationInProgress))]
[NotifyPropertyChangedFor(nameof(ConflictProgressText))]
private GitOperationState _operationState = GitOperationState.None;
///
/// 병합 · rebase · cherry-pick · revert 중 무엇이든 진행 중인지. 충돌 수와 따로 본다 -
/// 예전에는 충돌이 0 이 되면 cherry-pick 이 진행 중이라는 표시도 [작업 취소] 도 사라졌다(7차 C-2).
///
public bool IsOperationInProgress => OperationState.IsInProgress;
/// 이력에 모든 브랜치의 커밋을 보일지(7차 C-2). 다른 브랜치 커밋을 골라 cherry-pick 할 때 켠다.
[ObservableProperty] private bool _showAllBranchesInHistory;
partial void OnShowAllBranchesInHistoryChanged(bool value)
{
if (IsRepositoryOpen) _ = RefreshAsync();
}
[ObservableProperty] private bool _isRebaseInProgress;
[ObservableProperty] private string _pendingOperationText = string.Empty;
[ObservableProperty] private CommitInfo? _selectedCommit;
/// 지난 새로고침 때 "작업 중 · 충돌 0" 이었는지. 그 상태가 막 된 순간에만 선택을 HEAD 로 옮긴다(9차 A-1).
private bool _wasResolvedOperation;
/// 지난 새로고침 때의 HEAD(전체 해시). HEAD 를 보고 있다가 HEAD 가 움직이면 새 HEAD 를 따라가려고 둔다(9차 A-2).
private string? _lastHeadSha;
[ObservableProperty] private BranchInfo? _selectedBranch;
[ObservableProperty] private string _log = string.Empty;
// ================= 실행 =================
///
/// 지금 도는 Git 작업이 끝날 때까지 기다린다. 아무 일도 하지 않는다.
///
/// 는 게이트가 막혀 있으면 기다리지 않고 건너뛴다(겹쳐 돌면 안 되니까).
/// 그런데 새로고침 뒤처럼 "이건 꼭 해야 하는" 일이 그 순간 걸리면 조용히 빠진다 -
/// 병합 중 [해결로 저장] 뒤 파일 목록이 안 바뀐 것이 그랬다(6차 A-2). 그럴 때 이걸로 기다렸다 다시 한다.
///
public async Task WhenIdleAsync()
{
await _gate.WaitAsync();
_gate.Release();
}
///
/// Git 호출 하나를 백그라운드에서 직렬 실행한다.
/// 실패하면 로그 + Error 토스트를 띄우고 false 를 돌려준다.
///
public async Task RunAsync(string operation, Action action)
{
// 이미 다른 Git 작업이 도는 중이면 겹쳐 실행하지 않는다.
// 조용히 사라지면 원인을 못 찾으니 반드시 로그를 남긴다.
if (!await _gate.WaitAsync(0))
{
_logger.W($"[{operation}] 다른 작업 진행 중이라 건너뜀");
return false;
}
try
{
await Task.Run(action);
return true;
}
catch (Exception ex)
{
_logger.E($"[{operation}] 실패", ex);
OnUi(() =>
{
// 인증이 필요하다는 영어 말은 무엇을 하라는 말로 바꿔 준다(11차 F-11).
var said = GitErrors.Friendly(ex.Message) is { } friendly ? $"{friendly} ({ex.Message})" : ex.Message;
AppendLog($"[{operation}] 실패: {said}");
MessageUtil.ShowErrorToast($"{operation} 실패: {said}");
});
return false;
}
finally
{
_gate.Release();
OnUi(() => StateChanged?.Invoke());
}
}
///
/// 저장하지 않은 편집이 있으면 물어봐 달라고 파일 비교 화면이 앉혀 두는 자리.
///
/// 두 화면이 서로를 모르는 채로 이 판단을 나눠야 한다 - Git 탭은 "무슨 작업을 하려는지" 를 알고,
/// 파일 비교 탭은 "고치던 것이 있는지" 를 안다. 이 객체가 그 사이 통로다.
/// 앉은 사람이 없으면(파일 비교 탭을 아직 안 열었으면) 늘 계속한다.
///
public Func? ConfirmDiscardEdits { get; set; }
///
/// 사람이 한 탭의 이력에서 다른 커밋을 골랐다는 알림. 인자는 고른 쪽(뷰모델)이다(10차 B-1).
///
/// 고른 커밋()은 Git 탭과 파일 비교 탭이 함께 쓰는데, 예전에는 고른 쪽만 화면을 바꾸고
/// 다른 쪽은 새로고침 알림으로만 다시 읽었다. 파일 비교 탭에서 '바탕' 을 고르면 Git 탭은 '내 쪽 작업' 에 선택 표시가 남은 채
/// [Revert] 가 '바탕' 을 되돌렸다. 새로고침이 목록을 갈아 끼우며 고르는 것은 이 알림을 내지 않는다( 가 맡는다).
///
public event Action