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