4
0

LibGit2GitRepository.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  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. // The user opted into git by setting GIT_TOKEN. Auto-init on a fresh
  33. // config directory so the UI can immediately offer Add Remote — without
  34. // this, first-time users hit "Git is not available." on every action.
  35. // Init is idempotent for existing repos (IsValid skips the call) and
  36. // does not touch existing files; it only creates .git/. Failure (e.g.
  37. // a read-only mount) must not throw out of the singleton factory — the
  38. // UI relies on IsAvailable=false to render the writability warning.
  39. if (Directory.Exists(configDirectory) && !Repository.IsValid(configDirectory))
  40. try {
  41. Repository.Init(configDirectory);
  42. }
  43. catch {
  44. // Leave IsAvailable=false; surfaced via the writability warning.
  45. }
  46. _isAvailable = Repository.IsValid(configDirectory);
  47. // When insecureTls is true, accept any TLS certificate. Required for
  48. // self-hosted forges (Gitea, GitLab) behind a private CA or self-signed
  49. // cert. Public hosts already ship trusted certs; leave it off for them.
  50. InsecureTls = insecureTls;
  51. _certificateCheck = insecureTls
  52. ? (_, _, _) => true
  53. : null;
  54. }
  55. private FetchOptions BuildFetchOptions() => new() {
  56. CredentialsProvider = _credentials,
  57. CertificateCheck = _certificateCheck
  58. };
  59. private PushOptions BuildPushOptions() => new() {
  60. CredentialsProvider = _credentials,
  61. CertificateCheck = _certificateCheck
  62. };
  63. private bool _isAvailable;
  64. public bool IsAvailable => _isAvailable;
  65. public bool InsecureTls { get; }
  66. public void Init() {
  67. Repository.Init(_configDirectory);
  68. _isAvailable = true;
  69. }
  70. private Repository OpenRepo() => new(_configDirectory);
  71. private static Signature GetSignature(Repository repo) {
  72. var name = repo.Config.Get<string>("user.name")?.Value ?? "RackPeek";
  73. var email = repo.Config.Get<string>("user.email")?.Value ?? "rackpeek@local";
  74. return new Signature(name, email, DateTimeOffset.Now);
  75. }
  76. private static Remote GetRemote(Repository repo)
  77. => repo.Network.Remotes["origin"] ?? repo.Network.Remotes.First();
  78. public GitRepoStatus GetStatus() {
  79. if (!_isAvailable)
  80. return GitRepoStatus.NotAvailable;
  81. using Repository repo = OpenRepo();
  82. return repo.RetrieveStatus().IsDirty
  83. ? GitRepoStatus.Dirty
  84. : GitRepoStatus.Clean;
  85. }
  86. public void StageAll() {
  87. using Repository repo = OpenRepo();
  88. var files = repo.RetrieveStatus()
  89. .Where(e => e.State != FileStatus.Ignored)
  90. .Select(e => e.FilePath)
  91. .ToList();
  92. if (files.Count == 0)
  93. return;
  94. Commands.Stage(repo, files);
  95. }
  96. public void Commit(string message) {
  97. using Repository repo = OpenRepo();
  98. Signature signature = GetSignature(repo);
  99. repo.Commit(message, signature, signature);
  100. }
  101. public string GetDiff() {
  102. using Repository repo = OpenRepo();
  103. Tree? tree = repo.Head.Tip?.Tree;
  104. Patch patch = repo.Diff.Compare<Patch>(
  105. tree,
  106. DiffTargets.Index | DiffTargets.WorkingDirectory);
  107. return patch?.Content ?? string.Empty;
  108. }
  109. public string[] GetChangedFiles() {
  110. using Repository repo = OpenRepo();
  111. return repo.RetrieveStatus()
  112. .Where(e => e.State != FileStatus.Ignored)
  113. .Select(e => $"{GetPrefix(e.State)} {e.FilePath}")
  114. .ToArray();
  115. }
  116. private static string GetPrefix(FileStatus state) => state switch {
  117. FileStatus.NewInWorkdir or FileStatus.NewInIndex => "A",
  118. FileStatus.DeletedFromWorkdir or FileStatus.DeletedFromIndex => "D",
  119. FileStatus.RenamedInWorkdir or FileStatus.RenamedInIndex => "R",
  120. _ when state.HasFlag(FileStatus.ModifiedInWorkdir)
  121. || state.HasFlag(FileStatus.ModifiedInIndex) => "M",
  122. _ => "?"
  123. };
  124. public void RestoreAll() {
  125. using Repository repo = OpenRepo();
  126. repo.CheckoutPaths(
  127. repo.Head.FriendlyName,
  128. ["*"],
  129. new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
  130. repo.RemoveUntrackedFiles();
  131. }
  132. public string GetCurrentBranch() {
  133. using Repository repo = OpenRepo();
  134. return repo.Head.FriendlyName;
  135. }
  136. public GitLogEntry[] GetLog(int count) {
  137. using Repository repo = OpenRepo();
  138. if (repo.Head.Tip is null)
  139. return [];
  140. return repo.Commits
  141. .Take(count)
  142. .Select(c => new GitLogEntry(
  143. c.Sha[..7],
  144. c.MessageShort,
  145. c.Author.Name,
  146. FormatRelativeDate(c.Author.When)))
  147. .ToArray();
  148. }
  149. public bool HasRemote() {
  150. using Repository repo = OpenRepo();
  151. return repo.Network.Remotes.Any();
  152. }
  153. public GitSyncStatus FetchAndGetSyncStatus() {
  154. using Repository repo = OpenRepo();
  155. if (!repo.Network.Remotes.Any())
  156. return new GitSyncStatus(0, 0, false);
  157. Remote remote = GetRemote(repo);
  158. Commands.Fetch(
  159. repo,
  160. remote.Name,
  161. remote.FetchRefSpecs.Select(r => r.Specification),
  162. BuildFetchOptions(),
  163. null);
  164. // If the repo has no commits yet (unborn branch)
  165. if (repo.Head.Tip == null)
  166. return new GitSyncStatus(0, 0, true);
  167. Branch? remoteBranch = repo.Branches[$"{remote.Name}/{repo.Head.FriendlyName}"];
  168. if (remoteBranch?.Tip == null)
  169. return new GitSyncStatus(repo.Commits.Count(), 0, true);
  170. HistoryDivergence? divergence = repo.ObjectDatabase.CalculateHistoryDivergence(
  171. repo.Head.Tip,
  172. remoteBranch.Tip);
  173. return new GitSyncStatus(
  174. divergence.AheadBy ?? 0,
  175. divergence.BehindBy ?? 0,
  176. true);
  177. }
  178. public void Push() {
  179. using Repository repo = OpenRepo();
  180. Remote remote = GetRemote(repo);
  181. var branch = repo.Head.FriendlyName;
  182. var refSpec = $"refs/heads/{branch}:refs/heads/{branch}";
  183. try {
  184. repo.Network.Push(
  185. remote,
  186. refSpec,
  187. BuildPushOptions());
  188. }
  189. catch (NonFastForwardException) {
  190. PullInternal(repo);
  191. repo.Network.Push(
  192. remote,
  193. refSpec,
  194. BuildPushOptions());
  195. }
  196. if (repo.Head.TrackedBranch is null) {
  197. repo.Branches.Update(repo.Head,
  198. b => b.TrackedBranch = $"refs/remotes/{remote.Name}/{branch}");
  199. }
  200. }
  201. public void Pull() {
  202. using Repository repo = OpenRepo();
  203. PullInternal(repo);
  204. }
  205. private void PullInternal(Repository repo) {
  206. if (!repo.Network.Remotes.Any())
  207. return;
  208. Remote remote = GetRemote(repo);
  209. Commands.Fetch(
  210. repo,
  211. remote.Name,
  212. remote.FetchRefSpecs.Select(r => r.Specification),
  213. BuildFetchOptions(),
  214. null);
  215. Branch? remoteBranch = repo.Branches[$"{remote.Name}/{repo.Head.FriendlyName}"];
  216. if (remoteBranch?.Tip == null)
  217. return;
  218. // hard reset to remote branch
  219. repo.Reset(ResetMode.Hard, remoteBranch.Tip);
  220. repo.Branches.Update(repo.Head,
  221. b => b.TrackedBranch = remoteBranch.CanonicalName);
  222. }
  223. public void AddRemote(string name, string url) {
  224. using Repository repo = OpenRepo();
  225. if (repo.Network.Remotes[name] != null)
  226. return;
  227. repo.Network.Remotes.Add(name, url);
  228. Remote remote = repo.Network.Remotes[name];
  229. // fetch remote state
  230. Commands.Fetch(
  231. repo,
  232. remote.Name,
  233. remote.FetchRefSpecs.Select(r => r.Specification),
  234. BuildFetchOptions(),
  235. null);
  236. // detect if remote has a default branch
  237. Branch? remoteMain =
  238. repo.Branches[$"{remote.Name}/main"] ??
  239. repo.Branches[$"{remote.Name}/master"];
  240. var hasLocalFiles =
  241. repo.RetrieveStatus()
  242. .Any(e => e.State != FileStatus.Ignored);
  243. // CASE 1: remote repo already has commits
  244. if (remoteMain != null && remoteMain.Tip != null) {
  245. Branch local = repo.CreateBranch(remoteMain.FriendlyName, remoteMain.Tip);
  246. Commands.Checkout(repo, local);
  247. repo.Branches.Update(local,
  248. b => b.TrackedBranch = remoteMain.CanonicalName);
  249. if (hasLocalFiles) {
  250. // import existing config to a new branch
  251. var importBranchName = $"rackpeek-{DateTime.UtcNow:yyyyMMddHHmmss}";
  252. Branch importBranch = repo.CreateBranch(importBranchName);
  253. Commands.Checkout(repo, importBranch);
  254. Commands.Stage(repo, "*");
  255. Signature sig = GetSignature(repo);
  256. repo.Commit(
  257. "rackpeek: import existing config",
  258. sig,
  259. sig);
  260. repo.Network.Push(
  261. remote,
  262. $"refs/heads/{importBranchName}:refs/heads/{importBranchName}",
  263. BuildPushOptions());
  264. repo.Branches.Update(importBranch,
  265. b => b.TrackedBranch = $"refs/remotes/{remote.Name}/{importBranchName}");
  266. }
  267. return;
  268. }
  269. // CASE 2: remote repo is empty
  270. if (hasLocalFiles) {
  271. var branchName = "main";
  272. Branch branch = repo.CreateBranch(branchName);
  273. Commands.Checkout(repo, branch);
  274. Commands.Stage(repo, "*");
  275. Signature sig = GetSignature(repo);
  276. repo.Commit(
  277. "rackpeek: initial config",
  278. sig,
  279. sig);
  280. repo.Network.Push(
  281. remote,
  282. $"refs/heads/{branchName}:refs/heads/{branchName}",
  283. BuildPushOptions());
  284. repo.Branches.Update(branch,
  285. b => b.TrackedBranch = $"refs/remotes/{remote.Name}/{branchName}");
  286. }
  287. }
  288. private static string FormatRelativeDate(DateTimeOffset date) {
  289. TimeSpan diff = DateTimeOffset.Now - date;
  290. if (diff.TotalMinutes < 1) return "just now";
  291. if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes} minutes ago";
  292. if (diff.TotalHours < 24) return $"{(int)diff.TotalHours} hours ago";
  293. if (diff.TotalDays < 30) return $"{(int)diff.TotalDays} days ago";
  294. if (diff.TotalDays < 365) return $"{(int)(diff.TotalDays / 30)} months ago";
  295. return $"{(int)(diff.TotalDays / 365)} years ago";
  296. }
  297. }