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