사용자 결정(2026-09-18): 새 폴더를 만든다 · 쓰다 막히면 먼저 보고 시작한다. - Services/ExportPlan.cs (새 파일): 폴더 이름(변경분_범위_날짜) · 같은 이름이면 _2 _3 · 쓸 수 있는지 먼저 보기 · 보일 글 - GitService.ExportChangedFiles: 쓰기 전에 막힌 파일을 먼저 보고, 하나라도 있으면 폴더도 만들지 않고 멈춘다 - GitViewModel: 고른 폴더 아래 새 폴더에 뽑고 안내는 그 옆에. 알림에 폴더 이름 - 하네스 ReportTest +12 (45 → 57), 22벌 1,185건 통과, 빌드 경고 0 오류 0 - 화면 확인: 같은 폴더에 두 번 뽑아 _2 가 생기고 기존 파일(메모 · 옛 폴더)은 그대로 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2100 lines
91 KiB
C#
2100 lines
91 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using LibGit2Sharp;
|
|
using LibGit2Sharp.Handlers;
|
|
using MergeHub.Models;
|
|
|
|
namespace MergeHub.Services
|
|
{
|
|
/// <summary>
|
|
/// LibGit2Sharp 기반 Git 조작 구현.
|
|
/// libgit2 네이티브 라이브러리가 .git 의 objects/refs/packfile 을 직접 읽으므로
|
|
/// git.exe 를 매번 새 프로세스로 띄우는 방식보다 이력 조회가 빠르다. (기획안 9p)
|
|
/// </summary>
|
|
public sealed class GitService : IGitService
|
|
{
|
|
/// <summary>UTF-8 로 디코딩되지 않는 파일에 적용할 시스템 기본 코드페이지(한국어 Windows = CP949).</summary>
|
|
private static readonly Encoding AnsiEncoding;
|
|
|
|
private static readonly Encoding Utf8Strict = new UTF8Encoding(false, throwOnInvalidBytes: true);
|
|
|
|
static GitService()
|
|
{
|
|
// .NET Core 부터는 CP949 같은 레거시 코드페이지가 기본 제공되지 않아 등록이 필요하다.
|
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
|
try
|
|
{
|
|
AnsiEncoding = Encoding.GetEncoding(System.Globalization.CultureInfo.CurrentCulture.TextInfo.ANSICodePage);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
AnsiEncoding = Encoding.UTF8;
|
|
}
|
|
}
|
|
|
|
private Repository? _repo;
|
|
|
|
public bool IsOpen => _repo is not null;
|
|
|
|
public GitCredentials? Credentials { get; set; }
|
|
|
|
public bool IgnoreWhitespace { get; set; }
|
|
|
|
public bool WordLevelDiff { get; set; }
|
|
|
|
public event EventHandler<string>? Progress;
|
|
|
|
private Repository Repo =>
|
|
_repo ?? throw new InvalidOperationException("저장소가 열려 있지 않습니다. Open() 또는 Clone() 을 먼저 호출하세요.");
|
|
|
|
private void Report(string message) => Progress?.Invoke(this, message);
|
|
|
|
// ================= 저장소 열기 =================
|
|
|
|
/// <summary>지정 경로에서 위로 올라가며 .git 을 찾는다. 없으면 null.</summary>
|
|
public static string? Discover(string startPath)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(startPath)) return null;
|
|
var gitDir = Repository.Discover(startPath);
|
|
return string.IsNullOrEmpty(gitDir) ? null : gitDir;
|
|
}
|
|
|
|
public RepositoryInfo Open(string path)
|
|
{
|
|
Close();
|
|
|
|
var gitDir = Repository.Discover(path);
|
|
if (string.IsNullOrEmpty(gitDir))
|
|
throw new RepositoryNotFoundException($"'{path}' 및 상위 경로에서 Git 저장소를 찾지 못했습니다.");
|
|
|
|
_repo = new Repository(gitDir);
|
|
Report($"저장소 열기: {_repo.Info.WorkingDirectory ?? _repo.Info.Path}");
|
|
return GetRepositoryInfo();
|
|
}
|
|
|
|
public RepositoryInfo Clone(string remoteUrl, string localPath, string? branch = null)
|
|
{
|
|
Close();
|
|
|
|
var options = new CloneOptions(BuildFetchOptions())
|
|
{
|
|
Checkout = true
|
|
};
|
|
if (!string.IsNullOrWhiteSpace(branch))
|
|
options.BranchName = branch;
|
|
|
|
Report($"클론 시작: {remoteUrl} -> {localPath}");
|
|
var repoPath = Repository.Clone(remoteUrl, localPath, options);
|
|
_repo = new Repository(repoPath);
|
|
Report("클론 완료");
|
|
return GetRepositoryInfo();
|
|
}
|
|
|
|
public void Close()
|
|
{
|
|
_repo?.Dispose();
|
|
_repo = null;
|
|
}
|
|
|
|
public RepositoryInfo GetRepositoryInfo()
|
|
{
|
|
var head = Repo.Head;
|
|
return new RepositoryInfo(
|
|
WorkingDirectory: Repo.Info.WorkingDirectory ?? string.Empty,
|
|
GitDirectory: Repo.Info.Path,
|
|
CurrentBranchName: head?.FriendlyName,
|
|
HeadSha: head?.Tip?.Sha,
|
|
IsDetachedHead: Repo.Info.IsHeadDetached,
|
|
PendingOperation: Repo.Info.CurrentOperation.ToString(),
|
|
RemoteNames: Repo.Network.Remotes.Select(r => r.Name).ToList());
|
|
}
|
|
|
|
// ================= 브랜치 =================
|
|
|
|
public IReadOnlyList<BranchInfo> GetBranches(bool includeRemote = true)
|
|
{
|
|
var list = new List<BranchInfo>();
|
|
foreach (var b in Repo.Branches)
|
|
{
|
|
if (b.IsRemote && !includeRemote) continue;
|
|
|
|
list.Add(new BranchInfo(
|
|
FriendlyName: b.FriendlyName,
|
|
CanonicalName: b.CanonicalName,
|
|
IsRemote: b.IsRemote,
|
|
IsCurrentHead: b.IsCurrentRepositoryHead,
|
|
TrackedBranch: b.TrackedBranch?.FriendlyName,
|
|
AheadBy: b.TrackingDetails?.AheadBy,
|
|
BehindBy: b.TrackingDetails?.BehindBy,
|
|
TipSha: b.Tip?.Sha ?? string.Empty,
|
|
TipMessageShort: b.Tip?.MessageShort ?? string.Empty));
|
|
}
|
|
|
|
return list
|
|
.OrderByDescending(b => b.IsCurrentHead)
|
|
.ThenBy(b => b.IsRemote)
|
|
.ThenBy(b => b.FriendlyName, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
|
|
public void Checkout(string branchName, bool force = false)
|
|
{
|
|
var branch = Repo.Branches[branchName]
|
|
?? throw new ArgumentException($"브랜치를 찾을 수 없습니다: {branchName}");
|
|
|
|
if (branch.IsRemote)
|
|
{
|
|
CheckoutRemoteBranch(branchName);
|
|
return;
|
|
}
|
|
|
|
var options = new CheckoutOptions
|
|
{
|
|
CheckoutModifiers = force ? CheckoutModifiers.Force : CheckoutModifiers.None
|
|
};
|
|
Commands.Checkout(Repo, branch, options);
|
|
Report($"체크아웃: {branch.FriendlyName}");
|
|
}
|
|
|
|
public BranchInfo CreateBranch(string name, string? startPointCommittish = null, bool checkout = true)
|
|
{
|
|
var startCommit = string.IsNullOrWhiteSpace(startPointCommittish)
|
|
? Repo.Head.Tip
|
|
: ResolveCommit(startPointCommittish!);
|
|
|
|
if (startCommit is null)
|
|
throw new ArgumentException($"기준 커밋을 찾을 수 없습니다: {startPointCommittish}");
|
|
|
|
var branch = Repo.CreateBranch(name, startCommit);
|
|
if (checkout) Commands.Checkout(Repo, branch);
|
|
Report($"브랜치 생성: {name} @ {startCommit.Sha[..7]}");
|
|
|
|
return GetBranches(includeRemote: false).First(b => b.FriendlyName == branch.FriendlyName);
|
|
}
|
|
|
|
public void DeleteBranch(string name)
|
|
{
|
|
var branch = Repo.Branches[name]
|
|
?? throw new ArgumentException($"브랜치를 찾을 수 없습니다: {name}");
|
|
Repo.Branches.Remove(branch);
|
|
Report($"브랜치 삭제: {name}");
|
|
}
|
|
|
|
public BranchInfo CheckoutRemoteBranch(string remoteBranchName)
|
|
{
|
|
var remote = Repo.Branches[remoteBranchName];
|
|
if (remote is null || !remote.IsRemote)
|
|
throw new ArgumentException($"원격 브랜치를 찾을 수 없습니다: {remoteBranchName}");
|
|
|
|
// origin/feature/x -> feature/x
|
|
var localName = remoteBranchName;
|
|
var slash = localName.IndexOf('/');
|
|
if (slash > 0) localName = localName[(slash + 1)..];
|
|
|
|
var local = Repo.Branches[localName] ?? Repo.CreateBranch(localName, remote.Tip);
|
|
local = Repo.Branches.Update(local, b => b.TrackedBranch = remote.CanonicalName);
|
|
|
|
Commands.Checkout(Repo, local);
|
|
Report($"원격 브랜치 체크아웃: {remoteBranchName} -> {localName}");
|
|
|
|
return GetBranches(includeRemote: false).First(b => b.FriendlyName == local.FriendlyName);
|
|
}
|
|
|
|
// ================= 원격 =================
|
|
|
|
public IReadOnlyList<RemoteInfo> GetRemotes() =>
|
|
Repo.Network.Remotes.Select(r => new RemoteInfo(r.Name, r.Url, r.PushUrl ?? r.Url)).ToList();
|
|
|
|
public RemoteInfo AddRemote(string name, string url)
|
|
{
|
|
Remote remote;
|
|
if (Repo.Network.Remotes[name] is null)
|
|
{
|
|
remote = Repo.Network.Remotes.Add(name, url);
|
|
}
|
|
else
|
|
{
|
|
Repo.Network.Remotes.Update(name, r => r.Url = url);
|
|
remote = Repo.Network.Remotes[name];
|
|
}
|
|
|
|
Report($"원격 등록: {name} -> {url}");
|
|
return new RemoteInfo(remote.Name, remote.Url, remote.PushUrl ?? remote.Url);
|
|
}
|
|
|
|
public void RemoveRemote(string name)
|
|
{
|
|
Repo.Network.Remotes.Remove(name);
|
|
Report($"원격 삭제: {name}");
|
|
}
|
|
|
|
public void Fetch(string remoteName = "origin", bool prune = true)
|
|
{
|
|
var remote = Repo.Network.Remotes[remoteName]
|
|
?? throw new ArgumentException($"원격을 찾을 수 없습니다: {remoteName}");
|
|
|
|
var refSpecs = remote.FetchRefSpecs.Select(x => x.Specification).ToList();
|
|
var options = BuildFetchOptions();
|
|
options.Prune = prune;
|
|
|
|
Report($"fetch {remoteName} ...");
|
|
Commands.Fetch(Repo, remote.Name, refSpecs, options, $"fetch from {remote.Name}");
|
|
Report("fetch 완료");
|
|
}
|
|
|
|
public MergeResultInfo Pull(string remoteName = "origin")
|
|
{
|
|
Fetch(remoteName);
|
|
|
|
var tracked = Repo.Head.TrackedBranch;
|
|
if (tracked is null)
|
|
{
|
|
// 다른 결과처럼 여기서 로그에 적는다 - 화면은 결과 메시지를 따로 적지 않는다(6차 C-1).
|
|
var message = $"현재 브랜치 '{Repo.Head.FriendlyName}' 에 추적 중인 원격 브랜치가 없습니다.";
|
|
Report(message);
|
|
return new MergeResultInfo(MergeOutcome.Failed, null, Array.Empty<ConflictInfo>(), message);
|
|
}
|
|
|
|
return Merge(tracked.FriendlyName, commitOnSuccess: false);
|
|
}
|
|
|
|
public void Push(string? branchName = null, string remoteName = "origin")
|
|
{
|
|
var branch = string.IsNullOrWhiteSpace(branchName) ? Repo.Head : Repo.Branches[branchName];
|
|
if (branch is null)
|
|
throw new ArgumentException($"브랜치를 찾을 수 없습니다: {branchName}");
|
|
|
|
var remote = Repo.Network.Remotes[remoteName]
|
|
?? throw new ArgumentException($"원격을 찾을 수 없습니다: {remoteName}");
|
|
|
|
var options = new PushOptions { CredentialsProvider = BuildCredentialsProvider() };
|
|
var refSpec = $"{branch.CanonicalName}:{branch.CanonicalName}";
|
|
|
|
Report($"push {remoteName} {branch.FriendlyName} ...");
|
|
Repo.Network.Push(remote, refSpec, options);
|
|
|
|
if (branch.TrackedBranch is null)
|
|
{
|
|
Repo.Branches.Update(branch,
|
|
b => b.Remote = remote.Name,
|
|
b => b.UpstreamBranch = branch.CanonicalName);
|
|
}
|
|
Report("push 완료");
|
|
}
|
|
|
|
// ================= 히스토리 =================
|
|
|
|
public IReadOnlyList<CommitInfo> GetCommits(string? committish = null, int maxCount = 200, bool allBranches = false)
|
|
{
|
|
var refsByTarget = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
|
foreach (var branch in Repo.Branches)
|
|
{
|
|
var sha = branch.Tip?.Sha;
|
|
if (sha is null) continue;
|
|
if (!refsByTarget.TryGetValue(sha, out var names))
|
|
refsByTarget[sha] = names = new List<string>();
|
|
names.Add(branch.FriendlyName);
|
|
}
|
|
foreach (var tag in Repo.Tags)
|
|
{
|
|
var sha = tag.Target?.Sha;
|
|
if (sha is null) continue;
|
|
if (!refsByTarget.TryGetValue(sha, out var names))
|
|
refsByTarget[sha] = names = new List<string>();
|
|
names.Add($"tag: {tag.FriendlyName}");
|
|
}
|
|
|
|
var filter = new CommitFilter
|
|
{
|
|
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(committish))
|
|
{
|
|
var start = ResolveCommit(committish!)
|
|
?? throw new ArgumentException($"커밋/브랜치를 찾을 수 없습니다: {committish}");
|
|
filter.IncludeReachableFrom = start;
|
|
}
|
|
else if (allBranches)
|
|
{
|
|
// 모든 브랜치(원격 추적 포함)와 HEAD 에서 닿는 커밋. 다른 브랜치의 커밋을 골라 cherry-pick 하려면 필요하다(7차 C-2).
|
|
// 넣지 않으면 libgit2 는 HEAD 에서 닿는 것만 준다.
|
|
var tips = Repo.Branches.Select(b => b.Tip).Where(t => t is not null).ToList();
|
|
if (Repo.Head?.Tip is { } headTip) tips.Add(headTip);
|
|
if (tips.Count > 0) filter.IncludeReachableFrom = tips;
|
|
}
|
|
|
|
var result = new List<CommitInfo>(Math.Min(maxCount, 256));
|
|
foreach (var c in Repo.Commits.QueryBy(filter))
|
|
{
|
|
result.Add(ToCommitInfo(c, refsByTarget));
|
|
if (result.Count >= maxCount) break;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
public CommitInfo? GetCommit(string committish)
|
|
{
|
|
var c = ResolveCommit(committish);
|
|
return c is null ? null : ToCommitInfo(c, null);
|
|
}
|
|
|
|
public string? GetMergeBaseSha(string committishA, string committishB)
|
|
{
|
|
var a = ResolveCommit(committishA);
|
|
var b = ResolveCommit(committishB);
|
|
if (a is null || b is null) return null;
|
|
return Repo.ObjectDatabase.FindMergeBase(a, b)?.Sha;
|
|
}
|
|
|
|
public string GetFileTextAtCommit(string committish, string path)
|
|
{
|
|
var commit = ResolveCommit(committish)
|
|
?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}");
|
|
|
|
var entry = commit[NormalizePath(path)];
|
|
if (entry is null) return string.Empty;
|
|
return entry.Target is Blob blob ? ReadBlobText(blob, NormalizePath(path)) : string.Empty;
|
|
}
|
|
|
|
public IReadOnlyList<string> GetFilesAtCommit(string committish)
|
|
{
|
|
var commit = ResolveCommit(committish)
|
|
?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}");
|
|
|
|
var files = new List<string>();
|
|
CollectTree(commit.Tree, files);
|
|
files.Sort(StringComparer.OrdinalIgnoreCase);
|
|
return files;
|
|
}
|
|
|
|
private static void CollectTree(Tree tree, List<string> into)
|
|
{
|
|
foreach (var entry in tree)
|
|
{
|
|
if (entry.TargetType == TreeEntryTargetType.Tree)
|
|
CollectTree((Tree)entry.Target, into);
|
|
else if (entry.TargetType == TreeEntryTargetType.Blob)
|
|
into.Add(entry.Path.Replace('\\', '/'));
|
|
}
|
|
}
|
|
|
|
// ================= 상태 / 스테이징 =================
|
|
|
|
public RepositoryStatusInfo GetStatus(bool includeUntracked = true)
|
|
{
|
|
var options = new StatusOptions
|
|
{
|
|
IncludeUntracked = includeUntracked,
|
|
RecurseUntrackedDirs = includeUntracked,
|
|
DetectRenamesInIndex = true,
|
|
DetectRenamesInWorkDir = true
|
|
};
|
|
|
|
var files = new List<FileStatusInfo>();
|
|
foreach (var entry in Repo.RetrieveStatus(options))
|
|
{
|
|
if (entry.State == FileStatus.Ignored) continue;
|
|
|
|
var conflicted = entry.State.HasFlag(FileStatus.Conflicted);
|
|
files.Add(new FileStatusInfo(
|
|
Path: entry.FilePath.Replace('\\', '/'),
|
|
StagedState: MapStaged(entry.State),
|
|
WorkingDirState: MapWorkingDir(entry.State),
|
|
IsConflicted: conflicted));
|
|
}
|
|
|
|
files.Sort((a, b) => string.Compare(a.Path, b.Path, StringComparison.OrdinalIgnoreCase));
|
|
|
|
return new RepositoryStatusInfo(
|
|
IsClean: files.Count == 0,
|
|
StagedCount: files.Count(f => f.IsStaged && !f.IsConflicted),
|
|
ModifiedCount: files.Count(f => f.WorkingDirState is WorkingFileState.Modified
|
|
or WorkingFileState.Deleted or WorkingFileState.Renamed or WorkingFileState.TypeChanged),
|
|
UntrackedCount: files.Count(f => f.WorkingDirState == WorkingFileState.Untracked),
|
|
ConflictedCount: files.Count(f => f.IsConflicted),
|
|
Files: files);
|
|
}
|
|
|
|
private static WorkingFileState MapStaged(FileStatus s)
|
|
{
|
|
if (s.HasFlag(FileStatus.Conflicted)) return WorkingFileState.Conflicted;
|
|
if (s.HasFlag(FileStatus.NewInIndex)) return WorkingFileState.Added;
|
|
if (s.HasFlag(FileStatus.ModifiedInIndex)) return WorkingFileState.Modified;
|
|
if (s.HasFlag(FileStatus.DeletedFromIndex)) return WorkingFileState.Deleted;
|
|
if (s.HasFlag(FileStatus.RenamedInIndex)) return WorkingFileState.Renamed;
|
|
if (s.HasFlag(FileStatus.TypeChangeInIndex)) return WorkingFileState.TypeChanged;
|
|
return WorkingFileState.Unmodified;
|
|
}
|
|
|
|
private static WorkingFileState MapWorkingDir(FileStatus s)
|
|
{
|
|
if (s.HasFlag(FileStatus.Conflicted)) return WorkingFileState.Conflicted;
|
|
if (s.HasFlag(FileStatus.NewInWorkdir)) return WorkingFileState.Untracked;
|
|
if (s.HasFlag(FileStatus.ModifiedInWorkdir)) return WorkingFileState.Modified;
|
|
if (s.HasFlag(FileStatus.DeletedFromWorkdir)) return WorkingFileState.Deleted;
|
|
if (s.HasFlag(FileStatus.RenamedInWorkdir)) return WorkingFileState.Renamed;
|
|
if (s.HasFlag(FileStatus.TypeChangeInWorkdir)) return WorkingFileState.TypeChanged;
|
|
return WorkingFileState.Unmodified;
|
|
}
|
|
|
|
public void Stage(params string[] paths)
|
|
{
|
|
if (paths is null || paths.Length == 0) return;
|
|
Commands.Stage(Repo, paths.Select(NormalizePath));
|
|
}
|
|
|
|
/// <summary>
|
|
/// git add -A.
|
|
/// 와일드카드("*") 를 그대로 넘기면 서브모듈 디렉터리에서 libgit2 가 'invalid path' 로 실패하므로,
|
|
/// 변경된 경로를 직접 모아서 스테이징한다.
|
|
/// </summary>
|
|
public void StageAll()
|
|
{
|
|
var options = new StatusOptions
|
|
{
|
|
IncludeUntracked = true,
|
|
RecurseUntrackedDirs = true,
|
|
IncludeIgnored = false
|
|
};
|
|
|
|
var paths = new List<string>();
|
|
foreach (var entry in Repo.RetrieveStatus(options))
|
|
{
|
|
if (entry.State is FileStatus.Unaltered or FileStatus.Ignored) continue;
|
|
paths.Add(entry.FilePath);
|
|
}
|
|
|
|
if (paths.Count == 0) return;
|
|
Commands.Stage(Repo, paths);
|
|
}
|
|
|
|
public void Unstage(params string[] paths)
|
|
{
|
|
if (paths is null || paths.Length == 0) return;
|
|
Commands.Unstage(Repo, paths.Select(NormalizePath));
|
|
}
|
|
|
|
public string Commit(string message, bool amend = false)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(message))
|
|
throw new ArgumentException("커밋 메시지가 비어 있습니다.", nameof(message));
|
|
|
|
var signature = BuildSignature();
|
|
var commit = Repo.Commit(message, signature, signature, new CommitOptions { AmendPreviousCommit = amend });
|
|
Report($"커밋: {commit.Sha[..7]} {commit.MessageShort}");
|
|
return commit.Sha;
|
|
}
|
|
|
|
// ================= 병합 =================
|
|
|
|
public bool IsMergeInProgress =>
|
|
Repo.Info.CurrentOperation == CurrentOperation.Merge || Repo.Index.Conflicts.Any();
|
|
|
|
public MergeResultInfo Merge(string branchName, bool commitOnSuccess = false, bool allowFastForward = true)
|
|
{
|
|
var branch = Repo.Branches[branchName];
|
|
Commit? target = branch?.Tip ?? ResolveCommit(branchName);
|
|
if (target is null)
|
|
throw new ArgumentException($"병합 대상을 찾을 수 없습니다: {branchName}");
|
|
|
|
if (GetConflicts().Count > 0)
|
|
return new MergeResultInfo(MergeOutcome.Conflicts, null, GetConflicts(),
|
|
"이미 해결되지 않은 충돌이 있습니다. 먼저 처리하세요.");
|
|
|
|
var options = new MergeOptions
|
|
{
|
|
CommitOnSuccess = commitOnSuccess,
|
|
FastForwardStrategy = allowFastForward ? FastForwardStrategy.Default : FastForwardStrategy.NoFastForward,
|
|
// 충돌 시 <<<<<<< ======= >>>>>>> 마커가 들어간 파일을 워킹트리에 남긴다.
|
|
MergeFileFavor = MergeFileFavor.Normal,
|
|
FileConflictStrategy = CheckoutFileConflictStrategy.Merge,
|
|
FindRenames = true
|
|
};
|
|
|
|
Report($"병합 시작: {branchName} -> {Repo.Head.FriendlyName}");
|
|
var result = Repo.Merge(target, BuildSignature(), options);
|
|
|
|
var outcome = result.Status switch
|
|
{
|
|
MergeStatus.UpToDate => MergeOutcome.UpToDate,
|
|
MergeStatus.FastForward => MergeOutcome.FastForward,
|
|
MergeStatus.NonFastForward => MergeOutcome.Merged,
|
|
MergeStatus.Conflicts => MergeOutcome.Conflicts,
|
|
_ => MergeOutcome.Failed
|
|
};
|
|
|
|
var conflicts = outcome == MergeOutcome.Conflicts ? GetConflicts() : Array.Empty<ConflictInfo>();
|
|
if (outcome == MergeOutcome.Conflicts) RememberMergeConflicts(conflicts);
|
|
|
|
var message = outcome switch
|
|
{
|
|
MergeOutcome.UpToDate => "이미 최신 상태입니다.",
|
|
MergeOutcome.FastForward => $"Fast-forward 완료 ({Shorten(result.Commit?.Sha)})",
|
|
MergeOutcome.Merged => commitOnSuccess
|
|
? $"병합 커밋 생성 ({Shorten(result.Commit?.Sha)})"
|
|
: "충돌 없이 병합됨 - 확인 후 CommitMerge() 를 호출하세요.",
|
|
MergeOutcome.Conflicts => $"충돌 {conflicts.Count}건 - 해결이 필요합니다.",
|
|
_ => "병합에 실패했습니다."
|
|
};
|
|
|
|
Report(message);
|
|
return new MergeResultInfo(outcome, result.Commit?.Sha, conflicts, message);
|
|
}
|
|
|
|
public void AbortMerge() => AbortOperation();
|
|
|
|
public void AbortOperation()
|
|
{
|
|
if (Repo.Info.CurrentOperation is CurrentOperation.Rebase or CurrentOperation.RebaseInteractive or CurrentOperation.RebaseMerge)
|
|
{
|
|
Repo.Rebase.Abort();
|
|
Report("rebase 취소");
|
|
return;
|
|
}
|
|
|
|
// libgit2 의 reset(HARD) 이 MERGE_HEAD / CHERRY_PICK_HEAD / REVERT_HEAD 까지 정리한다.
|
|
Repo.Reset(ResetMode.Hard, Repo.Head.Tip);
|
|
ForgetStaleMergeConflicts(); // MERGE_HEAD 가 없어졌으니 처음 충돌 목록도 치운다(7차 B-1)
|
|
Report("작업 취소 (reset --hard HEAD)");
|
|
}
|
|
|
|
// ================= 되돌리기 =================
|
|
|
|
public void Reset(ResetKind kind, string committish = "HEAD")
|
|
{
|
|
var target = ResolveCommit(committish)
|
|
?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}");
|
|
|
|
var mode = kind switch
|
|
{
|
|
ResetKind.Soft => ResetMode.Soft,
|
|
ResetKind.Mixed => ResetMode.Mixed,
|
|
_ => ResetMode.Hard
|
|
};
|
|
|
|
Repo.Reset(mode, target);
|
|
Report($"reset --{kind.ToString().ToLowerInvariant()} {Shorten(target.Sha)}");
|
|
}
|
|
|
|
public void RestoreFiles(IEnumerable<string> paths, string committish = "HEAD")
|
|
{
|
|
var list = paths.Select(NormalizePath).ToArray();
|
|
if (list.Length == 0) return;
|
|
|
|
Repo.CheckoutPaths(committish, list, new CheckoutOptions
|
|
{
|
|
CheckoutModifiers = CheckoutModifiers.Force
|
|
});
|
|
Report($"파일 복원({committish}): {string.Join(", ", list)}");
|
|
}
|
|
|
|
public PatchApplyResult Revert(string committish, bool commitOnSuccess = false, int mainline = 0)
|
|
{
|
|
var commit = ResolveCommit(committish)
|
|
?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}");
|
|
|
|
Report($"revert 시작: {Shorten(commit.Sha)} {commit.MessageShort}");
|
|
var result = Repo.Revert(commit, BuildSignature(), new RevertOptions
|
|
{
|
|
CommitOnSuccess = commitOnSuccess,
|
|
Mainline = mainline,
|
|
FileConflictStrategy = CheckoutFileConflictStrategy.Merge,
|
|
FindRenames = true
|
|
});
|
|
|
|
var outcome = result.Status switch
|
|
{
|
|
RevertStatus.Reverted => PatchApplyOutcome.Applied,
|
|
RevertStatus.Conflicts => PatchApplyOutcome.Conflicts,
|
|
RevertStatus.NothingToRevert => PatchApplyOutcome.NothingToDo,
|
|
_ => PatchApplyOutcome.Failed
|
|
};
|
|
|
|
return BuildPatchResult(outcome, result.Commit?.Sha, commitOnSuccess, "revert");
|
|
}
|
|
|
|
public PatchApplyResult CherryPick(string committish, bool commitOnSuccess = false, int mainline = 0)
|
|
{
|
|
var commit = ResolveCommit(committish)
|
|
?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}");
|
|
|
|
Report($"cherry-pick 시작: {Shorten(commit.Sha)} {commit.MessageShort}");
|
|
var result = Repo.CherryPick(commit, BuildSignature(), new CherryPickOptions
|
|
{
|
|
CommitOnSuccess = commitOnSuccess,
|
|
Mainline = mainline,
|
|
FileConflictStrategy = CheckoutFileConflictStrategy.Merge,
|
|
FindRenames = true
|
|
});
|
|
|
|
var outcome = result.Status switch
|
|
{
|
|
CherryPickStatus.CherryPicked => PatchApplyOutcome.Applied,
|
|
CherryPickStatus.Conflicts => PatchApplyOutcome.Conflicts,
|
|
_ => PatchApplyOutcome.Failed
|
|
};
|
|
|
|
return BuildPatchResult(outcome, result.Commit?.Sha, commitOnSuccess, "cherry-pick");
|
|
}
|
|
|
|
private PatchApplyResult BuildPatchResult(PatchApplyOutcome outcome, string? sha, bool commitOnSuccess, string operation)
|
|
{
|
|
var conflicts = outcome == PatchApplyOutcome.Conflicts ? GetConflicts() : Array.Empty<ConflictInfo>();
|
|
|
|
var message = outcome switch
|
|
{
|
|
// 사람이 읽는 로그다 - 예전에는 "Commit() 을 호출하세요" 처럼 코드 이름을 적었다(7차 C-2).
|
|
PatchApplyOutcome.Applied => commitOnSuccess
|
|
? $"{operation} 완료 ({Shorten(sha)})"
|
|
// 이미 들어 있는 커밋을 가져오면 적용은 됐는데 커밋할 것이 없다(8차 B-2).
|
|
: HasStagedChanges()
|
|
? $"{operation} 적용됨 - 확인한 뒤 충돌 탭의 [커밋] 으로 끝냅니다."
|
|
: $"{operation} 했지만 {(operation == "revert" ? "되돌릴" : "가져올")} 변경이 없습니다 - [작업 취소] 로 끝냅니다.",
|
|
PatchApplyOutcome.Conflicts => $"{operation} 충돌 {conflicts.Count}건 - 풀고 나서 충돌 탭의 [커밋] 으로 끝냅니다.",
|
|
PatchApplyOutcome.NothingToDo => $"{operation} 할 변경이 없습니다.",
|
|
_ => $"{operation} 실패"
|
|
};
|
|
|
|
Report(message);
|
|
return new PatchApplyResult(outcome, sha, conflicts, message);
|
|
}
|
|
|
|
// ================= rebase =================
|
|
|
|
public RebaseResultInfo RebaseStart(string upstreamBranch, string? branch = null, string? onto = null)
|
|
{
|
|
var upstream = Repo.Branches[upstreamBranch]
|
|
?? throw new ArgumentException($"upstream 브랜치를 찾을 수 없습니다: {upstreamBranch}");
|
|
|
|
var target = string.IsNullOrWhiteSpace(branch) ? Repo.Head : Repo.Branches[branch];
|
|
if (target is null) throw new ArgumentException($"브랜치를 찾을 수 없습니다: {branch}");
|
|
|
|
var ontoBranch = string.IsNullOrWhiteSpace(onto) ? upstream : Repo.Branches[onto];
|
|
if (ontoBranch is null) throw new ArgumentException($"onto 브랜치를 찾을 수 없습니다: {onto}");
|
|
|
|
Report($"rebase 시작: {target.FriendlyName} onto {ontoBranch.FriendlyName}");
|
|
var result = Repo.Rebase.Start(target, upstream, ontoBranch, BuildIdentity(), new RebaseOptions
|
|
{
|
|
FileConflictStrategy = CheckoutFileConflictStrategy.Merge
|
|
});
|
|
|
|
return BuildRebaseResult(result);
|
|
}
|
|
|
|
public RebaseResultInfo RebaseContinue()
|
|
{
|
|
if (GetConflicts().Count > 0)
|
|
throw new InvalidOperationException("해결되지 않은 충돌이 남아 있습니다.");
|
|
|
|
var result = Repo.Rebase.Continue(BuildIdentity(), new RebaseOptions
|
|
{
|
|
FileConflictStrategy = CheckoutFileConflictStrategy.Merge
|
|
});
|
|
|
|
return BuildRebaseResult(result);
|
|
}
|
|
|
|
public void RebaseAbort()
|
|
{
|
|
Repo.Rebase.Abort();
|
|
Report("rebase 취소");
|
|
}
|
|
|
|
public RebaseStatusInfo GetRebaseStatus()
|
|
{
|
|
var inProgress = Repo.Info.CurrentOperation
|
|
is CurrentOperation.Rebase or CurrentOperation.RebaseInteractive or CurrentOperation.RebaseMerge;
|
|
|
|
if (!inProgress) return new RebaseStatusInfo(false, 0, 0, null, null);
|
|
|
|
var step = Repo.Rebase.GetCurrentStepInfo();
|
|
return new RebaseStatusInfo(
|
|
IsInProgress: true,
|
|
CurrentStep: (int)(Repo.Rebase.GetCurrentStepIndex() + 1),
|
|
TotalSteps: (int)Repo.Rebase.GetTotalStepCount(),
|
|
CurrentCommitSha: step?.Commit?.Sha,
|
|
CurrentMessage: step?.Commit?.MessageShort);
|
|
}
|
|
|
|
private RebaseResultInfo BuildRebaseResult(RebaseResult result)
|
|
{
|
|
var outcome = result.Status switch
|
|
{
|
|
RebaseStatus.Complete => RebaseOutcome.Completed,
|
|
RebaseStatus.Conflicts => RebaseOutcome.Conflicts,
|
|
RebaseStatus.Stop => RebaseOutcome.Stopped,
|
|
_ => RebaseOutcome.Failed
|
|
};
|
|
|
|
if (outcome == RebaseOutcome.Completed && result.TotalStepCount == 0)
|
|
outcome = RebaseOutcome.NothingToDo;
|
|
|
|
var conflicts = outcome == RebaseOutcome.Conflicts ? GetConflicts() : Array.Empty<ConflictInfo>();
|
|
|
|
var message = outcome switch
|
|
{
|
|
RebaseOutcome.Completed => $"rebase 완료 ({result.CompletedStepCount}/{result.TotalStepCount} 단계)",
|
|
RebaseOutcome.NothingToDo => "재배치할 커밋이 없습니다.",
|
|
RebaseOutcome.Conflicts =>
|
|
$"rebase {result.CompletedStepCount + 1}/{result.TotalStepCount} 단계에서 충돌 {conflicts.Count}건 " +
|
|
"- 풀고 나서 [계속] 을 누르세요.",
|
|
RebaseOutcome.Stopped => $"rebase 정지 ({result.CompletedStepCount}/{result.TotalStepCount} 단계)",
|
|
_ => "rebase 실패"
|
|
};
|
|
|
|
Report(message);
|
|
return new RebaseResultInfo(outcome, (int)result.CompletedStepCount, (int)result.TotalStepCount, conflicts, message);
|
|
}
|
|
|
|
// ================= 태그 =================
|
|
|
|
public IReadOnlyList<TagInfo> GetTags()
|
|
{
|
|
var list = new List<TagInfo>();
|
|
foreach (var tag in Repo.Tags)
|
|
{
|
|
list.Add(new TagInfo(
|
|
Name: tag.FriendlyName,
|
|
TargetSha: tag.Target?.Sha ?? string.Empty,
|
|
IsAnnotated: tag.IsAnnotated,
|
|
Message: tag.Annotation?.Message?.Trim(),
|
|
TaggerName: tag.Annotation?.Tagger?.Name,
|
|
When: tag.Annotation?.Tagger?.When));
|
|
}
|
|
list.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
|
return list;
|
|
}
|
|
|
|
public TagInfo CreateTag(string name, string? committish = null, string? message = null)
|
|
{
|
|
var target = ResolveCommit(committish ?? "HEAD")
|
|
?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}");
|
|
|
|
var tag = string.IsNullOrWhiteSpace(message)
|
|
? Repo.Tags.Add(name, target)
|
|
: Repo.Tags.Add(name, target, BuildSignature(), message);
|
|
|
|
Report($"태그 생성: {name} @ {Shorten(target.Sha)}{(tag.IsAnnotated ? " (annotated)" : "")}");
|
|
|
|
return new TagInfo(
|
|
Name: tag.FriendlyName,
|
|
TargetSha: tag.Target?.Sha ?? target.Sha,
|
|
IsAnnotated: tag.IsAnnotated,
|
|
Message: tag.Annotation?.Message?.Trim(),
|
|
TaggerName: tag.Annotation?.Tagger?.Name,
|
|
When: tag.Annotation?.Tagger?.When);
|
|
}
|
|
|
|
public void DeleteTag(string name)
|
|
{
|
|
Repo.Tags.Remove(name);
|
|
Report($"태그 삭제: {name}");
|
|
}
|
|
|
|
// ================= 서브모듈 =================
|
|
|
|
public IReadOnlyList<SubmoduleInfo> GetSubmodules()
|
|
{
|
|
var list = new List<SubmoduleInfo>();
|
|
foreach (var sub in Repo.Submodules)
|
|
{
|
|
list.Add(new SubmoduleInfo(
|
|
Name: sub.Name,
|
|
Path: sub.Path.Replace('\\', '/'),
|
|
Url: sub.Url ?? string.Empty,
|
|
HeadSha: sub.HeadCommitId?.Sha,
|
|
IndexSha: sub.IndexCommitId?.Sha,
|
|
WorkDirSha: sub.WorkDirCommitId?.Sha,
|
|
Status: sub.RetrieveStatus().ToString()));
|
|
}
|
|
list.Sort((a, b) => string.Compare(a.Path, b.Path, StringComparison.OrdinalIgnoreCase));
|
|
return list;
|
|
}
|
|
|
|
public void UpdateSubmodule(string name, bool init = true)
|
|
{
|
|
var options = new SubmoduleUpdateOptions { Init = init };
|
|
options.FetchOptions.CredentialsProvider = BuildCredentialsProvider();
|
|
Repo.Submodules.Update(name, options);
|
|
Report($"서브모듈 업데이트: {name}");
|
|
}
|
|
|
|
public void UpdateAllSubmodules(bool init = true)
|
|
{
|
|
foreach (var sub in Repo.Submodules) UpdateSubmodule(sub.Name, init);
|
|
}
|
|
|
|
private Identity BuildIdentity()
|
|
{
|
|
var signature = BuildSignature();
|
|
return new Identity(signature.Name, signature.Email);
|
|
}
|
|
|
|
public IReadOnlyList<ConflictInfo> GetConflicts()
|
|
{
|
|
var list = new List<ConflictInfo>();
|
|
foreach (var conflict in Repo.Index.Conflicts)
|
|
{
|
|
var entry = conflict.Ours ?? conflict.Theirs ?? conflict.Ancestor;
|
|
if (entry is null) continue;
|
|
|
|
var isBinary =
|
|
IsBinaryEntry(conflict.Ours) ||
|
|
IsBinaryEntry(conflict.Theirs) ||
|
|
IsBinaryEntry(conflict.Ancestor);
|
|
|
|
list.Add(new ConflictInfo(
|
|
Path: entry.Path.Replace('\\', '/'),
|
|
HasBase: conflict.Ancestor is not null,
|
|
HasOurs: conflict.Ours is not null,
|
|
HasTheirs: conflict.Theirs is not null,
|
|
IsBinary: isBinary));
|
|
}
|
|
list.Sort((a, b) => string.Compare(a.Path, b.Path, StringComparison.OrdinalIgnoreCase));
|
|
return list;
|
|
}
|
|
|
|
/// <summary>병합 때 충돌 파일 목록을 적어 두는 파일(.git 안). 첫 줄은 MERGE_HEAD, 나머지는 경로.</summary>
|
|
private const string MergeConflictListFile = "MERGEHUB_CONFLICTS";
|
|
|
|
/// <summary>
|
|
/// 이번 병합에서 처음 난 충돌 파일을 적어 둔다(6차 C-1). 몇 개를 풀었는지 세려고 둔다 - 앱을 다시 켜도 이어진다.
|
|
///
|
|
/// git 인덱스의 해결 기록(REUC)으로 세면 안 된다. libgit2 는 <b>저절로 병합된 파일</b>도 거기 남겨서,
|
|
/// 충돌 26개 중 머지 주석 2개를 풀었을 뿐인데 "푼 것 9개 (9/33)" 로 나왔다.
|
|
/// </summary>
|
|
private void RememberMergeConflicts(IReadOnlyList<ConflictInfo> conflicts)
|
|
{
|
|
var head = ReadMergeHeadSha();
|
|
if (string.IsNullOrEmpty(head)) return;
|
|
|
|
var lines = new List<string> { head! };
|
|
lines.AddRange(conflicts.Select(c => c.Path));
|
|
File.WriteAllLines(Path.Combine(Repo.Info.Path, MergeConflictListFile), lines, new UTF8Encoding(false));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 이번 병합에서 처음 난 충돌 파일 수(6차 C-1). 적어 둔 것이 없거나 다른 병합의 것이면 null -
|
|
/// MergeHub 밖에서 시작한 병합은 처음 수를 모른다.
|
|
/// </summary>
|
|
public int? GetMergeConflictTotal()
|
|
{
|
|
var path = Path.Combine(Repo.Info.Path, MergeConflictListFile);
|
|
var head = ReadMergeHeadSha();
|
|
if (string.IsNullOrEmpty(head) || !File.Exists(path)) return null;
|
|
|
|
var lines = File.ReadAllLines(path);
|
|
if (lines.Length == 0 || !string.Equals(lines[0].Trim(), head, StringComparison.OrdinalIgnoreCase)) return null;
|
|
|
|
return lines.Skip(1).Count(l => l.Trim().Length > 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 쓸모없어진 충돌 목록 파일을 지운다(7차 B-1). 지웠으면 true.
|
|
/// 병합 커밋 · 작업 취소 직후와 상태를 다시 읽을 때마다 부른다 - 터미널에서 병합을 끝낸 경우도 다음 새로고침에 치운다.
|
|
/// 못 지워도(잠김 등) 다른 일을 막지 않는다. 다음에 다시 시도한다.
|
|
/// </summary>
|
|
public bool ForgetStaleMergeConflicts()
|
|
{
|
|
// 해결 기록(8차 F-1)도 같은 규칙으로 치운다 - 첫 줄이 지금 MERGE_HEAD 가 아니면 쓸모없다.
|
|
ForgetStaleRecord(MergeResolutionLog.FileName);
|
|
|
|
var path = Path.Combine(Repo.Info.Path, MergeConflictListFile);
|
|
if (!File.Exists(path)) return false;
|
|
|
|
try
|
|
{
|
|
var first = File.ReadAllLines(path).FirstOrDefault();
|
|
if (!MergeConflictRecord.IsStale(ReadMergeHeadSha(), first)) return false;
|
|
|
|
File.Delete(path);
|
|
return true;
|
|
}
|
|
catch (IOException) { return false; }
|
|
catch (UnauthorizedAccessException) { return false; }
|
|
}
|
|
|
|
private void ForgetStaleRecord(string fileName)
|
|
{
|
|
var path = Path.Combine(Repo.Info.Path, fileName);
|
|
if (!File.Exists(path)) return;
|
|
|
|
try
|
|
{
|
|
var first = File.ReadAllLines(path).FirstOrDefault();
|
|
if (MergeConflictRecord.IsStale(ReadMergeHeadSha(), first)) File.Delete(path);
|
|
}
|
|
catch (IOException) { }
|
|
catch (UnauthorizedAccessException) { }
|
|
}
|
|
|
|
/// <summary>병합 커밋 본문에 "누가" 로 적을 이름(8차 F-1). 작업 공간이 상태를 읽을 때 채운다.</summary>
|
|
public string? ResolverName { get; set; }
|
|
|
|
/// <summary>
|
|
/// 병합 충돌 하나를 어떻게 풀었는지 적어 둔다(8차 F-1). 병합 중이 아니면(cherry-pick · revert · rebase) 적지 않는다.
|
|
/// 적는 데 실패해도 푸는 일은 막지 않는다 - 기록은 덤이다.
|
|
/// </summary>
|
|
public void RecordResolution(string path, MergeResolutionKind kind)
|
|
{
|
|
var head = ReadMergeHeadSha();
|
|
if (string.IsNullOrEmpty(head) || string.IsNullOrWhiteSpace(path)) return;
|
|
|
|
try
|
|
{
|
|
var file = Path.Combine(Repo.Info.Path, MergeResolutionLog.FileName);
|
|
var existing = File.Exists(file) ? File.ReadAllText(file) : null;
|
|
File.WriteAllText(file, MergeResolutionLog.Record(existing, head!, NormalizePath(path), kind), new UTF8Encoding(false));
|
|
}
|
|
catch (IOException) { }
|
|
catch (UnauthorizedAccessException) { }
|
|
}
|
|
|
|
/// <summary>지금 병합의 해결 내역 본문(8차 F-1). 적힌 것이 없으면 null.</summary>
|
|
public string? GetResolutionBody()
|
|
{
|
|
var head = ReadMergeHeadSha();
|
|
if (string.IsNullOrEmpty(head)) return null;
|
|
|
|
try
|
|
{
|
|
var file = Path.Combine(Repo.Info.Path, MergeResolutionLog.FileName);
|
|
if (!File.Exists(file)) return null;
|
|
return MergeResolutionLog.Body(MergeResolutionLog.Parse(File.ReadAllText(file), head), ResolverName);
|
|
}
|
|
catch (IOException) { return null; }
|
|
catch (UnauthorizedAccessException) { return null; }
|
|
}
|
|
|
|
public ThreeWayContent GetThreeWayContent(string path)
|
|
{
|
|
var normalized = NormalizePath(path);
|
|
var conflict = Repo.Index.Conflicts[normalized]
|
|
?? throw new ArgumentException($"충돌 항목이 아닙니다: {path}");
|
|
|
|
var baseBlob = LookupBlob(conflict.Ancestor);
|
|
var ourBlob = LookupBlob(conflict.Ours);
|
|
var theirBlob = LookupBlob(conflict.Theirs);
|
|
|
|
var isBinary = (baseBlob?.IsBinary ?? false) || (ourBlob?.IsBinary ?? false) || (theirBlob?.IsBinary ?? false);
|
|
|
|
return new ThreeWayContent(
|
|
Path: normalized,
|
|
BaseText: isBinary ? string.Empty : ReadBlobText(baseBlob, normalized),
|
|
OursText: isBinary ? string.Empty : ReadBlobText(ourBlob, normalized),
|
|
TheirsText: isBinary ? string.Empty : ReadBlobText(theirBlob, normalized),
|
|
IsBinary: isBinary);
|
|
}
|
|
|
|
public void ResolveConflict(string path, ConflictResolution resolution)
|
|
{
|
|
var normalized = NormalizePath(path);
|
|
var conflict = Repo.Index.Conflicts[normalized]
|
|
?? throw new ArgumentException($"충돌 항목이 아닙니다: {path}");
|
|
|
|
var chosen = resolution switch
|
|
{
|
|
ConflictResolution.Mine => conflict.Ours,
|
|
ConflictResolution.Theirs => conflict.Theirs,
|
|
ConflictResolution.Base => conflict.Ancestor,
|
|
_ => null
|
|
};
|
|
|
|
var fullPath = Path.Combine(Repo.Info.WorkingDirectory, normalized.Replace('/', Path.DirectorySeparatorChar));
|
|
var directory = Path.GetDirectoryName(fullPath);
|
|
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
|
|
|
if (resolution == ConflictResolution.Both)
|
|
{
|
|
// 양쪽 파일을 읽는다. 한쪽이 없으면 빈 것으로 본다.
|
|
var oursBlob = LookupBlob(conflict.Ours);
|
|
var theirsBlob = LookupBlob(conflict.Theirs);
|
|
var ours = oursBlob is null ? Array.Empty<byte>() : ReadBlobBytes(oursBlob, normalized);
|
|
var theirs = theirsBlob is null ? Array.Empty<byte>() : ReadBlobBytes(theirsBlob, normalized);
|
|
|
|
// 바이트째 이어 붙이면 결과 한가운데에 BOM 이 하나 더 들어가고 줄끝이 섞였다(6차 A-5).
|
|
// 두 벌을 글로 풀어 이어 붙인 뒤 내 쪽 형식(인코딩 · BOM · 줄끝)으로 다시 쓴다.
|
|
File.WriteAllBytes(fullPath, BothJoin.Join(ours, theirs));
|
|
}
|
|
else if (chosen is null)
|
|
{
|
|
// 채택한 쪽에 파일이 없음 = 삭제를 채택
|
|
if (File.Exists(fullPath)) File.Delete(fullPath);
|
|
Commands.Remove(Repo, normalized, removeFromWorkingDirectory: false);
|
|
Report($"충돌 해결({resolution}): {normalized} (삭제 채택)");
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
var blob = LookupBlob(chosen)!;
|
|
using var source = OpenBlob(blob, normalized);
|
|
using var target = File.Create(fullPath);
|
|
source.CopyTo(target);
|
|
}
|
|
|
|
Commands.Stage(Repo, normalized);
|
|
Report($"충돌 해결({resolution}): {normalized}");
|
|
}
|
|
|
|
public void ResolveConflictWithContent(string path, string content,
|
|
string? encodingName = null, string? newLineName = null)
|
|
{
|
|
var normalized = NormalizePath(path);
|
|
var fullPath = ToFullPath(normalized);
|
|
var directory = Path.GetDirectoryName(fullPath);
|
|
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
|
|
|
// "원본" 은 워킹트리 파일이 아니라 <b>내 쪽(ours) blob</b> 이다(6차 A-1).
|
|
// 충돌 중 워킹트리에는 git 이 LF 로 충돌 표시를 넣어 두어서, 그 파일을 보면 줄끝이 "혼합" 으로 읽힌다.
|
|
// 예전에는 인코딩만 워킹트리에서 보고 줄끝은 아예 안 봐서 CRLF 파일이 통째로 LF 가 됐다.
|
|
// 내 쪽이 없으면(내가 지운 파일) 상대 쪽, 그것도 없으면 공통 조상을 본다.
|
|
var conflict = Repo.Index.Conflicts[normalized];
|
|
var referenceBlob = conflict is null
|
|
? null
|
|
: LookupBlob(conflict.Ours) ?? LookupBlob(conflict.Theirs) ?? LookupBlob(conflict.Ancestor);
|
|
|
|
var reference = referenceBlob is not null
|
|
? ReadBlobBytes(referenceBlob, normalized)
|
|
: File.Exists(fullPath) ? File.ReadAllBytes(fullPath) : null;
|
|
|
|
File.WriteAllBytes(fullPath, TextFile.Encode(content, encodingName, newLineName, reference));
|
|
Commands.Stage(Repo, normalized);
|
|
|
|
var written = TextFile.Describe(fullPath);
|
|
Report($"충돌 해결(직접 편집, {written.Encoding} · {written.NewLine}): {normalized}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 머지 주석만 다른 충돌 파일을 풀고 스테이징한다(6차 B-5). 푼 파일 경로를 돌려준다.
|
|
///
|
|
/// git 은 주석 줄도 글자로 보고 충돌을 낸다. 워킹트리에 남긴 충돌 표시를 읽어 <b>모든 덩어리가</b>
|
|
/// 주석을 빼면 같은 코드일 때만 푼다(<see cref="NoteConflict"/>). 양쪽 주석을 다 남긴다.
|
|
/// 형식(인코딩 · 줄끝)은 직접 편집 해결과 같은 길로 내 쪽 파일을 따른다.
|
|
/// 한쪽이 지운 파일 · 이진 파일은 보지 않는다.
|
|
/// </summary>
|
|
public IReadOnlyList<string> ResolveNoteOnlyConflicts()
|
|
{
|
|
var solved = new List<string>();
|
|
|
|
foreach (var conflict in GetConflicts())
|
|
{
|
|
if (conflict.IsBinary || !conflict.HasOurs || !conflict.HasTheirs) continue;
|
|
|
|
var marked = ReadWorkingFileText(conflict.Path);
|
|
if (!NoteConflict.TryResolve(marked, conflict.Path, out var resolved, out var blocks)) continue;
|
|
|
|
ResolveConflictWithContent(conflict.Path, resolved);
|
|
RecordResolution(conflict.Path, MergeResolutionKind.NoteAuto);
|
|
Report($"머지 주석만 다른 충돌 {blocks}곳 자동 해결: {conflict.Path}");
|
|
solved.Add(conflict.Path);
|
|
}
|
|
|
|
return solved;
|
|
}
|
|
|
|
public string CommitMerge(string? message = null)
|
|
{
|
|
var remaining = GetConflicts();
|
|
if (remaining.Count > 0)
|
|
throw new InvalidOperationException($"아직 해결되지 않은 충돌이 {remaining.Count}건 있습니다.");
|
|
|
|
// 사람이 적은 메시지가 없으면 git 이 남긴 병합 메시지를 <b>다듬어</b> 쓴다(6차 B-3).
|
|
// 예전에는 MERGE_MSG 를 통째로 써서 "Merge commit '해시'" 와 "#Conflicts:" 파일 목록이 이력에 남았다.
|
|
// MergeHub 는 브랜치를 커밋으로 풀어 병합하므로 git 이 첫 줄을 해시로 적는다 - 상대 이름으로 바꾼다.
|
|
if (string.IsNullOrWhiteSpace(message))
|
|
message = MergeNames.CleanMergeMessage(ReadMergeMessage(), GetMergeTheirsName(), Repo.Head.FriendlyName);
|
|
|
|
// 충돌을 누가 어떻게 풀었는지 본문에 붙인다(8차 F-1). 사람이 메시지를 고쳤어도 이 블록은 붙는다 -
|
|
// 파일 단위 Accept 는 소스에 흔적이 없어 여기가 유일한 기록이다. 이미 붙어 있으면 새것으로 갈아 끼운다.
|
|
message = MergeResolutionLog.WithBody(message, GetResolutionBody());
|
|
|
|
var signature = BuildSignature();
|
|
var commit = Repo.Commit(message, signature, signature);
|
|
ForgetStaleMergeConflicts(); // 병합이 끝났으니 처음 충돌 목록 · 해결 기록도 치운다(7차 B-1 · 8차 F-1)
|
|
Report($"병합 커밋: {Shorten(commit.Sha)} {commit.MessageShort}");
|
|
return commit.Sha;
|
|
}
|
|
|
|
private string? ReadMergeMessage()
|
|
{
|
|
var path = Path.Combine(Repo.Info.Path, "MERGE_MSG");
|
|
return File.Exists(path) ? File.ReadAllText(path).Trim() : null;
|
|
}
|
|
|
|
private string? ReadMergeHeadSha()
|
|
{
|
|
var path = Path.Combine(Repo.Info.Path, "MERGE_HEAD");
|
|
if (!File.Exists(path)) return null;
|
|
|
|
foreach (var line in File.ReadAllLines(path))
|
|
{
|
|
var sha = line.Trim();
|
|
if (sha.Length >= 40) return sha.Substring(0, 40);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public GitOperationState GetOperationState()
|
|
{
|
|
var head = Repo.Head;
|
|
|
|
GitOperationState Picked(GitOperationKind kind, string headFile)
|
|
{
|
|
var sha = ReadFirstShaLine(Path.Combine(Repo.Info.Path, headFile));
|
|
var commit = sha is null ? null : Repo.Lookup<Commit>(sha);
|
|
return new GitOperationState(kind, head?.FriendlyName, null, commit?.Sha ?? sha, commit?.MessageShort, 0, 0);
|
|
}
|
|
|
|
switch (Repo.Info.CurrentOperation)
|
|
{
|
|
case CurrentOperation.Rebase:
|
|
case CurrentOperation.RebaseInteractive:
|
|
case CurrentOperation.RebaseMerge:
|
|
{
|
|
// rebase 는 병합과 두 쪽이 거꾸로다: ours = 올릴 자리(onto), theirs = 다시 올리는 내 커밋.
|
|
var dir = Path.Combine(Repo.Info.Path, "rebase-merge");
|
|
var onto = ReadFirstShaLine(Path.Combine(dir, "onto"));
|
|
var headName = File.Exists(Path.Combine(dir, "head-name"))
|
|
? File.ReadAllText(Path.Combine(dir, "head-name")).Trim()
|
|
: null;
|
|
var step = Repo.Rebase.GetCurrentStepInfo();
|
|
return new GitOperationState(
|
|
GitOperationKind.Rebase,
|
|
OursName: BranchNameAt(onto) ?? (onto is null ? null : Shorten(onto)),
|
|
TheirsName: headName is not null && headName.StartsWith("refs/heads/", StringComparison.Ordinal)
|
|
? headName.Substring("refs/heads/".Length)
|
|
: headName,
|
|
TheirsSha: step?.Commit?.Sha,
|
|
TheirsMessage: step?.Commit?.MessageShort,
|
|
Step: (int)(Repo.Rebase.GetCurrentStepIndex() + 1),
|
|
TotalSteps: (int)Repo.Rebase.GetTotalStepCount());
|
|
}
|
|
case CurrentOperation.CherryPick:
|
|
case CurrentOperation.CherryPickSequence:
|
|
return Picked(GitOperationKind.CherryPick, "CHERRY_PICK_HEAD");
|
|
case CurrentOperation.Revert:
|
|
case CurrentOperation.RevertSequence:
|
|
return Picked(GitOperationKind.Revert, "REVERT_HEAD");
|
|
case CurrentOperation.Merge:
|
|
return new GitOperationState(GitOperationKind.Merge, head?.FriendlyName, GetMergeTheirsName(),
|
|
ReadMergeHeadSha(), null, 0, 0);
|
|
default:
|
|
// 작업 표시 없이 충돌만 남은 경우(stash 적용 등)는 지금까지처럼 병합으로 본다.
|
|
return Repo.Index.Conflicts.Any()
|
|
? new GitOperationState(GitOperationKind.Merge, head?.FriendlyName, null, null, null, 0, 0)
|
|
: GitOperationState.None;
|
|
}
|
|
}
|
|
|
|
/// <summary>파일 첫 줄의 40자 해시. 없으면 null.</summary>
|
|
private static string? ReadFirstShaLine(string path)
|
|
{
|
|
if (!File.Exists(path)) return null;
|
|
foreach (var line in File.ReadAllLines(path))
|
|
{
|
|
var sha = line.Trim();
|
|
if (sha.Length >= 40) return sha.Substring(0, 40);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>그 커밋을 끝으로 가리키는 브랜치 이름. 로컬을 먼저 본다.</summary>
|
|
private string? BranchNameAt(string? sha)
|
|
{
|
|
if (sha is null) return null;
|
|
return Repo.Branches.Where(b => !b.IsRemote && b.Tip?.Sha == sha).Select(b => b.FriendlyName).FirstOrDefault()
|
|
?? Repo.Branches.Where(b => b.IsRemote && b.Tip?.Sha == sha).Select(b => b.FriendlyName).FirstOrDefault();
|
|
}
|
|
|
|
public string? GetPendingCommitMessage()
|
|
{
|
|
var state = GetOperationState();
|
|
if (state.Kind is not (GitOperationKind.CherryPick or GitOperationKind.Revert)) return null;
|
|
|
|
// libgit2 가 남긴 MERGE_MSG(원래 메시지)가 있으면 # 줄을 빼고 쓴다. 없으면 그 커밋에서 만든다.
|
|
var raw = ReadMergeMessage();
|
|
if (raw is not null)
|
|
{
|
|
var cleaned = string.Join("\n", raw.Replace("\r\n", "\n").Split('\n')
|
|
.Where(l => !l.TrimStart().StartsWith("#", StringComparison.Ordinal))).Trim();
|
|
if (cleaned.Length > 0) return cleaned;
|
|
}
|
|
|
|
var commit = state.TheirsSha is null ? null : Repo.Lookup<Commit>(state.TheirsSha);
|
|
if (commit is null) return null;
|
|
return state.Kind == GitOperationKind.CherryPick
|
|
? commit.Message.TrimEnd()
|
|
: $"Revert \"{commit.MessageShort}\"\n\nThis reverts commit {commit.Sha}.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 지금 커밋할 것이 있는가 - 인덱스가 HEAD 와 다른가(8차 B-2).
|
|
/// 이미 들어 있는 커밋을 cherry-pick 하면 가져올 변경이 없다. 그때 [커밋] 은 눌러 봐야 실패한다.
|
|
/// </summary>
|
|
public bool HasStagedChanges()
|
|
{
|
|
var head = Repo.Head?.Tip;
|
|
if (head is null) return Repo.Index.Count > 0;
|
|
|
|
return Repo.Diff.Compare<TreeChanges>(head.Tree, DiffTargets.Index).Count > 0;
|
|
}
|
|
|
|
public string CommitPendingOperation(string? message = null)
|
|
{
|
|
var state = GetOperationState();
|
|
if (state.Kind is not (GitOperationKind.CherryPick or GitOperationKind.Revert))
|
|
return CommitMerge(message);
|
|
|
|
var remaining = GetConflicts();
|
|
if (remaining.Count > 0)
|
|
throw new InvalidOperationException($"아직 해결되지 않은 충돌이 {remaining.Count}건 있습니다.");
|
|
|
|
// 가져올 것이 없으면 libgit2 가 "No changes; nothing to commit" 으로 막는다. 사람 말로 먼저 막는다(8차 B-2).
|
|
if (!HasStagedChanges())
|
|
throw new InvalidOperationException(state.Kind == GitOperationKind.CherryPick
|
|
? "가져올 변경이 없습니다. [작업 취소] 로 끝내세요."
|
|
: "되돌릴 변경이 없습니다. [작업 취소] 로 끝내세요.");
|
|
|
|
if (string.IsNullOrWhiteSpace(message))
|
|
message = GetPendingCommitMessage() ?? (state.Kind == GitOperationKind.CherryPick ? "cherry-pick" : "revert");
|
|
|
|
// cherry-pick 은 git 처럼 원래 작성자를 남기고, 커밋한 사람은 지금 사용자로 둔다.
|
|
var committer = BuildSignature();
|
|
var author = committer;
|
|
if (state.Kind == GitOperationKind.CherryPick && state.TheirsSha is { } sha && Repo.Lookup<Commit>(sha) is { } picked)
|
|
author = new Signature(picked.Author.Name, picked.Author.Email, picked.Author.When);
|
|
|
|
var commit = Repo.Commit(message, author, committer);
|
|
ForgetStaleMergeConflicts();
|
|
Report($"{(state.Kind == GitOperationKind.CherryPick ? "cherry-pick" : "revert")} 커밋: {Shorten(commit.Sha)} {commit.MessageShort}");
|
|
return commit.Sha;
|
|
}
|
|
|
|
public string? GetMergeTheirsName()
|
|
{
|
|
if (Repo.Info.CurrentOperation != CurrentOperation.Merge && !Repo.Index.Conflicts.Any()) return null;
|
|
|
|
// 1) 병합해 들어오는 커밋을 가리키는 브랜치가 있으면 그 이름이 가장 정확하다.
|
|
// 내 브랜치(HEAD)는 빼고, 로컬을 원격보다 먼저 본다.
|
|
// LibGit2Sharp 0.32 에는 MergeHeads 가 없다. git 이 남기는 MERGE_HEAD 파일의 첫 줄이 들어오는 커밋이다.
|
|
var sha = ReadMergeHeadSha();
|
|
if (sha is not null)
|
|
{
|
|
var local = Repo.Branches
|
|
.Where(b => !b.IsRemote && !b.IsCurrentRepositoryHead && b.Tip?.Sha == sha)
|
|
.Select(b => b.FriendlyName)
|
|
.FirstOrDefault();
|
|
if (local is not null) return local;
|
|
|
|
var remote = Repo.Branches
|
|
.Where(b => b.IsRemote && b.Tip?.Sha == sha)
|
|
.Select(b => b.FriendlyName)
|
|
.FirstOrDefault();
|
|
if (remote is not null) return remote;
|
|
}
|
|
|
|
// 2) 브랜치가 이미 앞으로 나갔으면 git 이 남긴 병합 메시지에서 이름을 꺼낸다.
|
|
// 3) 그것도 없으면(커밋으로 병합) 짧은 해시.
|
|
return MergeNames.TheirsFromMergeMessage(ReadMergeMessage())
|
|
?? (sha is null ? null : Shorten(sha));
|
|
}
|
|
|
|
// ================= Diff =================
|
|
|
|
public IReadOnlyList<FileDiff> DiffCommits(string oldCommittish, string newCommittish, params string[] paths)
|
|
{
|
|
var oldCommit = ResolveCommit(oldCommittish);
|
|
var newCommit = ResolveCommit(newCommittish);
|
|
if (newCommit is null) throw new ArgumentException($"커밋을 찾을 수 없습니다: {newCommittish}");
|
|
|
|
using var patch = Repo.Diff.Compare<Patch>(
|
|
oldCommit?.Tree,
|
|
newCommit.Tree,
|
|
NormalizePaths(paths),
|
|
null,
|
|
BuildCompareOptions());
|
|
|
|
return BuildFileDiffs(patch);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 두 커밋 사이에 바뀐 파일만 골라 폴더로 뽑는다.
|
|
///
|
|
/// 기획안 4p "변경분 파일 추출" - 장비에 소스를 통째로 넣지 못하는 현장에서
|
|
/// <b>바뀐 파일만</b> 들고 가야 할 때 쓴다. 저장소 안의 경로 구조를 그대로 만든다.
|
|
///
|
|
/// 지워진 파일은 뽑을 것이 없으므로 <b>목록으로만</b> 돌려주고 파일은 만들지 않는다 -
|
|
/// 그 목록이 안내 글의 "장비에서 지워야 할 파일" 이 된다(11차 A-2).
|
|
/// </summary>
|
|
/// <returns>(뽑은 파일 수, 지워져서 건너뛴 파일 수, 무엇을 어떻게 했는지 목록)</returns>
|
|
public (int Written, int Skipped, IReadOnlyList<ExportReport.Item> Items)
|
|
ExportChangedFiles(string oldCommittish, string newCommittish, string targetDirectory)
|
|
{
|
|
var newCommit = ResolveCommit(newCommittish)
|
|
?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {newCommittish}");
|
|
|
|
var diffs = DiffCommits(oldCommittish, newCommittish);
|
|
|
|
// 쓰기 전에 <b>쓸 수 있는지 먼저 본다</b>(11차 A-3, 사용자 결정). 하나라도 막히면
|
|
// 한 글자도 쓰지 않고 멈춘다 - 예전에는 앞 순서만 써진 반쯤 뽑힌 폴더가 남았다.
|
|
var willWrite = diffs.Where(d => newCommit[d.Path]?.Target is Blob).Select(d => d.Path).ToList();
|
|
var blocked = ExportPlan.Blocked(targetDirectory, willWrite);
|
|
if (blocked.Count > 0) throw new IOException(ExportPlan.BlockedMessage(blocked));
|
|
|
|
Directory.CreateDirectory(targetDirectory);
|
|
|
|
var written = 0;
|
|
var skipped = 0;
|
|
var items = new List<ExportReport.Item>();
|
|
|
|
foreach (var diff in diffs)
|
|
{
|
|
// 새 커밋 쪽에 파일이 없다(삭제됨). 뽑을 내용이 없다.
|
|
if (newCommit[diff.Path]?.Target is not Blob blob)
|
|
{
|
|
skipped++;
|
|
items.Add(new ExportReport.Item(diff.Path, null, ExportReport.ExportKind.Deleted));
|
|
continue;
|
|
}
|
|
|
|
var target = Path.Combine(targetDirectory, diff.Path.Replace('/', Path.DirectorySeparatorChar));
|
|
var folder = Path.GetDirectoryName(target);
|
|
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
|
|
|
|
// 텍스트로 다시 쓰면 줄바꿈과 인코딩이 바뀐다. 바이트 그대로 옮긴다.
|
|
using (var source = blob.GetContentStream())
|
|
using (var file = File.Create(target))
|
|
{
|
|
source.CopyTo(file);
|
|
}
|
|
|
|
written++;
|
|
|
|
// git 의 상태 글자(A · M · R …)를 안내 글의 말로 옮긴다. 이름이 바뀐 파일은 옛 경로를 함께 들고 간다.
|
|
var kind = diff.ChangeKind switch
|
|
{
|
|
"Added" => ExportReport.ExportKind.Added,
|
|
"Renamed" => ExportReport.ExportKind.Renamed,
|
|
"Copied" => ExportReport.ExportKind.Added,
|
|
_ => ExportReport.ExportKind.Modified
|
|
};
|
|
var oldPath = kind == ExportReport.ExportKind.Renamed &&
|
|
!string.IsNullOrEmpty(diff.OldPath) && diff.OldPath != diff.Path
|
|
? diff.OldPath
|
|
: null;
|
|
|
|
items.Add(new ExportReport.Item(diff.Path, oldPath, kind));
|
|
}
|
|
|
|
Report($"변경분 추출: {written}개 파일 → {targetDirectory} (삭제되어 건너뜀 {skipped}개)");
|
|
return (written, skipped, items);
|
|
}
|
|
|
|
public IReadOnlyList<FileDiff> DiffCommitWithParent(string committish, params string[] paths)
|
|
{
|
|
var commit = ResolveCommit(committish)
|
|
?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}");
|
|
|
|
var parent = commit.Parents.FirstOrDefault();
|
|
using var patch = Repo.Diff.Compare<Patch>(
|
|
parent?.Tree,
|
|
commit.Tree,
|
|
NormalizePaths(paths),
|
|
null,
|
|
BuildCompareOptions());
|
|
|
|
return BuildFileDiffs(patch);
|
|
}
|
|
|
|
public IReadOnlyList<FileDiff> DiffWorkingDirectory(bool stagedOnly = false, params string[] paths)
|
|
{
|
|
var targets = stagedOnly ? DiffTargets.Index : DiffTargets.Index | DiffTargets.WorkingDirectory;
|
|
using var patch = Repo.Diff.Compare<Patch>(
|
|
Repo.Head.Tip?.Tree,
|
|
targets,
|
|
NormalizePaths(paths),
|
|
null,
|
|
BuildCompareOptions());
|
|
|
|
return BuildFileDiffs(patch);
|
|
}
|
|
|
|
private static CompareOptions BuildCompareOptions() => new()
|
|
{
|
|
ContextLines = 3,
|
|
InterhunkLines = 0,
|
|
Similarity = SimilarityOptions.Renames
|
|
};
|
|
|
|
private List<FileDiff> BuildFileDiffs(Patch patch)
|
|
{
|
|
var list = new List<FileDiff>();
|
|
foreach (var change in patch)
|
|
{
|
|
var lines = change.IsBinaryComparison
|
|
? (IReadOnlyList<DiffLine>)Array.Empty<DiffLine>()
|
|
: ParsePatch(change.Patch, IgnoreWhitespace, WordLevelDiff);
|
|
|
|
var added = lines.Count(l => l.Kind == DiffLineKind.Added);
|
|
var deleted = lines.Count(l => l.Kind == DiffLineKind.Deleted);
|
|
|
|
// 공백 변경 무시 옵션에서 실제 변경이 사라진 파일은 목록에서 제외
|
|
if (IgnoreWhitespace && !change.IsBinaryComparison && added == 0 && deleted == 0)
|
|
continue;
|
|
|
|
list.Add(new FileDiff(
|
|
Path: change.Path.Replace('\\', '/'),
|
|
OldPath: change.OldPath?.Replace('\\', '/'),
|
|
ChangeKind: change.Status.ToString(),
|
|
IsBinary: change.IsBinaryComparison,
|
|
LinesAdded: change.IsBinaryComparison ? change.LinesAdded : added,
|
|
LinesDeleted: change.IsBinaryComparison ? change.LinesDeleted : deleted,
|
|
Patch: change.Patch,
|
|
Lines: lines));
|
|
}
|
|
return list;
|
|
}
|
|
|
|
/// <summary>unified diff 텍스트를 줄 단위 구조로 변환한다.</summary>
|
|
internal static IReadOnlyList<DiffLine> ParsePatch(string patchText, bool ignoreWhitespace, bool wordLevel = false)
|
|
{
|
|
var result = new List<DiffLine>();
|
|
if (string.IsNullOrEmpty(patchText)) return result;
|
|
|
|
int oldLine = 0, newLine = 0;
|
|
|
|
foreach (var raw in patchText.Split('\n'))
|
|
{
|
|
var line = raw.Length > 0 && raw[raw.Length - 1] == '\r'
|
|
? raw.Substring(0, raw.Length - 1)
|
|
: raw;
|
|
if (line.Length == 0) continue;
|
|
|
|
if (line.StartsWith("@@", StringComparison.Ordinal))
|
|
{
|
|
(oldLine, newLine) = ParseHunkHeader(line);
|
|
result.Add(new DiffLine(DiffLineKind.HunkHeader, null, null, line));
|
|
continue;
|
|
}
|
|
|
|
if (IsPatchHeaderLine(line)) continue;
|
|
|
|
var marker = line[0];
|
|
var text = line[1..];
|
|
|
|
switch (marker)
|
|
{
|
|
case '+':
|
|
result.Add(new DiffLine(DiffLineKind.Added, null, newLine++, text));
|
|
break;
|
|
case '-':
|
|
result.Add(new DiffLine(DiffLineKind.Deleted, oldLine++, null, text));
|
|
break;
|
|
default:
|
|
result.Add(new DiffLine(DiffLineKind.Context, oldLine++, newLine++, text));
|
|
break;
|
|
}
|
|
}
|
|
|
|
var lines = ignoreWhitespace ? CollapseWhitespaceOnlyChanges(result) : result;
|
|
if (!wordLevel) return lines;
|
|
|
|
var withWords = lines as List<DiffLine> ?? lines.ToList();
|
|
ApplyWordLevelDiff(withWords);
|
|
return withWords;
|
|
}
|
|
|
|
private static bool IsPatchHeaderLine(string line) =>
|
|
line.StartsWith("diff --git", StringComparison.Ordinal) ||
|
|
line.StartsWith("index ", StringComparison.Ordinal) ||
|
|
line.StartsWith("--- ", StringComparison.Ordinal) ||
|
|
line.StartsWith("+++ ", StringComparison.Ordinal) ||
|
|
line.StartsWith("new file mode", StringComparison.Ordinal) ||
|
|
line.StartsWith("deleted file mode", StringComparison.Ordinal) ||
|
|
line.StartsWith("old mode", StringComparison.Ordinal) ||
|
|
line.StartsWith("new mode", StringComparison.Ordinal) ||
|
|
line.StartsWith("similarity index", StringComparison.Ordinal) ||
|
|
line.StartsWith("rename from", StringComparison.Ordinal) ||
|
|
line.StartsWith("rename to", StringComparison.Ordinal) ||
|
|
line.StartsWith("Binary files", StringComparison.Ordinal) ||
|
|
line.StartsWith("\\ No newline", StringComparison.Ordinal);
|
|
|
|
private static (int oldStart, int newStart) ParseHunkHeader(string header)
|
|
{
|
|
// @@ -12,7 +12,9 @@ optional section heading
|
|
int oldStart = 0, newStart = 0;
|
|
var minus = header.IndexOf('-');
|
|
var plus = header.IndexOf('+');
|
|
|
|
if (minus >= 0) oldStart = ReadNumber(header, minus + 1);
|
|
if (plus >= 0) newStart = ReadNumber(header, plus + 1);
|
|
return (oldStart, newStart);
|
|
|
|
static int ReadNumber(string s, int i)
|
|
{
|
|
var value = 0;
|
|
var any = false;
|
|
while (i < s.Length && char.IsDigit(s[i]))
|
|
{
|
|
value = value * 10 + (s[i] - '0');
|
|
i++;
|
|
any = true;
|
|
}
|
|
return any ? value : 0;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 삭제/추가가 짝을 이루고 공백만 다른 경우 두 줄을 컨텍스트로 접는다.
|
|
/// (기획안 4p "whitespace-only diff ignore")
|
|
/// </summary>
|
|
private static IReadOnlyList<DiffLine> CollapseWhitespaceOnlyChanges(IReadOnlyList<DiffLine> lines)
|
|
{
|
|
var result = new List<DiffLine>(lines.Count);
|
|
var i = 0;
|
|
|
|
while (i < lines.Count)
|
|
{
|
|
if (lines[i].Kind != DiffLineKind.Deleted)
|
|
{
|
|
result.Add(lines[i++]);
|
|
continue;
|
|
}
|
|
|
|
var delStart = i;
|
|
while (i < lines.Count && lines[i].Kind == DiffLineKind.Deleted) i++;
|
|
var addStart = i;
|
|
while (i < lines.Count && lines[i].Kind == DiffLineKind.Added) i++;
|
|
|
|
var delCount = addStart - delStart;
|
|
var addCount = i - addStart;
|
|
|
|
if (delCount == addCount)
|
|
{
|
|
var allWhitespaceOnly = true;
|
|
for (var k = 0; k < delCount; k++)
|
|
{
|
|
if (!IsWhitespaceOnlyDifference(lines[delStart + k].Text, lines[addStart + k].Text))
|
|
{
|
|
allWhitespaceOnly = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (allWhitespaceOnly)
|
|
{
|
|
for (var k = 0; k < delCount; k++)
|
|
{
|
|
result.Add(new DiffLine(
|
|
DiffLineKind.Context,
|
|
lines[delStart + k].OldLineNumber,
|
|
lines[addStart + k].NewLineNumber,
|
|
lines[addStart + k].Text));
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
for (var k = delStart; k < i; k++) result.Add(lines[k]);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ================= 단어 단위 diff =================
|
|
|
|
/// <summary>토큰 수가 이보다 많은 줄은 단어 비교를 건너뛴다(O(n*m) 방어).</summary>
|
|
private const int MaxWordDiffTokens = 400;
|
|
|
|
public (IReadOnlyList<WordDiffSegment> Old, IReadOnlyList<WordDiffSegment> New) DiffWords(string oldLine, string newLine)
|
|
{
|
|
var result = ComputeWordDiff(oldLine, newLine);
|
|
return result ?? (
|
|
new[] { new WordDiffSegment(WordDiffKind.Deleted, oldLine) },
|
|
new[] { new WordDiffSegment(WordDiffKind.Added, newLine) });
|
|
}
|
|
|
|
/// <summary>
|
|
/// 두 줄을 토큰(단어/공백/기호)으로 쪼개 LCS 를 구하고, 공통 구간과 변경 구간을 나눈다.
|
|
/// 줄이 너무 길면 null 을 돌려주고 호출측이 줄 전체를 변경으로 처리한다.
|
|
/// </summary>
|
|
private static (IReadOnlyList<WordDiffSegment> Old, IReadOnlyList<WordDiffSegment> New)? ComputeWordDiff(
|
|
string oldLine, string newLine)
|
|
{
|
|
var a = Tokenize(oldLine);
|
|
var b = Tokenize(newLine);
|
|
if (a.Count > MaxWordDiffTokens || b.Count > MaxWordDiffTokens) return null;
|
|
|
|
// LCS 길이 표
|
|
var table = new int[a.Count + 1, b.Count + 1];
|
|
for (var i = a.Count - 1; i >= 0; i--)
|
|
{
|
|
for (var j = b.Count - 1; j >= 0; j--)
|
|
{
|
|
table[i, j] = a[i] == b[j]
|
|
? table[i + 1, j + 1] + 1
|
|
: Math.Max(table[i + 1, j], table[i, j + 1]);
|
|
}
|
|
}
|
|
|
|
var oldSegments = new List<WordDiffSegment>();
|
|
var newSegments = new List<WordDiffSegment>();
|
|
|
|
int x = 0, y = 0;
|
|
while (x < a.Count && y < b.Count)
|
|
{
|
|
if (a[x] == b[y])
|
|
{
|
|
Append(oldSegments, WordDiffKind.Unchanged, a[x]);
|
|
Append(newSegments, WordDiffKind.Unchanged, b[y]);
|
|
x++; y++;
|
|
}
|
|
else if (table[x + 1, y] >= table[x, y + 1])
|
|
{
|
|
Append(oldSegments, WordDiffKind.Deleted, a[x++]);
|
|
}
|
|
else
|
|
{
|
|
Append(newSegments, WordDiffKind.Added, b[y++]);
|
|
}
|
|
}
|
|
while (x < a.Count) Append(oldSegments, WordDiffKind.Deleted, a[x++]);
|
|
while (y < b.Count) Append(newSegments, WordDiffKind.Added, b[y++]);
|
|
|
|
return (oldSegments, newSegments);
|
|
|
|
// 같은 종류가 이어지면 하나로 합쳐 세그먼트 수를 줄인다.
|
|
static void Append(List<WordDiffSegment> target, WordDiffKind kind, string text)
|
|
{
|
|
if (target.Count > 0 && target[^1].Kind == kind)
|
|
target[^1] = target[^1] with { Text = target[^1].Text + text };
|
|
else
|
|
target.Add(new WordDiffSegment(kind, text));
|
|
}
|
|
}
|
|
|
|
/// <summary>식별자 / 숫자 / 공백 덩어리 / 단일 기호 단위로 자른다.</summary>
|
|
private static List<string> Tokenize(string line)
|
|
{
|
|
var tokens = new List<string>();
|
|
var i = 0;
|
|
|
|
while (i < line.Length)
|
|
{
|
|
var start = i;
|
|
var c = line[i];
|
|
|
|
if (char.IsWhiteSpace(c))
|
|
{
|
|
while (i < line.Length && char.IsWhiteSpace(line[i])) i++;
|
|
}
|
|
else if (char.IsLetterOrDigit(c) || c == '_')
|
|
{
|
|
while (i < line.Length && (char.IsLetterOrDigit(line[i]) || line[i] == '_')) i++;
|
|
}
|
|
else
|
|
{
|
|
i++;
|
|
}
|
|
|
|
tokens.Add(line[start..i]);
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
/// <summary>짝을 이루는 삭제/추가 줄에 단어 단위 변경 구간을 채운다.</summary>
|
|
private static void ApplyWordLevelDiff(List<DiffLine> lines)
|
|
{
|
|
var i = 0;
|
|
while (i < lines.Count)
|
|
{
|
|
if (lines[i].Kind != DiffLineKind.Deleted) { i++; continue; }
|
|
|
|
var delStart = i;
|
|
while (i < lines.Count && lines[i].Kind == DiffLineKind.Deleted) i++;
|
|
var addStart = i;
|
|
while (i < lines.Count && lines[i].Kind == DiffLineKind.Added) i++;
|
|
|
|
var pairs = Math.Min(addStart - delStart, i - addStart);
|
|
for (var k = 0; k < pairs; k++)
|
|
{
|
|
var oldLine = lines[delStart + k];
|
|
var newLine = lines[addStart + k];
|
|
|
|
var diff = ComputeWordDiff(oldLine.Text, newLine.Text);
|
|
if (diff is null) continue;
|
|
|
|
lines[delStart + k] = oldLine with { Words = diff.Value.Old };
|
|
lines[addStart + k] = newLine with { Words = diff.Value.New };
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool IsWhitespaceOnlyDifference(string a, string b)
|
|
{
|
|
int i = 0, j = 0;
|
|
while (true)
|
|
{
|
|
while (i < a.Length && char.IsWhiteSpace(a[i])) i++;
|
|
while (j < b.Length && char.IsWhiteSpace(b[j])) j++;
|
|
|
|
if (i >= a.Length || j >= b.Length) return i >= a.Length && j >= b.Length;
|
|
if (a[i] != b[j]) return false;
|
|
i++; j++;
|
|
}
|
|
}
|
|
|
|
// ================= 확장 =================
|
|
|
|
public IReadOnlyList<BlameLineInfo> Blame(string path, string? committish = null)
|
|
{
|
|
var normalized = NormalizePath(path);
|
|
|
|
var options = new BlameOptions();
|
|
if (!string.IsNullOrWhiteSpace(committish))
|
|
{
|
|
var start = ResolveCommit(committish!);
|
|
if (start is not null) options.StartingAt = start;
|
|
}
|
|
|
|
var hunks = Repo.Blame(normalized, options);
|
|
|
|
string[] fileLines;
|
|
if (string.IsNullOrWhiteSpace(committish))
|
|
{
|
|
var full = Path.Combine(Repo.Info.WorkingDirectory, normalized.Replace('/', Path.DirectorySeparatorChar));
|
|
fileLines = File.Exists(full)
|
|
? File.ReadAllLines(full)
|
|
: SplitLines(GetFileTextAtCommit("HEAD", normalized));
|
|
}
|
|
else
|
|
{
|
|
fileLines = SplitLines(GetFileTextAtCommit(committish!, normalized));
|
|
}
|
|
|
|
var result = new List<BlameLineInfo>(fileLines.Length);
|
|
foreach (var hunk in hunks)
|
|
{
|
|
var commit = hunk.FinalCommit;
|
|
for (var k = 0; k < hunk.LineCount; k++)
|
|
{
|
|
var lineIndex = hunk.FinalStartLineNumber + k;
|
|
if (lineIndex < 0 || lineIndex >= fileLines.Length) continue;
|
|
|
|
result.Add(new BlameLineInfo(
|
|
LineNumber: lineIndex + 1,
|
|
Text: fileLines[lineIndex],
|
|
CommitSha: commit.Sha,
|
|
ShortSha: Shorten(commit.Sha),
|
|
AuthorName: commit.Author.Name,
|
|
When: commit.Author.When,
|
|
MessageShort: commit.MessageShort));
|
|
}
|
|
}
|
|
|
|
result.Sort((a, b) => a.LineNumber.CompareTo(b.LineNumber));
|
|
return result;
|
|
}
|
|
|
|
public IReadOnlyList<StashInfo> GetStashes()
|
|
{
|
|
var list = new List<StashInfo>();
|
|
var index = 0;
|
|
foreach (var stash in Repo.Stashes)
|
|
{
|
|
list.Add(new StashInfo(index++, stash.Message, stash.WorkTree?.Sha ?? string.Empty));
|
|
}
|
|
return list;
|
|
}
|
|
|
|
public void StashSave(string message)
|
|
{
|
|
Repo.Stashes.Add(BuildSignature(), message);
|
|
Report($"stash 저장: {message}");
|
|
}
|
|
|
|
public void StashPop(int index = 0)
|
|
{
|
|
var status = Repo.Stashes.Pop(index);
|
|
Report($"stash 적용: {status}");
|
|
}
|
|
|
|
public void StashDrop(int index)
|
|
{
|
|
Repo.Stashes.Remove(index);
|
|
Report($"stash 삭제: index {index}");
|
|
}
|
|
|
|
public void RegisterAsMergeTool(string toolName, string executablePath, bool global = true)
|
|
{
|
|
var level = global ? ConfigurationLevel.Global : ConfigurationLevel.Local;
|
|
var exe = executablePath.Replace('\\', '/');
|
|
|
|
// $BASE/$LOCAL/$REMOTE/$MERGED 는 git 이 넘겨주는 임시 파일 경로
|
|
Repo.Config.Set("merge.tool", toolName, level);
|
|
Repo.Config.Set($"mergetool.{toolName}.cmd",
|
|
$"\"{exe}\" --merge --base \"$BASE\" --local \"$LOCAL\" --remote \"$REMOTE\" --merged \"$MERGED\"", level);
|
|
Repo.Config.Set($"mergetool.{toolName}.trustExitCode", "true", level);
|
|
Repo.Config.Set("mergetool.keepBackup", "false", level);
|
|
|
|
Repo.Config.Set("diff.tool", toolName, level);
|
|
Repo.Config.Set($"difftool.{toolName}.cmd",
|
|
$"\"{exe}\" --diff --local \"$LOCAL\" --remote \"$REMOTE\"", level);
|
|
|
|
Report($"mergetool 등록 완료: merge.tool={toolName} ({(global ? "global" : "local")})");
|
|
}
|
|
|
|
public string? GetConfig(string key) => Repo.Config.Get<string>(key)?.Value;
|
|
|
|
public void SetConfig(string key, string value, bool global = false) =>
|
|
Repo.Config.Set(key, value, global ? ConfigurationLevel.Global : ConfigurationLevel.Local);
|
|
|
|
// ================= 내부 헬퍼 =================
|
|
|
|
private Commit? ResolveCommit(string committish)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(committish)) return null;
|
|
|
|
if (string.Equals(committish, "HEAD", StringComparison.OrdinalIgnoreCase))
|
|
return Repo.Head.Tip;
|
|
|
|
var branch = Repo.Branches[committish];
|
|
if (branch?.Tip is not null) return branch.Tip;
|
|
|
|
var obj = Repo.Lookup(committish);
|
|
return obj switch
|
|
{
|
|
Commit c => c,
|
|
TagAnnotation t => t.Target as Commit,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private static CommitInfo ToCommitInfo(Commit c, Dictionary<string, List<string>>? refsByTarget)
|
|
{
|
|
List<string>? refs = null;
|
|
refsByTarget?.TryGetValue(c.Sha, out refs);
|
|
|
|
return new CommitInfo(
|
|
Sha: c.Sha,
|
|
ShortSha: Shorten(c.Sha),
|
|
MessageShort: c.MessageShort,
|
|
Message: c.Message,
|
|
AuthorName: c.Author.Name,
|
|
AuthorEmail: c.Author.Email,
|
|
When: c.Author.When,
|
|
ParentShas: c.Parents.Select(p => p.Sha).ToList(),
|
|
Refs: refs ?? new List<string>());
|
|
}
|
|
|
|
private Blob? LookupBlob(IndexEntry? entry) =>
|
|
entry is null ? null : Repo.Lookup<Blob>(entry.Id);
|
|
|
|
private bool IsBinaryEntry(IndexEntry? entry) => LookupBlob(entry)?.IsBinary ?? false;
|
|
|
|
/// <summary>
|
|
/// blob 본문을 읽는다. hintPath 를 주면 git 의 checkout 필터(core.autocrlf, .gitattributes 의
|
|
/// text/eol 속성)를 적용해 워킹트리에 기록될 형태 그대로 얻는다.
|
|
/// 이걸 빼면 CRLF 저장소의 파일이 LF 로 바뀌어 전체 줄이 변경으로 잡힌다.
|
|
/// </summary>
|
|
private static string ReadBlobText(Blob? blob, string? hintPath = null) =>
|
|
blob is null ? string.Empty : DecodeText(ReadBlobBytes(blob, hintPath));
|
|
|
|
private static byte[] ReadBlobBytes(Blob blob, string? hintPath)
|
|
{
|
|
using var stream = OpenBlob(blob, hintPath);
|
|
using var buffer = new MemoryStream();
|
|
stream.CopyTo(buffer);
|
|
return buffer.ToArray();
|
|
}
|
|
|
|
private static Stream OpenBlob(Blob blob, string? hintPath) =>
|
|
hintPath is null ? blob.GetContentStream() : blob.GetContentStream(new FilteringOptions(hintPath));
|
|
|
|
/// <summary>
|
|
/// BOM -> UTF-8 유효성 -> 시스템 코드페이지 순으로 인코딩을 판별한다.
|
|
/// 사내 C/C++ 소스가 CP949 로 저장된 경우 UTF-8 로 강제 디코딩하면 한글이 깨진다.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// 인코딩 판별은 <see cref="TextFile.DetectEncoding"/> 한 곳에서만 한다(5차 A-1).
|
|
///
|
|
/// 전에는 여기에 똑같은 코드가 한 벌 더 있었다. 주석으로 "TextFile 과 같아야 한다" 고
|
|
/// 적어 두었지만 지키는 방법이 <b>복사</b>뿐이라 언젠가 어긋난다 - 같은 파일을 워킹트리로 열 때와
|
|
/// 커밋에서 열 때 한글이 다르게 보이면 어느 쪽이 맞는지 알 수도 없다.
|
|
/// </summary>
|
|
private static Encoding DetectEncoding(byte[] bytes) => TextFile.DetectEncoding(bytes);
|
|
|
|
private static string DecodeText(byte[] bytes)
|
|
{
|
|
if (bytes.Length == 0) return string.Empty;
|
|
|
|
var encoding = DetectEncoding(bytes);
|
|
var preamble = encoding.GetPreamble();
|
|
var offset = bytes.Length >= preamble.Length && preamble.Length > 0 && bytes.Take(preamble.Length).SequenceEqual(preamble)
|
|
? preamble.Length
|
|
: 0;
|
|
|
|
return encoding.GetString(bytes, offset, bytes.Length - offset);
|
|
}
|
|
|
|
/// <summary>워킹트리 파일을 인코딩 자동 판별로 읽는다. (충돌 마커가 들어간 상태 그대로)</summary>
|
|
public string ReadWorkingFileText(string path)
|
|
{
|
|
var full = ToFullPath(path);
|
|
return File.Exists(full) ? DecodeText(File.ReadAllBytes(full)) : string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 워킹트리 파일의 인코딩과 줄바꿈 방식을 사람이 읽을 형태로 알려준다.
|
|
/// 편집 화면 상태바에 띄워, 저장할 때 무엇이 유지되는지 사용자가 알 수 있게 한다.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// 병합 중 충돌 파일의 인코딩과 줄바꿈 - <b>내 쪽(ours) 파일</b> 기준(6차 B-7).
|
|
///
|
|
/// 워킹트리 파일에는 git 이 충돌 표시를 LF 로 넣어 두어서, 양쪽 다 CRLF 인 파일도 "혼합" 으로 읽혔다.
|
|
/// 저장(해결로 저장)도 내 쪽 파일의 형식을 따르므로(6차 A-1) 상태바가 같은 것을 보여야 한다.
|
|
/// 내 쪽이 없으면(내가 지운 파일) 상대 쪽, 그것도 없으면 공통 조상. 충돌이 아니면 워킹트리 파일을 본다.
|
|
/// </summary>
|
|
public TextFileInfo DescribeConflictFile(string path)
|
|
{
|
|
var normalized = NormalizePath(path);
|
|
var conflict = Repo.Index.Conflicts[normalized];
|
|
var blob = conflict is null
|
|
? null
|
|
: LookupBlob(conflict.Ours) ?? LookupBlob(conflict.Theirs) ?? LookupBlob(conflict.Ancestor);
|
|
|
|
if (blob is null) return DescribeWorkingFile(path);
|
|
|
|
var bytes = ReadBlobBytes(blob, normalized);
|
|
return new TextFileInfo(DescribeEncoding(bytes), DescribeNewLine(DecodeText(bytes)));
|
|
}
|
|
|
|
public TextFileInfo DescribeWorkingFile(string path)
|
|
{
|
|
var full = ToFullPath(path);
|
|
if (!File.Exists(full)) return new TextFileInfo("-", "-");
|
|
|
|
var bytes = File.ReadAllBytes(full);
|
|
return new TextFileInfo(DescribeEncoding(bytes), DescribeNewLine(DecodeText(bytes)));
|
|
}
|
|
|
|
private static string DescribeEncoding(byte[] bytes)
|
|
{
|
|
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) return "UTF-8 (BOM)";
|
|
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) return "UTF-16 LE";
|
|
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) return "UTF-16 BE";
|
|
|
|
try
|
|
{
|
|
Utf8Strict.GetString(bytes);
|
|
return "UTF-8";
|
|
}
|
|
catch (DecoderFallbackException)
|
|
{
|
|
// 시스템 코드페이지. 국내 장비 소스는 대개 CP949 다.
|
|
// 상태바와 저장 형식 목록이 같은 이름을 쓰게 한다(5차 A-1).
|
|
return AnsiEncoding.CodePage == 949 ? "CP949" : AnsiEncoding.WebName.ToUpperInvariant();
|
|
}
|
|
}
|
|
|
|
/// <summary>줄바꿈이 섞여 있으면 그 사실 자체가 정보다 - 감추지 않고 "혼합"으로 알린다.</summary>
|
|
private static string DescribeNewLine(string text)
|
|
{
|
|
if (text.Length == 0) return "-";
|
|
|
|
var crlf = 0; var lf = 0; var cr = 0;
|
|
|
|
for (var i = 0; i < text.Length; i++)
|
|
{
|
|
if (text[i] == 0x0D)
|
|
{
|
|
if (i + 1 < text.Length && text[i + 1] == 0x0A) { crlf++; i++; }
|
|
else cr++;
|
|
}
|
|
else if (text[i] == 0x0A) lf++;
|
|
}
|
|
|
|
var kinds = (crlf > 0 ? 1 : 0) + (lf > 0 ? 1 : 0) + (cr > 0 ? 1 : 0);
|
|
if (kinds == 0) return "없음";
|
|
if (kinds > 1) return "혼합";
|
|
|
|
return crlf > 0 ? "CRLF" : lf > 0 ? "LF" : "CR";
|
|
}
|
|
|
|
private string ToFullPath(string path) =>
|
|
Path.Combine(Repo.Info.WorkingDirectory, NormalizePath(path).Replace('/', Path.DirectorySeparatorChar));
|
|
|
|
private static string[] SplitLines(string text) =>
|
|
text.Length == 0 ? Array.Empty<string>() : text.Replace("\r\n", "\n").Split('\n');
|
|
|
|
private const byte Cr = 0x0D;
|
|
private const byte Lf = 0x0A;
|
|
|
|
private static bool ContainsCrLf(byte[] bytes)
|
|
{
|
|
for (var i = 1; i < bytes.Length; i++)
|
|
if (bytes[i] == Lf && bytes[i - 1] == Cr) return true;
|
|
return false;
|
|
}
|
|
|
|
private static string NormalizePath(string path) => path.Replace('\\', '/').TrimStart('/');
|
|
|
|
private static string[]? NormalizePaths(string[]? paths) =>
|
|
paths is null || paths.Length == 0 ? null : paths.Select(NormalizePath).ToArray();
|
|
|
|
private static string Shorten(string? sha) =>
|
|
string.IsNullOrEmpty(sha) ? string.Empty : (sha!.Length <= 7 ? sha : sha[..7]);
|
|
|
|
private Signature BuildSignature()
|
|
{
|
|
var name = Repo.Config.Get<string>("user.name")?.Value;
|
|
var email = Repo.Config.Get<string>("user.email")?.Value;
|
|
|
|
if (string.IsNullOrWhiteSpace(name)) name = Environment.UserName;
|
|
if (string.IsNullOrWhiteSpace(email)) email = $"{Environment.UserName}@localhost";
|
|
|
|
return new Signature(name, email, DateTimeOffset.Now);
|
|
}
|
|
|
|
private CredentialsHandler? BuildCredentialsProvider()
|
|
{
|
|
var credentials = Credentials;
|
|
if (credentials is null) return null;
|
|
|
|
return (_, _, _) => new UsernamePasswordCredentials
|
|
{
|
|
Username = credentials.Username,
|
|
Password = credentials.Password
|
|
};
|
|
}
|
|
|
|
private FetchOptions BuildFetchOptions()
|
|
{
|
|
var lastPercent = -1;
|
|
|
|
return new FetchOptions
|
|
{
|
|
CredentialsProvider = BuildCredentialsProvider(),
|
|
OnTransferProgress = progress =>
|
|
{
|
|
if (progress.TotalObjects <= 0) return true;
|
|
var percent = progress.ReceivedObjects * 100 / progress.TotalObjects;
|
|
if (percent != lastPercent && percent % 10 == 0)
|
|
{
|
|
lastPercent = percent;
|
|
Report($"수신 {percent}% ({progress.ReceivedObjects}/{progress.TotalObjects})");
|
|
}
|
|
return true;
|
|
}
|
|
};
|
|
}
|
|
|
|
public void Dispose() => Close();
|
|
}
|
|
}
|