병합 중에 충돌 파일을 고르면 BASE 와 내 것 두 칸뿐이었다. 읽기는 충돌 갈래(세 벌)로 했지만 켠 것은 지역 변수 threeWay 뿐이고, 화면 틀을 정하는 IsThreeWay 는 콤보에서 브랜치를 골랐을 때만 참이었다. 상대가 무엇을 바꿨는지 안 보인 채 풀게 됐다. - IsThreeWay: 콤보로 고른 3-Way 이거나 충돌 파일이라 켠 3분할이면 참. 충돌 쪽은 따로 둔 깃발이라 콤보 값은 건드리지 않는다 - LoadFileAsync: 행을 싣기 전에 틀부터 바꾸고, 읽기 갈래는 콤보로 고른 3-Way 만 본다(앞 파일의 3분할이 다음 파일로 새지 않게) - GetMergeTheirsName: MERGE_HEAD 를 가리키는 브랜치 이름, 없으면 병합 메시지의 이름, 그것도 없으면 짧은 해시 - MergeNames: 병합 메시지에서 이름 꺼내기(저장소 없이 검사) - REMOTE 제목에 상대 브랜치 이름 화면 확인 18건 통과(충돌 파일 네 칸, 제목 REMOTE · feature/theirs, 콤보 없이 해결로 저장, 병합이 끝나면 두 칸, 콤보 3-Way 는 예전과 같음). MergeSaveTest 14건 추가. 21벌 759건 통과(새로 빌드한 실행 파일). 경고 0, 오류 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1755 lines
72 KiB
C#
1755 lines
72 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)
|
|
return new MergeResultInfo(MergeOutcome.Failed, null, Array.Empty<ConflictInfo>(),
|
|
$"현재 브랜치 '{Repo.Head.FriendlyName}' 에 추적 중인 원격 브랜치가 없습니다.");
|
|
|
|
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)
|
|
{
|
|
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;
|
|
}
|
|
|
|
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>();
|
|
|
|
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);
|
|
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
|
|
{
|
|
PatchApplyOutcome.Applied => commitOnSuccess
|
|
? $"{operation} 완료 ({Shorten(sha)})"
|
|
: $"{operation} 적용됨 - 확인 후 Commit() 을 호출하세요.",
|
|
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}건 " +
|
|
"- 해결 후 RebaseContinue() 를 호출하세요.",
|
|
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;
|
|
}
|
|
|
|
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)
|
|
{
|
|
// 인코딩 변환 없이 바이트 단위로 이어붙인다 - CP949 등 원본 인코딩이 그대로 보존된다.
|
|
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);
|
|
|
|
using var output = File.Create(fullPath);
|
|
output.Write(ours, 0, ours.Length);
|
|
if (ours.Length > 0 && ours[ours.Length - 1] != Lf)
|
|
{
|
|
var newLine = ContainsCrLf(ours) ? new[] { Cr, Lf } : new[] { Lf };
|
|
output.Write(newLine, 0, newLine.Length);
|
|
}
|
|
output.Write(theirs, 0, theirs.Length);
|
|
}
|
|
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}");
|
|
}
|
|
|
|
public string CommitMerge(string? message = null)
|
|
{
|
|
var remaining = GetConflicts();
|
|
if (remaining.Count > 0)
|
|
throw new InvalidOperationException($"아직 해결되지 않은 충돌이 {remaining.Count}건 있습니다.");
|
|
|
|
if (string.IsNullOrWhiteSpace(message))
|
|
message = ReadMergeMessage() ?? $"Merge into {Repo.Head.FriendlyName}";
|
|
|
|
var signature = BuildSignature();
|
|
var commit = Repo.Commit(message, signature, signature);
|
|
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 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> 들고 가야 할 때 쓴다. 저장소 안의 경로 구조를 그대로 만든다.
|
|
///
|
|
/// 지워진 파일은 뽑을 것이 없으므로 목록에만 남기고 파일은 만들지 않는다.
|
|
/// </summary>
|
|
/// <returns>(뽑은 파일 수, 지워져서 건너뛴 파일 수)</returns>
|
|
public (int Written, int Skipped) ExportChangedFiles(string oldCommittish, string newCommittish, string targetDirectory)
|
|
{
|
|
var newCommit = ResolveCommit(newCommittish)
|
|
?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {newCommittish}");
|
|
|
|
Directory.CreateDirectory(targetDirectory);
|
|
|
|
var written = 0;
|
|
var skipped = 0;
|
|
|
|
foreach (var diff in DiffCommits(oldCommittish, newCommittish))
|
|
{
|
|
// 새 커밋 쪽에 파일이 없다(삭제됨). 뽑을 내용이 없다.
|
|
if (newCommit[diff.Path]?.Target is not Blob blob)
|
|
{
|
|
skipped++;
|
|
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++;
|
|
}
|
|
|
|
Report($"변경분 추출: {written}개 파일 → {targetDirectory} (삭제되어 건너뜀 {skipped}개)");
|
|
return (written, skipped);
|
|
}
|
|
|
|
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>
|
|
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();
|
|
}
|
|
}
|