using System.Text.RegularExpressions; using Blog.Models; using LibGit2Sharp; using Microsoft.Extensions.Options; namespace Blog.Services; /// Where the repositories are, and how much of one page is allowed to show. public sealed class GitOptions { public const string Section = "Git"; /// /// The directory the bare repositories sit in — the same scan path cgit was pointed at. A /// leading ~/ is expanded, so a development machine can point this at a checkout /// directory without hardcoding a home directory. /// public string RepositoryRoot { get; set; } = "/home/git"; /// /// The clone line shown on a repository's page, with {repo} standing in for its /// directory name. Cloning goes over ssh; nothing here serves the git protocol. /// public string? CloneUrl { get; set; } = "ssh://git@git.bes.is/{repo}"; /// /// How many diff lines one commit page may render before the remaining files keep their stats /// and lose their hunks. A commit that vendors a 4 MB file is a real thing that happens in /// these repositories, and it should not become a 4 MB page. /// public int MaxDiffLines { get; set; } = 3000; /// /// The same ceiling measured in characters, because the two run out independently: three /// thousand lines of ordinary code is a couple of hundred kilobytes, and three thousand lines /// of machine-generated HTML is several megabytes. Whichever budget empties first ends the /// diff. /// public int MaxDiffCharacters { get; set; } = 250_000; /// Blobs past this size are linked rather than printed. public int MaxBlobBytes { get; set; } = 512 * 1024; /// /// The largest file the raw endpoint will hand over. It reads the blob into memory to serve /// it — streaming would mean keeping the repository open across the response — so this is the /// ceiling on what one request can allocate. The biggest thing in these repositories is a /// 4 MB vendored HTML file. /// public int MaxRawBytes { get; set; } = 25 * 1024 * 1024; } /// /// Reads the git repositories on this server for the pages under /git. /// /// /// Every method opens a , copies what it needs into the plain records in /// Models/GitRepositories.cs, and closes it again. That is deliberate: libgit2's objects are /// handles into an open repository and a is not thread-safe, so keeping /// one alive across requests would mean either a lock around the whole site or handles outliving /// the thing they point into. Opening a repository is a couple of file reads — cheap enough to do /// per request, and it means a push is visible immediately with nothing to invalidate. /// /// Repository names arrive from the URL, so is the only way this class turns /// one into a path: a name is a single directory entry under the root, and anything else is a miss. /// public sealed partial class GitService(IOptions options, ILogger logger) { private readonly GitOptions _options = options.Value; /// The scan path, with ~/ expanded. public string Root { get; } = Expand(options.Value.RepositoryRoot); public string? CloneUrl(string repository) => _options.CloneUrl is { Length: > 0 } url ? url.Replace("{repo}", repository, StringComparison.Ordinal) : null; /// Every repository under the root, by name, or why there are none. public GitIndexView ListRepositories() { if (!Directory.Exists(Root)) { // Exists() is false for a directory that is not there and false for one this process // cannot reach, and on a hardened systemd unit ProtectHome hides /home outright, which // looks exactly the same. The message has to cover all of it. logger.LogWarning("The git repository root {Root} is missing or out of reach", Root); return new GitIndexView([], $"{Root} is not there, or this process cannot reach it."); } var found = new List(); try { foreach (var directory in Directory.EnumerateDirectories(Root)) { var name = Path.GetFileName(directory); if (!IsRepositoryName(name)) continue; try { using var repository = Open(directory); if (repository is null) continue; found.Add(Summarise(repository, name)); } catch (Exception exception) when (exception is LibGit2SharpException or IOException or UnauthorizedAccessException) { // One unreadable repository is not a reason for the index to fail. logger.LogWarning(exception, "Could not read the repository at {Directory}", directory); } } } catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) { // The root itself. A home directory is 0700 unless someone says otherwise, so this is // the likely answer the first time this site is pointed at a real one. logger.LogError(exception, "Could not list the git repository root {Root}", Root); return new GitIndexView([], $"{Root} cannot be read by the user this site runs as."); } return new GitIndexView( found.OrderBy(repository => repository.DisplayName, StringComparer.OrdinalIgnoreCase).ToArray(), null); } public GitRepoSummary? GetRepository(string name) => WithRepository(name, repository => Summarise(repository, name)); /// /// A page of history for , oldest-last. /// narrows it to the commits that touched one file or directory. /// public GitLogPage? GetLog(string name, string? reference, string? path, int skip, int take) => WithRepository(name, repository => { if (Resolve(repository, reference) is not { } commit) return null; var tips = RefsByCommit(repository); var filter = new CommitFilter { IncludeReachableFrom = commit, SortBy = CommitSortStrategies.Time }; var commits = string.IsNullOrEmpty(path) ? repository.Commits.QueryBy(filter) : repository.Commits.QueryBy(path, filter).Select(entry => entry.Commit); // One past the page, so the pager knows whether there is a next one without counting // the whole history. var page = commits.Skip(skip).Take(take + 1).ToArray(); return new GitLogPage( page.Take(take) .Select(item => ToCommit(item, tips.GetValueOrDefault(item.Sha) ?? [])) .ToArray(), page.Length > take); }); /// One commit, with its diff against its first parent — a merge included, as git does. public GitCommitView? GetCommit(string name, string sha) => WithRepository(name, repository => { if (Resolve(repository, sha) is not { } commit) return null; var parent = commit.Parents.FirstOrDefault(); var compare = new CompareOptions { Similarity = SimilarityOptions.Renames }; using var patch = repository.Diff.Compare(parent?.Tree, commit.Tree, compare); return new GitCommitView( ToCommit(commit, RefsByCommit(repository).GetValueOrDefault(commit.Sha) ?? []), ToDiff(patch)); }); /// /// One path in one revision. The result carries a directory listing or a file, depending on /// what the path turned out to be; both empty means the path is not in that revision. /// public GitPathView? GetPath(string name, string? reference, string? path) => WithRepository(name, repository => { if (Resolve(repository, reference) is not { } commit) return null; var head = reference is { Length: > 0 } ? reference : DefaultReference(repository); var info = ToCommit(commit); path = path?.Trim('/'); if (string.IsNullOrEmpty(path)) { return new GitPathView(info, head, "", Entries(commit.Tree, ""), null); } if (commit[path] is not { } entry) return new GitPathView(info, head, path, null, null); return entry.TargetType switch { TreeEntryTargetType.Tree => new GitPathView(info, head, path, Entries((Tree)entry.Target, path), null), TreeEntryTargetType.Blob => new GitPathView(info, head, path, null, ToBlob((Blob)entry.Target, path)), // A submodule's commit lives in another repository, so there is nothing to open. _ => new GitPathView(info, head, path, null, null) }; }); /// The bytes of one blob, for the raw endpoint. Null when the path is not a file. public GitRawBlob? GetRawBlob(string name, string? reference, string path) => WithRepository(name, repository => { path = path.Trim('/'); if (Resolve(repository, reference) is not { } commit) return null; if (commit[path]?.Target is not Blob blob) return null; if (blob.Size > _options.MaxRawBytes) return null; using var content = blob.GetContentStream(); using var buffer = new MemoryStream(); content.CopyTo(buffer); return new GitRawBlob(buffer.ToArray(), blob.IsBinary, Path.GetFileName(path)); }); /// Branches and tags, each with the commit it points at. public GitRefsView? GetRefs(string name) => WithRepository(name, repository => { var head = repository.Info.IsHeadDetached ? null : repository.Head.FriendlyName; // Local branches only. A bare repository on a server has no remotes, and on a working // checkout every origin/* duplicate would double the list and the badges on the log. var branches = repository.Branches .Where(branch => !branch.IsRemote && branch.Tip is not null) .Select(branch => new GitRef(branch.FriendlyName, false, branch.Tip.Sha, ToCommit(branch.Tip), null)) // The branch HEAD points at first, then whichever moved most recently. .OrderByDescending(branch => branch.Name == head) .ThenByDescending(branch => branch.Tip!.Committer.When) .ToArray(); var tags = repository.Tags .Select(tag => new GitRef( tag.FriendlyName, true, tag.Target.Sha, tag.PeeledTarget is Commit peeled ? ToCommit(peeled) : null, tag.Annotation?.Message)) .OrderByDescending(tag => tag.Tip?.Committer.When ?? DateTimeOffset.MinValue) .ToArray(); return new GitRefsView(branches, tags); }); /// /// Turns a name from the URL into a directory under the root. A repository is one entry in that /// directory and nothing else: no separators, no traversal, and the resolved path's parent has /// to be the root itself. /// private string? Resolve(string name) { if (!IsRepositoryName(name)) return null; var path = Path.GetFullPath(Path.Combine(Root, name)); if (Path.GetDirectoryName(path) != Path.GetFullPath(Root).TrimEnd(Path.DirectorySeparatorChar)) return null; return Directory.Exists(path) ? path : null; } /// Opens a repository by name and hands it to for as long as it lives. private T? WithRepository(string name, Func read) where T : class { if (Resolve(name) is not { } directory) return null; try { using var repository = Open(directory); return repository is null ? null : read(repository); } catch (Exception exception) when (exception is LibGit2SharpException or IOException or UnauthorizedAccessException) { logger.LogWarning(exception, "Could not read the repository {Name}", name); return null; } } /// /// Bare repositories are the point of the scan path, but a working checkout keeps its git /// directory one level down — worth handling, since that is what a development root holds. /// private static Repository? Open(string directory) { if (Repository.IsValid(directory)) return new Repository(directory); var dotGit = Path.Combine(directory, ".git"); return Repository.IsValid(dotGit) ? new Repository(dotGit) : null; } private GitRepoSummary Summarise(Repository repository, string name) { var tip = repository.Head.Tip; return new GitRepoSummary( name, name.EndsWith(".git", StringComparison.OrdinalIgnoreCase) ? name[..^4] : name, Description(repository), repository.Config.Get("gitweb.owner")?.Value, repository.Info.IsHeadDetached || tip is null ? null : repository.Head.FriendlyName, tip is null ? null : ToCommit(tip)); } /// /// The description file next to the objects, which is what cgit shows and what a hook /// on the server is most likely to set. git init writes a placeholder into it, and a /// placeholder is not a description. /// private string? Description(Repository repository) { var path = Path.Combine(repository.Info.Path, "description"); if (!File.Exists(path)) return null; try { var text = File.ReadAllText(path).Trim(); return text.Length == 0 || text.StartsWith("Unnamed repository", StringComparison.Ordinal) ? null : text; } catch (IOException exception) { logger.LogWarning(exception, "Could not read {Path}", path); return null; } } /// /// Resolves a branch name, tag, or hash, peeling an annotated tag down to the commit it marks. /// Null means the repository's own HEAD. /// private static Commit? Resolve(Repository repository, string? reference) { if (string.IsNullOrWhiteSpace(reference)) return repository.Head.Tip; try { return Peel(repository.Lookup(reference)); } catch (LibGit2SharpException) { // An unparseable revision is a 404, not a 500. return null; } } private static Commit? Peel(GitObject? item) => item switch { Commit commit => commit, TagAnnotation tag => Peel(tag.Target), _ => null }; private static string DefaultReference(Repository repository) => repository.Info.IsHeadDetached ? repository.Head.Tip?.Sha ?? "HEAD" : repository.Head.FriendlyName; /// /// Which branch and tag names point at each commit, for the badges on the log. Built once per /// page: the alternative is a ref walk per row. /// private static Dictionary> RefsByCommit(Repository repository) { var tips = new Dictionary>(StringComparer.Ordinal); void Add(string? sha, string label) { if (sha is null) return; if (!tips.TryGetValue(sha, out var names)) tips[sha] = names = []; if (!names.Contains(label)) names.Add(label); } // Local branches only, for the same reason GetRefs skips remotes. foreach (var branch in repository.Branches.Where(branch => !branch.IsRemote)) { Add(branch.Tip?.Sha, branch.FriendlyName); } foreach (var tag in repository.Tags) Add((tag.PeeledTarget as Commit)?.Sha, tag.FriendlyName); return tips; } private static GitCommitInfo ToCommit(Commit commit, IReadOnlyList? refs = null) => new( commit.Sha, commit.MessageShort, // MessageShort is the subject; everything after it is the body, blank line and all. commit.Message.Length > commit.MessageShort.Length ? commit.Message[commit.MessageShort.Length..].Trim('\n', '\r') : "", ToSignature(commit.Author), ToSignature(commit.Committer), commit.Parents.Select(parent => parent.Sha).ToArray(), refs ?? []); private static GitSignature ToSignature(Signature signature) => new(signature.Name, signature.Email, signature.When); /// Directories first, then names — the order every file browser lists a folder in. private static IReadOnlyList Entries(Tree tree, string prefix) => tree.Select(entry => { var target = entry.Target; return new GitTreeEntry( entry.Name, prefix.Length == 0 ? entry.Name : $"{prefix}/{entry.Name}", Kind(entry.Mode), // Only a blob has a size. A submodule's target is a bare id standing for a // commit in another repository, so there is nothing here to measure either. target is Blob blob ? blob.Size : 0, target.Sha); }) .OrderBy(entry => entry.Kind == GitEntryKind.Directory ? 0 : 1) .ThenBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); private static GitEntryKind Kind(Mode mode) => mode switch { Mode.Directory => GitEntryKind.Directory, Mode.ExecutableFile => GitEntryKind.Executable, Mode.SymbolicLink => GitEntryKind.Symlink, Mode.GitLink => GitEntryKind.Submodule, _ => GitEntryKind.File }; private GitBlob ToBlob(Blob blob, string path) { if (blob.IsBinary) return new GitBlob(path, blob.Sha, blob.Size, true, false, null); if (blob.Size > _options.MaxBlobBytes) return new GitBlob(path, blob.Sha, blob.Size, false, true, null); // Normalised here rather than wherever it is split: a file committed with CRLF would // otherwise end every line with a stray carriage return. The last newline ends the last // line, it does not begin another one - but only the last, since a file may genuinely end // on a blank line. var text = blob.GetContentText().ReplaceLineEndings("\n"); if (text.EndsWith('\n')) text = text[..^1]; return new GitBlob(path, blob.Sha, blob.Size, false, false, text); } private GitDiff ToDiff(Patch patch) { var files = new List(); var lineBudget = _options.MaxDiffLines; var characterBudget = _options.MaxDiffCharacters; var truncated = false; foreach (var change in patch) { var hunks = (IReadOnlyList)[]; var skipped = false; if (change.IsBinaryComparison) { // Nothing useful to print, and the patch text says only "Binary files differ". } else if (lineBudget <= 0 || characterBudget <= 0) { skipped = true; } else { var parsed = ParsePatch(change.Patch, lineBudget, characterBudget); hunks = parsed.Hunks; lineBudget -= parsed.Lines; characterBudget -= parsed.Characters; truncated |= parsed.Cut; } files.Add(new GitDiffFile( change.Path, change.OldPath == change.Path ? null : change.OldPath, Change(change.Status), change.LinesAdded, change.LinesDeleted, change.IsBinaryComparison, skipped, hunks)); } return new GitDiff( files, files.Sum(file => file.Added), files.Sum(file => file.Deleted), truncated || files.Any(file => file.Skipped)); } private static GitChange Change(ChangeKind kind) => kind switch { ChangeKind.Added => GitChange.Added, ChangeKind.Deleted => GitChange.Deleted, ChangeKind.Renamed => GitChange.Renamed, ChangeKind.Copied => GitChange.Copied, ChangeKind.TypeChanged => GitChange.TypeChanged, _ => GitChange.Modified }; /// /// Splits one file's unified diff into hunks, numbering both sides as it goes. libgit2 hands /// out the patch as text, and the line numbers only exist in the @@ headers, so this /// counts them out again rather than printing the raw patch: the numbers are half of what makes /// a diff readable against the file it came from. /// /// Lines left for the whole page, so one enormous file cannot eat it. /// The same, in characters — see . private static (IReadOnlyList Hunks, int Lines, int Characters, bool Cut) ParsePatch( string patch, int lineBudget, int characterBudget) { var hunks = new List(); List? lines = null; var header = ""; int oldNumber = 0, newNumber = 0, used = 0, written = 0; var cut = false; void Flush() { if (lines is { Count: > 0 }) hunks.Add(new GitDiffHunk(header, lines)); lines = null; } foreach (var raw in patch.Split('\n')) { var line = raw.TrimEnd('\r'); if (line.StartsWith("@@", StringComparison.Ordinal)) { Flush(); if (HunkHeader().Match(line) is not { Success: true } match) continue; oldNumber = int.Parse(match.Groups["old"].ValueSpan); newNumber = int.Parse(match.Groups["new"].ValueSpan); header = line; lines = []; continue; } // Everything before the first @@ is the file header: "diff --git", "index", "---", // "+++", mode and rename lines. None of it belongs in the body. if (lines is null || line.Length == 0) continue; if (used >= lineBudget || written >= characterBudget) { cut = true; break; } var text = line[1..]; written += text.Length; switch (line[0]) { case '+': lines.Add(new GitDiffLine('+', null, newNumber++, text)); used++; break; case '-': lines.Add(new GitDiffLine('-', oldNumber++, null, text)); used++; break; case ' ': lines.Add(new GitDiffLine(' ', oldNumber++, newNumber++, text)); used++; break; case '\\': // "\ No newline at end of file" — a note about the line above, not a line. lines.Add(new GitDiffLine('\\', null, null, text.Trim())); break; } } Flush(); return (hunks, used, written, cut); } /// /// A directory name and nothing else. The name comes out of the URL, so this is what stands /// between a request and the rest of the filesystem. /// private static bool IsRepositoryName(string name) => name.Length is > 0 and <= 100 && RepositoryName().IsMatch(name); private static string Expand(string path) => path.StartsWith("~/", StringComparison.Ordinal) ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[2..]) : path; [GeneratedRegex(@"^@@ -(?\d+)(?:,\d+)? \+(?\d+)(?:,\d+)? @@")] private static partial Regex HunkHeader(); [GeneratedRegex(@"^[A-Za-z0-9_][A-Za-z0-9._+-]*$")] private static partial Regex RepositoryName(); }