Blog/Services/GitService.cs 23.5 K · 575 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 // Normalised here rather than wherever it is split: a file committed with CRLF would
413 // otherwise end every line with a stray carriage return. The last newline ends the last
414 // line, it does not begin another one - but only the last, since a file may genuinely end
415 // on a blank line.
416 var text = blob.GetContentText().ReplaceLineEndings("\n");
417 if (text.EndsWith('\n')) text = text[..^1];
418
419 return new GitBlob(path, blob.Sha, blob.Size, false, false, text);
420 }
421
422 private GitDiff ToDiff(Patch patch)
423 {
424 var files = new List<GitDiffFile>();
425 var lineBudget = _options.MaxDiffLines;
426 var characterBudget = _options.MaxDiffCharacters;
427 var truncated = false;
428
429 foreach (var change in patch)
430 {
431 var hunks = (IReadOnlyList<GitDiffHunk>)[];
432 var skipped = false;
433
434 if (change.IsBinaryComparison)
435 {
436 // Nothing useful to print, and the patch text says only "Binary files differ".
437 }
438 else if (lineBudget <= 0 || characterBudget <= 0)
439 {
440 skipped = true;
441 }
442 else
443 {
444 var parsed = ParsePatch(change.Patch, lineBudget, characterBudget);
445 hunks = parsed.Hunks;
446 lineBudget -= parsed.Lines;
447 characterBudget -= parsed.Characters;
448 truncated |= parsed.Cut;
449 }
450
451 files.Add(new GitDiffFile(
452 change.Path,
453 change.OldPath == change.Path ? null : change.OldPath,
454 Change(change.Status),
455 change.LinesAdded,
456 change.LinesDeleted,
457 change.IsBinaryComparison,
458 skipped,
459 hunks));
460 }
461
462 return new GitDiff(
463 files,
464 files.Sum(file => file.Added),
465 files.Sum(file => file.Deleted),
466 truncated || files.Any(file => file.Skipped));
467 }
468
469 private static GitChange Change(ChangeKind kind) => kind switch
470 {
471 ChangeKind.Added => GitChange.Added,
472 ChangeKind.Deleted => GitChange.Deleted,
473 ChangeKind.Renamed => GitChange.Renamed,
474 ChangeKind.Copied => GitChange.Copied,
475 ChangeKind.TypeChanged => GitChange.TypeChanged,
476 _ => GitChange.Modified
477 };
478
479 /// <summary>
480 /// Splits one file's unified diff into hunks, numbering both sides as it goes. libgit2 hands
481 /// out the patch as text, and the line numbers only exist in the <c>@@</c> headers, so this
482 /// counts them out again rather than printing the raw patch: the numbers are half of what makes
483 /// a diff readable against the file it came from.
484 /// </summary>
485 /// <param name="lineBudget">Lines left for the whole page, so one enormous file cannot eat it.</param>
486 /// <param name="characterBudget">The same, in characters — see <see cref="GitOptions"/>.</param>
487 private static (IReadOnlyList<GitDiffHunk> Hunks, int Lines, int Characters, bool Cut) ParsePatch(
488 string patch,
489 int lineBudget,
490 int characterBudget)
491 {
492 var hunks = new List<GitDiffHunk>();
493 List<GitDiffLine>? lines = null;
494 var header = "";
495 int oldNumber = 0, newNumber = 0, used = 0, written = 0;
496 var cut = false;
497
498 void Flush()
499 {
500 if (lines is { Count: > 0 }) hunks.Add(new GitDiffHunk(header, lines));
501 lines = null;
502 }
503
504 foreach (var raw in patch.Split('\n'))
505 {
506 var line = raw.TrimEnd('\r');
507
508 if (line.StartsWith("@@", StringComparison.Ordinal))
509 {
510 Flush();
511
512 if (HunkHeader().Match(line) is not { Success: true } match) continue;
513
514 oldNumber = int.Parse(match.Groups["old"].ValueSpan);
515 newNumber = int.Parse(match.Groups["new"].ValueSpan);
516 header = line;
517 lines = [];
518 continue;
519 }
520
521 // Everything before the first @@ is the file header: "diff --git", "index", "---",
522 // "+++", mode and rename lines. None of it belongs in the body.
523 if (lines is null || line.Length == 0) continue;
524
525 if (used >= lineBudget || written >= characterBudget)
526 {
527 cut = true;
528 break;
529 }
530
531 var text = line[1..];
532 written += text.Length;
533 switch (line[0])
534 {
535 case '+':
536 lines.Add(new GitDiffLine('+', null, newNumber++, text));
537 used++;
538 break;
539 case '-':
540 lines.Add(new GitDiffLine('-', oldNumber++, null, text));
541 used++;
542 break;
543 case ' ':
544 lines.Add(new GitDiffLine(' ', oldNumber++, newNumber++, text));
545 used++;
546 break;
547 case '\\':
548 // "\ No newline at end of file" — a note about the line above, not a line.
549 lines.Add(new GitDiffLine('\\', null, null, text.Trim()));
550 break;
551 }
552 }
553
554 Flush();
555 return (hunks, used, written, cut);
556 }
557
558 /// <summary>
559 /// A directory name and nothing else. The name comes out of the URL, so this is what stands
560 /// between a request and the rest of the filesystem.
561 /// </summary>
562 private static bool IsRepositoryName(string name) =>
563 name.Length is > 0 and <= 100 && RepositoryName().IsMatch(name);
564
565 private static string Expand(string path) =>
566 path.StartsWith("~/", StringComparison.Ordinal)
567 ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[2..])
568 : path;
569
570 [GeneratedRegex(@"^@@ -(?<old>\d+)(?:,\d+)? \+(?<new>\d+)(?:,\d+)? @@")]
571 private static partial Regex HunkHeader();
572
573 [GeneratedRegex(@"^[A-Za-z0-9_][A-Za-z0-9._+-]*$")]
574 private static partial Regex RepositoryName();
575 }