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
{
///
/// LibGit2Sharp 기반 Git 조작 구현.
/// libgit2 네이티브 라이브러리가 .git 의 objects/refs/packfile 을 직접 읽으므로
/// git.exe 를 매번 새 프로세스로 띄우는 방식보다 이력 조회가 빠르다. (기획안 9p)
///
public sealed class GitService : IGitService
{
/// UTF-8 로 디코딩되지 않는 파일에 적용할 시스템 기본 코드페이지(한국어 Windows = CP949).
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? Progress;
private Repository Repo =>
_repo ?? throw new InvalidOperationException("저장소가 열려 있지 않습니다. Open() 또는 Clone() 을 먼저 호출하세요.");
private void Report(string message) => Progress?.Invoke(this, message);
// ================= 저장소 열기 =================
/// 지정 경로에서 위로 올라가며 .git 을 찾는다. 없으면 null.
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 GetBranches(bool includeRemote = true)
{
var list = new List();
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 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(), 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 GetCommits(string? committish = null, int maxCount = 200, bool allBranches = false)
{
var refsByTarget = new Dictionary>(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();
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();
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(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 GetFilesAtCommit(string committish)
{
var commit = ResolveCommit(committish)
?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {committish}");
var files = new List();
CollectTree(commit.Tree, files);
files.Sort(StringComparer.OrdinalIgnoreCase);
return files;
}
private static void CollectTree(Tree tree, List 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();
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));
}
///
/// git add -A.
/// 와일드카드("*") 를 그대로 넘기면 서브모듈 디렉터리에서 libgit2 가 'invalid path' 로 실패하므로,
/// 변경된 경로를 직접 모아서 스테이징한다.
///
public void StageAll()
{
var options = new StatusOptions
{
IncludeUntracked = true,
RecurseUntrackedDirs = true,
IncludeIgnored = false
};
var paths = new List();
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();
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 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();
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();
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 GetTags()
{
var list = new List();
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 GetSubmodules()
{
var list = new List();
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 GetConflicts()
{
var list = new List();
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;
}
/// 병합 때 충돌 파일 목록을 적어 두는 파일(.git 안). 첫 줄은 MERGE_HEAD, 나머지는 경로.
private const string MergeConflictListFile = "MERGEHUB_CONFLICTS";
///
/// 이번 병합에서 처음 난 충돌 파일을 적어 둔다(6차 C-1). 몇 개를 풀었는지 세려고 둔다 - 앱을 다시 켜도 이어진다.
///
/// git 인덱스의 해결 기록(REUC)으로 세면 안 된다. libgit2 는 저절로 병합된 파일도 거기 남겨서,
/// 충돌 26개 중 머지 주석 2개를 풀었을 뿐인데 "푼 것 9개 (9/33)" 로 나왔다.
///
private void RememberMergeConflicts(IReadOnlyList conflicts)
{
var head = ReadMergeHeadSha();
if (string.IsNullOrEmpty(head)) return;
var lines = new List { head! };
lines.AddRange(conflicts.Select(c => c.Path));
File.WriteAllLines(Path.Combine(Repo.Info.Path, MergeConflictListFile), lines, new UTF8Encoding(false));
}
///
/// 이번 병합에서 처음 난 충돌 파일 수(6차 C-1). 적어 둔 것이 없거나 다른 병합의 것이면 null -
/// MergeHub 밖에서 시작한 병합은 처음 수를 모른다.
///
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);
}
///
/// 쓸모없어진 충돌 목록 파일을 지운다(7차 B-1). 지웠으면 true.
/// 병합 커밋 · 작업 취소 직후와 상태를 다시 읽을 때마다 부른다 - 터미널에서 병합을 끝낸 경우도 다음 새로고침에 치운다.
/// 못 지워도(잠김 등) 다른 일을 막지 않는다. 다음에 다시 시도한다.
///
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) { }
}
/// 병합 커밋 본문에 "누가" 로 적을 이름(8차 F-1). 작업 공간이 상태를 읽을 때 채운다.
public string? ResolverName { get; set; }
///
/// 병합 충돌 하나를 어떻게 풀었는지 적어 둔다(8차 F-1). 병합 중이 아니면(cherry-pick · revert · rebase) 적지 않는다.
/// 적는 데 실패해도 푸는 일은 막지 않는다 - 기록은 덤이다.
///
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) { }
}
/// 지금 병합의 해결 내역 본문(8차 F-1). 적힌 것이 없으면 null.
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() : ReadBlobBytes(oursBlob, normalized);
var theirs = theirsBlob is null ? Array.Empty() : 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);
// "원본" 은 워킹트리 파일이 아니라 내 쪽(ours) blob 이다(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}");
}
///
/// 머지 주석만 다른 충돌 파일을 풀고 스테이징한다(6차 B-5). 푼 파일 경로를 돌려준다.
///
/// git 은 주석 줄도 글자로 보고 충돌을 낸다. 워킹트리에 남긴 충돌 표시를 읽어 모든 덩어리가
/// 주석을 빼면 같은 코드일 때만 푼다(). 양쪽 주석을 다 남긴다.
/// 형식(인코딩 · 줄끝)은 직접 편집 해결과 같은 길로 내 쪽 파일을 따른다.
/// 한쪽이 지운 파일 · 이진 파일은 보지 않는다.
///
public IReadOnlyList ResolveNoteOnlyConflicts()
{
var solved = new List();
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 이 남긴 병합 메시지를 다듬어 쓴다(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(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;
}
}
/// 파일 첫 줄의 40자 해시. 없으면 null.
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;
}
/// 그 커밋을 끝으로 가리키는 브랜치 이름. 로컬을 먼저 본다.
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(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}.";
}
///
/// 지금 커밋할 것이 있는가 - 인덱스가 HEAD 와 다른가(8차 B-2).
/// 이미 들어 있는 커밋을 cherry-pick 하면 가져올 변경이 없다. 그때 [커밋] 은 눌러 봐야 실패한다.
///
public bool HasStagedChanges()
{
var head = Repo.Head?.Tip;
if (head is null) return Repo.Index.Count > 0;
return Repo.Diff.Compare(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(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 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(
oldCommit?.Tree,
newCommit.Tree,
NormalizePaths(paths),
null,
BuildCompareOptions());
return BuildFileDiffs(patch);
}
///
/// 두 커밋 사이에 바뀐 파일만 골라 폴더로 뽑는다.
///
/// 기획안 4p "변경분 파일 추출" - 장비에 소스를 통째로 넣지 못하는 현장에서
/// 바뀐 파일만 들고 가야 할 때 쓴다. 저장소 안의 경로 구조를 그대로 만든다.
///
/// 지워진 파일은 뽑을 것이 없으므로 목록으로만 돌려주고 파일은 만들지 않는다 -
/// 그 목록이 안내 글의 "장비에서 지워야 할 파일" 이 된다(11차 A-2).
///
/// (뽑은 파일 수, 지워져서 건너뛴 파일 수, 무엇을 어떻게 했는지 목록)
public (int Written, int Skipped, IReadOnlyList Items)
ExportChangedFiles(string oldCommittish, string newCommittish, string targetDirectory)
{
var newCommit = ResolveCommit(newCommittish)
?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {newCommittish}");
Directory.CreateDirectory(targetDirectory);
var written = 0;
var skipped = 0;
var items = new List();
foreach (var diff in DiffCommits(oldCommittish, newCommittish))
{
// 새 커밋 쪽에 파일이 없다(삭제됨). 뽑을 내용이 없다.
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 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(
parent?.Tree,
commit.Tree,
NormalizePaths(paths),
null,
BuildCompareOptions());
return BuildFileDiffs(patch);
}
public IReadOnlyList DiffWorkingDirectory(bool stagedOnly = false, params string[] paths)
{
var targets = stagedOnly ? DiffTargets.Index : DiffTargets.Index | DiffTargets.WorkingDirectory;
using var patch = Repo.Diff.Compare(
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 BuildFileDiffs(Patch patch)
{
var list = new List();
foreach (var change in patch)
{
var lines = change.IsBinaryComparison
? (IReadOnlyList)Array.Empty()
: 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;
}
/// unified diff 텍스트를 줄 단위 구조로 변환한다.
internal static IReadOnlyList ParsePatch(string patchText, bool ignoreWhitespace, bool wordLevel = false)
{
var result = new List();
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 ?? 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;
}
}
///
/// 삭제/추가가 짝을 이루고 공백만 다른 경우 두 줄을 컨텍스트로 접는다.
/// (기획안 4p "whitespace-only diff ignore")
///
private static IReadOnlyList CollapseWhitespaceOnlyChanges(IReadOnlyList lines)
{
var result = new List(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 =================
/// 토큰 수가 이보다 많은 줄은 단어 비교를 건너뛴다(O(n*m) 방어).
private const int MaxWordDiffTokens = 400;
public (IReadOnlyList Old, IReadOnlyList 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) });
}
///
/// 두 줄을 토큰(단어/공백/기호)으로 쪼개 LCS 를 구하고, 공통 구간과 변경 구간을 나눈다.
/// 줄이 너무 길면 null 을 돌려주고 호출측이 줄 전체를 변경으로 처리한다.
///
private static (IReadOnlyList Old, IReadOnlyList 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();
var newSegments = new List();
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 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));
}
}
/// 식별자 / 숫자 / 공백 덩어리 / 단일 기호 단위로 자른다.
private static List Tokenize(string line)
{
var tokens = new List();
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;
}
/// 짝을 이루는 삭제/추가 줄에 단어 단위 변경 구간을 채운다.
private static void ApplyWordLevelDiff(List 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 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(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 GetStashes()
{
var list = new List();
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(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>? refsByTarget)
{
List? 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());
}
private Blob? LookupBlob(IndexEntry? entry) =>
entry is null ? null : Repo.Lookup(entry.Id);
private bool IsBinaryEntry(IndexEntry? entry) => LookupBlob(entry)?.IsBinary ?? false;
///
/// blob 본문을 읽는다. hintPath 를 주면 git 의 checkout 필터(core.autocrlf, .gitattributes 의
/// text/eol 속성)를 적용해 워킹트리에 기록될 형태 그대로 얻는다.
/// 이걸 빼면 CRLF 저장소의 파일이 LF 로 바뀌어 전체 줄이 변경으로 잡힌다.
///
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));
///
/// BOM -> UTF-8 유효성 -> 시스템 코드페이지 순으로 인코딩을 판별한다.
/// 사내 C/C++ 소스가 CP949 로 저장된 경우 UTF-8 로 강제 디코딩하면 한글이 깨진다.
///
///
/// 인코딩 판별은 한 곳에서만 한다(5차 A-1).
///
/// 전에는 여기에 똑같은 코드가 한 벌 더 있었다. 주석으로 "TextFile 과 같아야 한다" 고
/// 적어 두었지만 지키는 방법이 복사뿐이라 언젠가 어긋난다 - 같은 파일을 워킹트리로 열 때와
/// 커밋에서 열 때 한글이 다르게 보이면 어느 쪽이 맞는지 알 수도 없다.
///
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);
}
/// 워킹트리 파일을 인코딩 자동 판별로 읽는다. (충돌 마커가 들어간 상태 그대로)
public string ReadWorkingFileText(string path)
{
var full = ToFullPath(path);
return File.Exists(full) ? DecodeText(File.ReadAllBytes(full)) : string.Empty;
}
///
/// 워킹트리 파일의 인코딩과 줄바꿈 방식을 사람이 읽을 형태로 알려준다.
/// 편집 화면 상태바에 띄워, 저장할 때 무엇이 유지되는지 사용자가 알 수 있게 한다.
///
///
/// 병합 중 충돌 파일의 인코딩과 줄바꿈 - 내 쪽(ours) 파일 기준(6차 B-7).
///
/// 워킹트리 파일에는 git 이 충돌 표시를 LF 로 넣어 두어서, 양쪽 다 CRLF 인 파일도 "혼합" 으로 읽혔다.
/// 저장(해결로 저장)도 내 쪽 파일의 형식을 따르므로(6차 A-1) 상태바가 같은 것을 보여야 한다.
/// 내 쪽이 없으면(내가 지운 파일) 상대 쪽, 그것도 없으면 공통 조상. 충돌이 아니면 워킹트리 파일을 본다.
///
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();
}
}
/// 줄바꿈이 섞여 있으면 그 사실 자체가 정보다 - 감추지 않고 "혼합"으로 알린다.
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() : 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("user.name")?.Value;
var email = Repo.Config.Get("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();
}
}