LibGit2GitRepository.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. using LibGit2Sharp;
  2. using LibGit2Sharp.Handlers;
  3. namespace RackPeek.Domain.Git;
  4. public interface IGitCredentialsProvider {
  5. CredentialsHandler GetHandler();
  6. }
  7. /// <summary>
  8. /// HTTP Basic auth using a personal access token as the password. Works with
  9. /// any forge that accepts a token over HTTPS — GitHub, Gitea, GitLab, Bitbucket,
  10. /// Forgejo, etc.
  11. /// </summary>
  12. public sealed class TokenCredentialsProvider(string username, string token) : IGitCredentialsProvider {
  13. private readonly string _username = username ?? throw new ArgumentNullException(nameof(username));
  14. private readonly string _token = token ?? throw new ArgumentNullException(nameof(token));
  15. public CredentialsHandler GetHandler() {
  16. return (_, _, _) => new UsernamePasswordCredentials {
  17. Username = _username,
  18. Password = _token
  19. };
  20. }
  21. }
  22. public sealed class LibGit2GitRepository : IGitRepository {
  23. private readonly string _configDirectory;
  24. private readonly CredentialsHandler _credentials;
  25. private readonly CertificateCheckHandler? _certificateCheck;
  26. public LibGit2GitRepository(
  27. string configDirectory,
  28. IGitCredentialsProvider credentialsProvider,
  29. bool insecureTls = false) {
  30. _configDirectory = configDirectory;
  31. _credentials = credentialsProvider.GetHandler();
  32. _isAvailable = Repository.IsValid(configDirectory);
  33. // When insecureTls is true, accept any TLS certificate. Required for
  34. // self-hosted forges (Gitea, GitLab) behind a private CA or self-signed
  35. // cert. Public hosts already ship trusted certs; leave it off for them.
  36. _certificateCheck = insecureTls
  37. ? (_, _, _) => true
  38. : null;
  39. }
  40. private FetchOptions BuildFetchOptions() => new() {
  41. CredentialsProvider = _credentials,
  42. CertificateCheck = _certificateCheck
  43. };
  44. private PushOptions BuildPushOptions() => new() {
  45. CredentialsProvider = _credentials,
  46. CertificateCheck = _certificateCheck
  47. };
  48. private bool _isAvailable;
  49. public bool IsAvailable => _isAvailable;
  50. public void Init() {
  51. Repository.Init(_configDirectory);
  52. _isAvailable = true;
  53. }
  54. private Repository OpenRepo() => new(_configDirectory);
  55. private static Signature GetSignature(Repository repo) {
  56. var name = repo.Config.Get<string>("user.name")?.Value ?? "RackPeek";
  57. var email = repo.Config.Get<string>("user.email")?.Value ?? "rackpeek@local";
  58. return new Signature(name, email, DateTimeOffset.Now);
  59. }
  60. private static Remote GetRemote(Repository repo)
  61. => repo.Network.Remotes["origin"] ?? repo.Network.Remotes.First();
  62. public GitRepoStatus GetStatus() {
  63. if (!_isAvailable)
  64. return GitRepoStatus.NotAvailable;
  65. using Repository repo = OpenRepo();
  66. return repo.RetrieveStatus().IsDirty
  67. ? GitRepoStatus.Dirty
  68. : GitRepoStatus.Clean;
  69. }
  70. public void StageAll() {
  71. using Repository repo = OpenRepo();
  72. var files = repo.RetrieveStatus()
  73. .Where(e => e.State != FileStatus.Ignored)
  74. .Select(e => e.FilePath)
  75. .ToList();
  76. if (files.Count == 0)
  77. return;
  78. Commands.Stage(repo, files);
  79. }
  80. public void Commit(string message) {
  81. using Repository repo = OpenRepo();
  82. Signature signature = GetSignature(repo);
  83. repo.Commit(message, signature, signature);
  84. }
  85. public string GetDiff() {
  86. using Repository repo = OpenRepo();
  87. Tree? tree = repo.Head.Tip?.Tree;
  88. Patch patch = repo.Diff.Compare<Patch>(
  89. tree,
  90. DiffTargets.Index | DiffTargets.WorkingDirectory);
  91. return patch?.Content ?? string.Empty;
  92. }
  93. public string[] GetChangedFiles() {
  94. using Repository repo = OpenRepo();
  95. return repo.RetrieveStatus()
  96. .Where(e => e.State != FileStatus.Ignored)
  97. .Select(e => $"{GetPrefix(e.State)} {e.FilePath}")
  98. .ToArray();
  99. }
  100. private static string GetPrefix(FileStatus state) => state switch {
  101. FileStatus.NewInWorkdir or FileStatus.NewInIndex => "A",
  102. FileStatus.DeletedFromWorkdir or FileStatus.DeletedFromIndex => "D",
  103. FileStatus.RenamedInWorkdir or FileStatus.RenamedInIndex => "R",
  104. _ when state.HasFlag(FileStatus.ModifiedInWorkdir)
  105. || state.HasFlag(FileStatus.ModifiedInIndex) => "M",
  106. _ => "?"
  107. };
  108. public void RestoreAll() {
  109. using Repository repo = OpenRepo();
  110. repo.CheckoutPaths(
  111. repo.Head.FriendlyName,
  112. ["*"],
  113. new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
  114. repo.RemoveUntrackedFiles();
  115. }
  116. public string GetCurrentBranch() {
  117. using Repository repo = OpenRepo();
  118. return repo.Head.FriendlyName;
  119. }
  120. public GitLogEntry[] GetLog(int count) {
  121. using Repository repo = OpenRepo();
  122. if (repo.Head.Tip is null)
  123. return [];
  124. return repo.Commits
  125. .Take(count)
  126. .Select(c => new GitLogEntry(
  127. c.Sha[..7],
  128. c.MessageShort,
  129. c.Author.Name,
  130. FormatRelativeDate(c.Author.When)))
  131. .ToArray();
  132. }
  133. public bool HasRemote() {
  134. using Repository repo = OpenRepo();
  135. return repo.Network.Remotes.Any();
  136. }
  137. public GitSyncStatus FetchAndGetSyncStatus() {
  138. using Repository repo = OpenRepo();
  139. if (!repo.Network.Remotes.Any())
  140. return new GitSyncStatus(0, 0, false);
  141. Remote remote = GetRemote(repo);
  142. Commands.Fetch(
  143. repo,
  144. remote.Name,
  145. remote.FetchRefSpecs.Select(r => r.Specification),
  146. BuildFetchOptions(),
  147. null);
  148. // If the repo has no commits yet (unborn branch)
  149. if (repo.Head.Tip == null)
  150. return new GitSyncStatus(0, 0, true);
  151. Branch? remoteBranch = repo.Branches[$"{remote.Name}/{repo.Head.FriendlyName}"];
  152. if (remoteBranch?.Tip == null)
  153. return new GitSyncStatus(repo.Commits.Count(), 0, true);
  154. HistoryDivergence? divergence = repo.ObjectDatabase.CalculateHistoryDivergence(
  155. repo.Head.Tip,
  156. remoteBranch.Tip);
  157. return new GitSyncStatus(
  158. divergence.AheadBy ?? 0,
  159. divergence.BehindBy ?? 0,
  160. true);
  161. }
  162. public void Push() {
  163. using Repository repo = OpenRepo();
  164. Remote remote = GetRemote(repo);
  165. var branch = repo.Head.FriendlyName;
  166. var refSpec = $"refs/heads/{branch}:refs/heads/{branch}";
  167. try {
  168. repo.Network.Push(
  169. remote,
  170. refSpec,
  171. BuildPushOptions());
  172. }
  173. catch (NonFastForwardException) {
  174. PullInternal(repo);
  175. repo.Network.Push(
  176. remote,
  177. refSpec,
  178. BuildPushOptions());
  179. }
  180. if (repo.Head.TrackedBranch is null) {
  181. repo.Branches.Update(repo.Head,
  182. b => b.TrackedBranch = $"refs/remotes/{remote.Name}/{branch}");
  183. }
  184. }
  185. public void Pull() {
  186. using Repository repo = OpenRepo();
  187. PullInternal(repo);
  188. }
  189. private void PullInternal(Repository repo) {
  190. if (!repo.Network.Remotes.Any())
  191. return;
  192. Remote remote = GetRemote(repo);
  193. Commands.Fetch(
  194. repo,
  195. remote.Name,
  196. remote.FetchRefSpecs.Select(r => r.Specification),
  197. BuildFetchOptions(),
  198. null);
  199. Branch? remoteBranch = repo.Branches[$"{remote.Name}/{repo.Head.FriendlyName}"];
  200. if (remoteBranch?.Tip == null)
  201. return;
  202. // hard reset to remote branch
  203. repo.Reset(ResetMode.Hard, remoteBranch.Tip);
  204. repo.Branches.Update(repo.Head,
  205. b => b.TrackedBranch = remoteBranch.CanonicalName);
  206. }
  207. public void AddRemote(string name, string url) {
  208. using Repository repo = OpenRepo();
  209. if (repo.Network.Remotes[name] != null)
  210. return;
  211. repo.Network.Remotes.Add(name, url);
  212. Remote remote = repo.Network.Remotes[name];
  213. // fetch remote state
  214. Commands.Fetch(
  215. repo,
  216. remote.Name,
  217. remote.FetchRefSpecs.Select(r => r.Specification),
  218. BuildFetchOptions(),
  219. null);
  220. // detect if remote has a default branch
  221. Branch? remoteMain =
  222. repo.Branches[$"{remote.Name}/main"] ??
  223. repo.Branches[$"{remote.Name}/master"];
  224. var hasLocalFiles =
  225. repo.RetrieveStatus()
  226. .Any(e => e.State != FileStatus.Ignored);
  227. // CASE 1: remote repo already has commits
  228. if (remoteMain != null && remoteMain.Tip != null) {
  229. Branch local = repo.CreateBranch(remoteMain.FriendlyName, remoteMain.Tip);
  230. Commands.Checkout(repo, local);
  231. repo.Branches.Update(local,
  232. b => b.TrackedBranch = remoteMain.CanonicalName);
  233. if (hasLocalFiles) {
  234. // import existing config to a new branch
  235. var importBranchName = $"rackpeek-{DateTime.UtcNow:yyyyMMddHHmmss}";
  236. Branch importBranch = repo.CreateBranch(importBranchName);
  237. Commands.Checkout(repo, importBranch);
  238. Commands.Stage(repo, "*");
  239. Signature sig = GetSignature(repo);
  240. repo.Commit(
  241. "rackpeek: import existing config",
  242. sig,
  243. sig);
  244. repo.Network.Push(
  245. remote,
  246. $"refs/heads/{importBranchName}:refs/heads/{importBranchName}",
  247. BuildPushOptions());
  248. repo.Branches.Update(importBranch,
  249. b => b.TrackedBranch = $"refs/remotes/{remote.Name}/{importBranchName}");
  250. }
  251. return;
  252. }
  253. // CASE 2: remote repo is empty
  254. if (hasLocalFiles) {
  255. var branchName = "main";
  256. Branch branch = repo.CreateBranch(branchName);
  257. Commands.Checkout(repo, branch);
  258. Commands.Stage(repo, "*");
  259. Signature sig = GetSignature(repo);
  260. repo.Commit(
  261. "rackpeek: initial config",
  262. sig,
  263. sig);
  264. repo.Network.Push(
  265. remote,
  266. $"refs/heads/{branchName}:refs/heads/{branchName}",
  267. BuildPushOptions());
  268. repo.Branches.Update(branch,
  269. b => b.TrackedBranch = $"refs/remotes/{remote.Name}/{branchName}");
  270. }
  271. }
  272. private static string FormatRelativeDate(DateTimeOffset date) {
  273. TimeSpan diff = DateTimeOffset.Now - date;
  274. if (diff.TotalMinutes < 1) return "just now";
  275. if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes} minutes ago";
  276. if (diff.TotalHours < 24) return $"{(int)diff.TotalHours} hours ago";
  277. if (diff.TotalDays < 30) return $"{(int)diff.TotalDays} days ago";
  278. if (diff.TotalDays < 365) return $"{(int)(diff.TotalDays / 30)} months ago";
  279. return $"{(int)(diff.TotalDays / 365)} years ago";
  280. }
  281. }