Blog/Services/GitService.cs 23.5 K · 581 lines · raw · history

1 using System.Text.RegularExpressions;
2 using Blog.Models;
3 using LibGit2Sharp;
4 using Microsoft.Extensions.Options;
5
6 namespace Blog.Services;
7
8 /// <summary>Where the repositories are, and how much of one page is allowed to show.</summary>
9 public sealed class GitOptions
10 {
11 public const string Section = "Git";
12
13 /// <summary>
14 /// The directory the bare repositories sit in — the same scan path cgit was pointed at. A
15 /// leading <c>~/</c> is expanded, so a development machine can point this at a checkout
16 /// directory without hardcoding a home directory.
17 /// </summary>
18 public string RepositoryRoot { get; set; } = "/home/git";
19
20 /// <summary>
21 /// The clone line shown on a repository's page, with <c>{repo}</c> standing in for its
22 /// directory name. Cloning goes over ssh; nothing here serves the git protocol.
23 /// </summary>
24 public string? CloneUrl { get; set; } = "ssh://git@git.bes.is/{repo}";
25
26 /// <summary>
27 /// How many diff lines one commit page may render before the remaining files keep their stats
28 /// and lose their hunks. A commit that vendors a 4 MB file is a real thing that happens in
29 /// these repositories, and it should not become a 4 MB page.
30 /// </summary>
31 public int MaxDiffLines { get; set; } = 3000;
32
33 /// <summary>
34 /// The same ceiling measured in characters, because the two run out independently: three
35 /// thousand lines of ordinary code is a couple of hundred kilobytes, and three thousand lines
36 /// of machine-generated HTML is several megabytes. Whichever budget empties first ends the
37 /// diff.
38 /// </summary>
39 public int MaxDiffCharacters { get; set; } = 250_000;
40
41 /// <summary>Blobs past this size are linked rather than printed.</summary>
42 public int MaxBlobBytes { get; set; } = 512 * 1024;
43
44 /// <summary>
45 /// The largest file the raw endpoint will hand over. It reads the blob into memory to serve
46 /// it — streaming would mean keeping the repository open across the response — so this is the
47 /// ceiling on what one request can allocate. The biggest thing in these repositories is a
48 /// 4 MB vendored HTML file.
49 /// </summary>
50 public int MaxRawBytes { get; set; } = 25 * 1024 * 1024;
51 }
52
53 /// <summary>
54 /// Reads the git repositories on this server for the pages under <c>/git</c>.
55 /// </summary>
56 /// <remarks>
57 /// Every method opens a <see cref="Repository"/>, copies what it needs into the plain records in
58 /// <c>Models/GitRepositories.cs</c>, and closes it again. That is deliberate: libgit2's objects are
59 /// handles into an open repository and a <see cref="Repository"/> is not thread-safe, so keeping
60 /// one alive across requests would mean either a lock around the whole site or handles outliving
61 /// the thing they point into. Opening a repository is a couple of file reads — cheap enough to do
62 /// per request, and it means a push is visible immediately with nothing to invalidate.
63 ///
64 /// Repository names arrive from the URL, so <see cref="Resolve"/> is the only way this class turns
65 /// one into a path: a name is a single directory entry under the root, and anything else is a miss.
66 /// </remarks>
67 public sealed partial class GitService(IOptions<GitOptions> options, ILogger<GitService> logger)
68 {
69 private readonly GitOptions _options = options.Value;
70
71 /// <summary>The scan path, with <c>~/</c> expanded.</summary>
72 public string Root { get; } = Expand(options.Value.RepositoryRoot);
73
74 public string? CloneUrl(string repository) =>
75 _options.CloneUrl is { Length: > 0 } url ? url.Replace("{repo}", repository, StringComparison.Ordinal) : null;
76
77 /// <summary>Every repository under the root, by name. Missing root reads as no repositories.</summary>
78 public IReadOnlyList<GitRepoSummary> ListRepositories()
79 {
80 if (!Directory.Exists(Root))
81 {
82 logger.LogWarning("The git repository root {Root} does not exist", Root);
83 return [];
84 }
85
86 var found = new List<GitRepoSummary>();
87
88 foreach (var directory in Directory.EnumerateDirectories(Root))
89 {
90 var name = Path.GetFileName(directory);
91 if (!IsRepositoryName(name)) continue;
92
93 try
94 {
95 using var repository = Open(directory);
96 if (repository is null) continue;
97 found.Add(Summarise(repository, name));
98 }
99 catch (Exception exception) when (exception is LibGit2SharpException or IOException or UnauthorizedAccessException)
100 {
101 // One unreadable repository is not a reason for the index to fail.
102 logger.LogWarning(exception, "Could not read the repository at {Directory}", directory);
103 }
104 }
105
106 return found.OrderBy(repository => repository.DisplayName, StringComparer.OrdinalIgnoreCase).ToArray();
107 }
108
109 public GitRepoSummary? GetRepository(string name) =>
110 WithRepository(name, repository => Summarise(repository, name));
111
112 /// <summary>
113 /// A page of history for <paramref name="reference"/>, oldest-last. <paramref name="path"/>
114 /// narrows it to the commits that touched one file or directory.
115 /// </summary>
116 public GitLogPage? GetLog(string name, string? reference, string? path, int skip, int take) =>
117 WithRepository(name, repository =>
118 {
119 if (Resolve(repository, reference) is not { } commit) return null;
120
121 var tips = RefsByCommit(repository);
122
123 var filter = new CommitFilter { IncludeReachableFrom = commit, SortBy = CommitSortStrategies.Time };
124 var commits = string.IsNullOrEmpty(path)
125 ? repository.Commits.QueryBy(filter)
126 : repository.Commits.QueryBy(path, filter).Select(entry => entry.Commit);
127
128 // One past the page, so the pager knows whether there is a next one without counting
129 // the whole history.
130 var page = commits.Skip(skip).Take(take + 1).ToArray();
131
132 return new GitLogPage(
133 page.Take(take)
134 .Select(item => ToCommit(item, tips.GetValueOrDefault(item.Sha) ?? []))
135 .ToArray(),
136 page.Length > take);
137 });
138
139 /// <summary>One commit, with its diff against its first parent — a merge included, as git does.</summary>
140 public GitCommitView? GetCommit(string name, string sha) =>
141 WithRepository(name, repository =>
142 {
143 if (Resolve(repository, sha) is not { } commit) return null;
144
145 var parent = commit.Parents.FirstOrDefault();
146 var compare = new CompareOptions { Similarity = SimilarityOptions.Renames };
147
148 using var patch = repository.Diff.Compare<Patch>(parent?.Tree, commit.Tree, compare);
149
150 return new GitCommitView(
151 ToCommit(commit, RefsByCommit(repository).GetValueOrDefault(commit.Sha) ?? []),
152 ToDiff(patch));
153 });
154
155 /// <summary>
156 /// One path in one revision. The result carries a directory listing or a file, depending on
157 /// what the path turned out to be; both empty means the path is not in that revision.
158 /// </summary>
159 public GitPathView? GetPath(string name, string? reference, string? path) =>
160 WithRepository(name, repository =>
161 {
162 if (Resolve(repository, reference) is not { } commit) return null;
163
164 var head = reference is { Length: > 0 } ? reference : DefaultReference(repository);
165 var info = ToCommit(commit);
166 path = path?.Trim('/');
167
168 if (string.IsNullOrEmpty(path))
169 {
170 return new GitPathView(info, head, "", Entries(commit.Tree, ""), null);
171 }
172
173 if (commit[path] is not { } entry) return new GitPathView(info, head, path, null, null);
174
175 return entry.TargetType switch
176 {
177 TreeEntryTargetType.Tree =>
178 new GitPathView(info, head, path, Entries((Tree)entry.Target, path), null),
179 TreeEntryTargetType.Blob =>
180 new GitPathView(info, head, path, null, ToBlob((Blob)entry.Target, path)),
181 // A submodule's commit lives in another repository, so there is nothing to open.
182 _ => new GitPathView(info, head, path, null, null)
183 };
184 });
185
186 /// <summary>The bytes of one blob, for the raw endpoint. Null when the path is not a file.</summary>
187 public GitRawBlob? GetRawBlob(string name, string? reference, string path) =>
188 WithRepository(name, repository =>
189 {
190 path = path.Trim('/');
191 if (Resolve(repository, reference) is not { } commit) return null;
192 if (commit[path]?.Target is not Blob blob) return null;
193 if (blob.Size > _options.MaxRawBytes) return null;
194
195 using var content = blob.GetContentStream();
196 using var buffer = new MemoryStream();
197 content.CopyTo(buffer);
198
199 return new GitRawBlob(buffer.ToArray(), blob.IsBinary, Path.GetFileName(path));
200 });
201
202 /// <summary>Branches and tags, each with the commit it points at.</summary>
203 public GitRefsView? GetRefs(string name) =>
204 WithRepository(name, repository =>
205 {
206 var head = repository.Info.IsHeadDetached ? null : repository.Head.FriendlyName;
207
208 // Local branches only. A bare repository on a server has no remotes, and on a working
209 // checkout every origin/* duplicate would double the list and the badges on the log.
210 var branches = repository.Branches
211 .Where(branch => !branch.IsRemote && branch.Tip is not null)
212 .Select(branch => new GitRef(branch.FriendlyName, false, branch.Tip.Sha, ToCommit(branch.Tip), null))
213 // The branch HEAD points at first, then whichever moved most recently.
214 .OrderByDescending(branch => branch.Name == head)
215 .ThenByDescending(branch => branch.Tip!.Committer.When)
216 .ToArray();
217
218 var tags = repository.Tags
219 .Select(tag => new GitRef(
220 tag.FriendlyName,
221 true,
222 tag.Target.Sha,
223 tag.PeeledTarget is Commit peeled ? ToCommit(peeled) : null,
224 tag.Annotation?.Message))
225 .OrderByDescending(tag => tag.Tip?.Committer.When ?? DateTimeOffset.MinValue)
226 .ToArray();
227
228 return new GitRefsView(branches, tags);
229 });
230
231 /// <summary>
232 /// Turns a name from the URL into a directory under the root. A repository is one entry in that
233 /// directory and nothing else: no separators, no traversal, and the resolved path's parent has
234 /// to be the root itself.
235 /// </summary>
236 private string? Resolve(string name)
237 {
238 if (!IsRepositoryName(name)) return null;
239
240 var path = Path.GetFullPath(Path.Combine(Root, name));
241 if (Path.GetDirectoryName(path) != Path.GetFullPath(Root).TrimEnd(Path.DirectorySeparatorChar)) return null;
242
243 return Directory.Exists(path) ? path : null;
244 }
245
246 /// <summary>Opens a repository by name and hands it to <paramref name="read"/> for as long as it lives.</summary>
247 private T? WithRepository<T>(string name, Func<Repository, T?> read) where T : class
248 {
249 if (Resolve(name) is not { } directory) return null;
250
251 try
252 {
253 using var repository = Open(directory);
254 return repository is null ? null : read(repository);
255 }
256 catch (Exception exception) when (exception is LibGit2SharpException or IOException or UnauthorizedAccessException)
257 {
258 logger.LogWarning(exception, "Could not read the repository {Name}", name);
259 return null;
260 }
261 }
262
263 /// <summary>
264 /// Bare repositories are the point of the scan path, but a working checkout keeps its git
265 /// directory one level down — worth handling, since that is what a development root holds.
266 /// </summary>
267 private static Repository? Open(string directory)
268 {
269 if (Repository.IsValid(directory)) return new Repository(directory);
270
271 var dotGit = Path.Combine(directory, ".git");
272 return Repository.IsValid(dotGit) ? new Repository(dotGit) : null;
273 }
274
275 private GitRepoSummary Summarise(Repository repository, string name)
276 {
277 var tip = repository.Head.Tip;
278
279 return new GitRepoSummary(
280 name,
281 name.EndsWith(".git", StringComparison.OrdinalIgnoreCase) ? name[..^4] : name,
282 Description(repository),
283 repository.Config.Get<string>("gitweb.owner")?.Value,
284 repository.Info.IsHeadDetached || tip is null ? null : repository.Head.FriendlyName,
285 tip is null ? null : ToCommit(tip));
286 }
287
288 /// <summary>
289 /// The <c>description</c> file next to the objects, which is what cgit shows and what a hook
290 /// on the server is most likely to set. <c>git init</c> writes a placeholder into it, and a
291 /// placeholder is not a description.
292 /// </summary>
293 private string? Description(Repository repository)
294 {
295 var path = Path.Combine(repository.Info.Path, "description");
296 if (!File.Exists(path)) return null;
297
298 try
299 {
300 var text = File.ReadAllText(path).Trim();
301 return text.Length == 0 || text.StartsWith("Unnamed repository", StringComparison.Ordinal) ? null : text;
302 }
303 catch (IOException exception)
304 {
305 logger.LogWarning(exception, "Could not read {Path}", path);
306 return null;
307 }
308 }
309
310 /// <summary>
311 /// Resolves a branch name, tag, or hash, peeling an annotated tag down to the commit it marks.
312 /// Null <paramref name="reference"/> means the repository's own HEAD.
313 /// </summary>
314 private static Commit? Resolve(Repository repository, string? reference)
315 {
316 if (string.IsNullOrWhiteSpace(reference)) return repository.Head.Tip;
317
318 try
319 {
320 return Peel(repository.Lookup(reference));
321 }
322 catch (LibGit2SharpException)
323 {
324 // An unparseable revision is a 404, not a 500.
325 return null;
326 }
327 }
328
329 private static Commit? Peel(GitObject? item) => item switch
330 {
331 Commit commit => commit,
332 TagAnnotation tag => Peel(tag.Target),
333 _ => null
334 };
335
336 private static string DefaultReference(Repository repository) =>
337 repository.Info.IsHeadDetached ? repository.Head.Tip?.Sha ?? "HEAD" : repository.Head.FriendlyName;
338
339 /// <summary>
340 /// Which branch and tag names point at each commit, for the badges on the log. Built once per
341 /// page: the alternative is a ref walk per row.
342 /// </summary>
343 private static Dictionary<string, List<string>> RefsByCommit(Repository repository)
344 {
345 var tips = new Dictionary<string, List<string>>(StringComparer.Ordinal);
346
347 void Add(string? sha, string label)
348 {
349 if (sha is null) return;
350 if (!tips.TryGetValue(sha, out var names)) tips[sha] = names = [];
351 if (!names.Contains(label)) names.Add(label);
352 }
353
354 // Local branches only, for the same reason GetRefs skips remotes.
355 foreach (var branch in repository.Branches.Where(branch => !branch.IsRemote))
356 {
357 Add(branch.Tip?.Sha, branch.FriendlyName);
358 }
359 foreach (var tag in repository.Tags) Add((tag.PeeledTarget as Commit)?.Sha, tag.FriendlyName);
360
361 return tips;
362 }
363
364 private static GitCommitInfo ToCommit(Commit commit, IReadOnlyList<string>? refs = null) => new(
365 commit.Sha,
366 commit.MessageShort,
367 // MessageShort is the subject; everything after it is the body, blank line and all.
368 commit.Message.Length > commit.MessageShort.Length
369 ? commit.Message[commit.MessageShort.Length..].Trim('\n', '\r')
370 : "",
371 ToSignature(commit.Author),
372 ToSignature(commit.Committer),
373 commit.Parents.Select(parent => parent.Sha).ToArray(),
374 refs ?? []);
375
376 private static GitSignature ToSignature(Signature signature) =>
377 new(signature.Name, signature.Email, signature.When);
378
379 /// <summary>Directories first, then names — the order every file browser lists a folder in.</summary>
380 private static IReadOnlyList<GitTreeEntry> Entries(Tree tree, string prefix) =>
381 tree.Select(entry =>
382 {
383 var target = entry.Target;
384
385 return new GitTreeEntry(
386 entry.Name,
387 prefix.Length == 0 ? entry.Name : $"{prefix}/{entry.Name}",
388 Kind(entry.Mode),
389 // Only a blob has a size. A submodule's target is a bare id standing for a
390 // commit in another repository, so there is nothing here to measure either.
391 target is Blob blob ? blob.Size : 0,
392 target.Sha);
393 })
394 .OrderBy(entry => entry.Kind == GitEntryKind.Directory ? 0 : 1)
395 .ThenBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)
396 .ToArray();
397
398 private static GitEntryKind Kind(Mode mode) => mode switch
399 {
400 Mode.Directory => GitEntryKind.Directory,
401 Mode.ExecutableFile => GitEntryKind.Executable,
402 Mode.SymbolicLink => GitEntryKind.Symlink,
403 Mode.GitLink => GitEntryKind.Submodule,
404 _ => GitEntryKind.File
405 };
406
407 private GitBlob ToBlob(Blob blob, string path)
408 {
409 if (blob.IsBinary) return new GitBlob(path, blob.Sha, blob.Size, true, false, null);
410 if (blob.Size > _options.MaxBlobBytes) return new GitBlob(path, blob.Sha, blob.Size, false, true, null);
411
412 var text = blob.GetContentText();
413 var lines = text.Split('\n');
414
415 // A text file ends with a newline, which splits into a trailing empty element that is not
416 // a line of the file.
417 var count = lines.Length > 0 && lines[^1].Length == 0 ? lines.Length - 1 : lines.Length;
418
419 return new GitBlob(
420 path,
421 blob.Sha,
422 blob.Size,
423 false,
424 false,
425 lines.Take(count).Select(line => line.TrimEnd('\r')).ToArray());
426 }
427
428 private GitDiff ToDiff(Patch patch)
429 {
430 var files = new List<GitDiffFile>();
431 var lineBudget = _options.MaxDiffLines;
432 var characterBudget = _options.MaxDiffCharacters;
433 var truncated = false;
434
435 foreach (var change in patch)
436 {
437 var hunks = (IReadOnlyList<GitDiffHunk>)[];
438 var skipped = false;
439
440 if (change.IsBinaryComparison)
441 {
442 // Nothing useful to print, and the patch text says only "Binary files differ".
443 }
444 else if (lineBudget <= 0 || characterBudget <= 0)
445 {
446 skipped = true;
447 }
448 else
449 {
450 var parsed = ParsePatch(change.Patch, lineBudget, characterBudget);
451 hunks = parsed.Hunks;
452 lineBudget -= parsed.Lines;
453 characterBudget -= parsed.Characters;
454 truncated |= parsed.Cut;
455 }
456
457 files.Add(new GitDiffFile(
458 change.Path,
459 change.OldPath == change.Path ? null : change.OldPath,
460 Change(change.Status),
461 change.LinesAdded,
462 change.LinesDeleted,
463 change.IsBinaryComparison,
464 skipped,
465 hunks));
466 }
467
468 return new GitDiff(
469 files,
470 files.Sum(file => file.Added),
471 files.Sum(file => file.Deleted),
472 truncated || files.Any(file => file.Skipped));
473 }
474
475 private static GitChange Change(ChangeKind kind) => kind switch
476 {
477 ChangeKind.Added => GitChange.Added,
478 ChangeKind.Deleted => GitChange.Deleted,
479 ChangeKind.Renamed => GitChange.Renamed,
480 ChangeKind.Copied => GitChange.Copied,
481 ChangeKind.TypeChanged => GitChange.TypeChanged,
482 _ => GitChange.Modified
483 };
484
485 /// <summary>
486 /// Splits one file's unified diff into hunks, numbering both sides as it goes. libgit2 hands
487 /// out the patch as text, and the line numbers only exist in the <c>@@</c> headers, so this
488 /// counts them out again rather than printing the raw patch: the numbers are half of what makes
489 /// a diff readable against the file it came from.
490 /// </summary>
491 /// <param name="lineBudget">Lines left for the whole page, so one enormous file cannot eat it.</param>
492 /// <param name="characterBudget">The same, in characters — see <see cref="GitOptions"/>.</param>
493 private static (IReadOnlyList<GitDiffHunk> Hunks, int Lines, int Characters, bool Cut) ParsePatch(
494 string patch,
495 int lineBudget,
496 int characterBudget)
497 {
498 var hunks = new List<GitDiffHunk>();
499 List<GitDiffLine>? lines = null;
500 var header = "";
501 int oldNumber = 0, newNumber = 0, used = 0, written = 0;
502 var cut = false;
503
504 void Flush()
505 {
506 if (lines is { Count: > 0 }) hunks.Add(new GitDiffHunk(header, lines));
507 lines = null;
508 }
509
510 foreach (var raw in patch.Split('\n'))
511 {
512 var line = raw.TrimEnd('\r');
513
514 if (line.StartsWith("@@", StringComparison.Ordinal))
515 {
516 Flush();
517
518 if (HunkHeader().Match(line) is not { Success: true } match) continue;
519
520 oldNumber = int.Parse(match.Groups["old"].ValueSpan);
521 newNumber = int.Parse(match.Groups["new"].ValueSpan);
522 header = line;
523 lines = [];
524 continue;
525 }
526
527 // Everything before the first @@ is the file header: "diff --git", "index", "---",
528 // "+++", mode and rename lines. None of it belongs in the body.
529 if (lines is null || line.Length == 0) continue;
530
531 if (used >= lineBudget || written >= characterBudget)
532 {
533 cut = true;
534 break;
535 }
536
537 var text = line[1..];
538 written += text.Length;
539 switch (line[0])
540 {
541 case '+':
542 lines.Add(new GitDiffLine('+', null, newNumber++, text));
543 used++;
544 break;
545 case '-':
546 lines.Add(new GitDiffLine('-', oldNumber++, null, text));
547 used++;
548 break;
549 case ' ':
550 lines.Add(new GitDiffLine(' ', oldNumber++, newNumber++, text));
551 used++;
552 break;
553 case '\\':
554 // "\ No newline at end of file" — a note about the line above, not a line.
555 lines.Add(new GitDiffLine('\\', null, null, text.Trim()));
556 break;
557 }
558 }
559
560 Flush();
561 return (hunks, used, written, cut);
562 }
563
564 /// <summary>
565 /// A directory name and nothing else. The name comes out of the URL, so this is what stands
566 /// between a request and the rest of the filesystem.
567 /// </summary>
568 private static bool IsRepositoryName(string name) =>
569 name.Length is > 0 and <= 100 && RepositoryName().IsMatch(name);
570
571 private static string Expand(string path) =>
572 path.StartsWith("~/", StringComparison.Ordinal)
573 ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[2..])
574 : path;
575
576 [GeneratedRegex(@"^@@ -(?<old>\d+)(?:,\d+)? \+(?<new>\d+)(?:,\d+)? @@")]
577 private static partial Regex HunkHeader();
578
579 [GeneratedRegex(@"^[A-Za-z0-9_][A-Za-z0-9._+-]*$")]
580 private static partial Regex RepositoryName();
581 }