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) return new MergeResultInfo(MergeOutcome.Failed, null, Array.Empty(), $"현재 브랜치 '{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 GetCommits(string? committish = null, int maxCount = 200) { 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; } 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(); 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 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 { 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(); 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 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; } 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() : ReadBlobBytes(oursBlob, normalized); var theirs = theirsBlob is null ? Array.Empty() : 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); // "원본" 은 워킹트리 파일이 아니라 내 쪽(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}"); } 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 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 "변경분 파일 추출" - 장비에 소스를 통째로 넣지 못하는 현장에서 /// 바뀐 파일만 들고 가야 할 때 쓴다. 저장소 안의 경로 구조를 그대로 만든다. /// /// 지워진 파일은 뽑을 것이 없으므로 목록에만 남기고 파일은 만들지 않는다. /// /// (뽑은 파일 수, 지워져서 건너뛴 파일 수) 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 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; } /// /// 워킹트리 파일의 인코딩과 줄바꿈 방식을 사람이 읽을 형태로 알려준다. /// 편집 화면 상태바에 띄워, 저장할 때 무엇이 유지되는지 사용자가 알 수 있게 한다. /// 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(); } }