using System; using System.Collections.Generic; using System.IO; using System.Linq; namespace MergeHub.Services { public enum StartupMode { /// 그냥 켠 것. 지금까지처럼 설정의 기본 파일을 연다. None, /// 파일 두 개를 나란히(`git difftool`, 탐색기 우클릭). Diff, /// 충돌 해결(`git mergetool`). BASE·LOCAL·REMOTE 를 받고 MERGED 에 쓴다. Merge, /// 폴더 비교. Folder, } /// /// 프로그램을 켤 때 넘어온 인자. /// /// 이것이 있어야 `git mergetool` 과 탐색기 우클릭이 이 프로그램을 부를 수 있다. /// (git config 에 등록하는 쪽은 전부터 있었는데 받는 쪽이 없어 다리가 반만 놓여 있었다.) /// /// 받는 모양은 두 가지다. /// - 이름표 붙은 것: --merge --base B --local L --remote R --merged M /// - 그냥 경로: 탐색기가 "%1" 하나만 주는 경우. 파일이면 비교, 폴더면 폴더 비교로 본다. /// public sealed class StartupArgs { public StartupMode Mode { get; private set; } = StartupMode.None; public string Base { get; private set; } = string.Empty; public string Local { get; private set; } = string.Empty; public string Remote { get; private set; } = string.Empty; /// 결과를 써 넣을 파일. `git mergetool` 이 이 파일을 다시 읽어 간다. public string Merged { get; private set; } = string.Empty; public string LeftFolder { get; private set; } = string.Empty; public string RightFolder { get; private set; } = string.Empty; /// /// 이름표 없이 그냥 넘어온 파일 경로. 탐색기가 "%1" 만 줄 때가 이 자리다. /// 이름표가 붙은 · 와 뜻이 달라 따로 둔다 - /// difftool 은 "내 것/상대 것"이지만 탐색기는 그냥 "먼저 고른 것/나중 것"이다. /// public string LooseLeft { get; private set; } = string.Empty; public string LooseRight { get; private set; } = string.Empty; /// /// 셋째부터 넘어온 경로(11차 F-3). 탐색기 [보내기] 는 고른 것을 모두 한 번에 넘기는데 비교는 둘이라, /// 앞의 둘만 열고 나머지는 알린다(사용자 결정). 예전에는 말없이 버렸다. /// public IReadOnlyList Extra { get; private set; } = Array.Empty(); /// /// 앞의 둘 가운데 파일도 폴더도 아닌 경로(11차 F-3). 있으면 아무것도 열지 않고 알린다. /// 260자를 넘는 경로는 탐색기가 8.3 짧은 이름으로 넘기는데, .NET Framework 가 긴 이름으로 펼친 뒤 /// 찾지 못한다 - 예전에는 폴더인데도 "파일 비교 요청" 으로 적히고 아무것도 안 열렸다. /// 이름표 붙은 경로(git difftool 의 지워진 쪽 등)는 따지지 않는다. /// public IReadOnlyList Missing { get; private set; } = Array.Empty(); /// 사용자에게 알릴 한 줄(없으면 null). 로그와 알림에 같은 말을 쓴다. public string? Notice { get { if (Missing.Count > 0) { var first = Missing[0]; var why = FullLength(first) >= 260 ? $"경로가 {FullLength(first)}자라 열 수 없습니다(260자가 넘는 경로는 아직 열지 못합니다)" : "찾을 수 없습니다"; var more = Missing.Count > 1 ? $" 외 {Missing.Count - 1}개" : string.Empty; return $"{Path.GetFileName(first.TrimEnd('\\', '/'))}{more} - {why}"; } if (Extra.Count > 0) { var names = string.Join(", ", Extra.Select(p => Path.GetFileName(p.TrimEnd('\\', '/')))); return $"{Extra.Count + 2}개를 골랐습니다 - 앞의 둘만 비교하고 {Extra.Count}개({names})는 두었습니다"; } return null; } } private static int FullLength(string path) { try { return Path.GetFullPath(path).Length; } catch (Exception e) when (e is ArgumentException || e is NotSupportedException || e is PathTooLongException) { return path.Length; } } /// 이번 실행에 넘어온 인자. 한 번만 읽는다. public static StartupArgs Current { get; } = Parse(SafeArgs()); private static IReadOnlyList SafeArgs() { try { var all = Environment.GetCommandLineArgs(); return all.Length > 1 ? new List(all).GetRange(1, all.Length - 1) : Array.Empty(); } catch (NotSupportedException) { return Array.Empty(); } } public static StartupArgs Parse(IReadOnlyList args) { var result = new StartupArgs(); if (args is null || args.Count == 0) return result; var loose = new List(); for (var i = 0; i < args.Count; i++) { var arg = args[i] ?? string.Empty; switch (arg.ToLowerInvariant()) { case "--merge": result.Mode = StartupMode.Merge; continue; case "--diff": result.Mode = StartupMode.Diff; continue; case "--folder": result.Mode = StartupMode.Folder; continue; case "--base": result.Base = Next(args, ref i); continue; case "--local": result.Local = Next(args, ref i); continue; case "--remote": result.Remote = Next(args, ref i); continue; case "--merged": result.Merged = Next(args, ref i); continue; } // 이름표가 없는 것은 경로로 본다. 셸에서 넘어오는 "%1" 이 이 자리다. if (!arg.StartsWith("-", StringComparison.Ordinal) && arg.Length > 0) loose.Add(arg); } Fill(result, loose); return result; } private static string Next(IReadOnlyList args, ref int i) => i + 1 < args.Count ? args[++i] : string.Empty; /// /// 이름표 없이 넘어온 경로를 자리에 맞춘다. /// /// 탐색기 우클릭은 고른 것 하나만 준다. 그때는 왼쪽만 채우고 오른쪽은 비워 둔다 - /// 사용자가 그 자리에서 상대를 고르면 되고, 아무것도 안 열리는 것보다 훨씬 낫다. /// private static void Fill(StartupArgs result, List loose) { if (loose.Count == 0) return; if (loose.Count > 2) result.Extra = loose.GetRange(2, loose.Count - 2); var missing = loose.Take(2).Where(p => !Exists(p)).ToList(); if (missing.Count > 0) { // 없는 것을 열려 하면 엉뚱한 갈래(폴더인데 파일 비교)로 가서 빈 화면이 된다. 아무것도 열지 않는다. result.Missing = missing; result.Mode = StartupMode.None; return; } var first = loose[0]; var second = loose.Count > 1 ? loose[1] : string.Empty; // 이름표로 이미 모드를 정했으면 남은 경로만 채운다. if (result.Mode == StartupMode.Folder || (result.Mode == StartupMode.None && IsFolder(first))) { result.Mode = StartupMode.Folder; if (result.LeftFolder.Length == 0) result.LeftFolder = first; if (result.RightFolder.Length == 0) result.RightFolder = second; return; } if (result.Mode == StartupMode.None) result.Mode = StartupMode.Diff; result.LooseLeft = first; result.LooseRight = second; } private static bool Exists(string path) { try { return File.Exists(path) || Directory.Exists(path); } catch (ArgumentException) { return false; } } private static bool IsFolder(string path) { try { return Directory.Exists(path); } catch (ArgumentException) { return false; } } /// /// 파일 비교 화면에 넣을 두 파일. /// `git difftool` 은 LOCAL 이 내 것, REMOTE 가 상대 것이라 왼쪽이 REMOTE 가 된다 - /// 이 도구의 왼쪽은 늘 "기준"이기 때문이다. /// public (string Left, string Right) DiffPair { get { // 그냥 넘어온 경로가 있으면 그 순서가 곧 왼쪽·오른쪽이다. if (LooseLeft.Length > 0 || LooseRight.Length > 0) return (LooseLeft, LooseRight); return Mode == StartupMode.Merge ? (Base, Merged.Length > 0 ? Merged : Local) : (Remote.Length > 0 ? Remote : Base, Local); } } /// /// `git mergetool` 이 세 벌(BASE · LOCAL · REMOTE)을 넘겼으면 네 칸으로 열 요청. 아니면 null. /// /// 예전에는 로 BASE 와 MERGED 두 칸만 열었다(6차 B-4 확인 중 발견). /// MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라 두 칸으로는 상대가 무엇을 바꿨는지 볼 수 없었다. /// REMOTE 가 없으면 풀 것이 없으므로 세 벌로 보지 않는다. BASE 는 없어도 된다(양쪽이 새로 만든 파일). /// MERGED 가 없으면 LOCAL 에 쓴다. /// public MergeToolRequest? ThreeWayMerge => Mode == StartupMode.Merge && LooseLeft.Length == 0 && Local.Length > 0 && Remote.Length > 0 ? new MergeToolRequest(Base, Local, Remote, Merged.Length > 0 ? Merged : Local) : null; public bool WantsFolder => Mode == StartupMode.Folder && LeftFolder.Length > 0; public bool WantsFiles => Mode is StartupMode.Diff or StartupMode.Merge && (DiffPair.Left.Length > 0 || DiffPair.Right.Length > 0); } /// `git mergetool` 이 넘긴 세 벌과 결과를 쓸 파일. public sealed record MergeToolRequest(string Base, string Local, string Remote, string Merged); }