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)
{
static GitService()
{
// libgit2 refuses a repository owned by a different user than the process, the "dubious
// ownership" check git itself grew: picking up config and hooks out of a repository
// someone else planted in a shared directory is a way to be handed code to run.
//
// That is this deployment exactly, and on purpose. The repositories belong to the git user
// that serves them over ssh, and the site reads them as its own service user, which is
// what keeps the site from being able to write to them. Nothing here runs anything out of
// a repository - it reads objects and refs - and the directory it looks in is named in
// configuration by whoever deployed it, so there is nothing left for the check to protect.
//
// Leaving it on does not produce an error. Every repository quietly stops being a
// repository and the index comes up empty, which is a long way to walk back to here.
GlobalSettings.SetOwnerValidation(false);
}
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();
var candidates = 0;
try
{
foreach (var directory in Directory.EnumerateDirectories(Root))
{
var name = Path.GetFileName(directory);
if (!IsRepositoryName(name)) continue;
candidates++;
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.");
}
if (found.Count == 0 && candidates > 0)
{
// The names came back and not one of them opened. Two things do this: a root that
// can be listed but not read into (r without x), where even a stat on the HEAD
// inside fails; and a repository libgit2 will not touch, which included one owned by
// another user until the static constructor above. Neither leaves anything more
// specific to say than how many there were.
logger.LogWarning(
"None of the {Candidates} directories under {Root} could be opened as a repository",
candidates,
Root);
return new GitIndexView(
[],
$"Nothing under {Root} could be opened as a repository, though there "
+ $"{(candidates == 1 ? "is 1 directory" : $"are {candidates} directories")} there. "
+ "The user this site runs as most likely cannot read into them.");
}
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 Repository? Open(string directory)
{
if (Repository.IsValid(directory)) return new Repository(directory);
var dotGit = Path.Combine(directory, ".git");
if (Repository.IsValid(dotGit)) return new Repository(dotGit);
// Not a repository, which for most directories under a scan path is unremarkable and
// silent. One that has a HEAD and an objects directory and still will not open is not
// unremarkable: something is stopping libgit2 reading a repository that is plainly there,
// and an empty index with nothing in the log is no way to find out what.
if (File.Exists(Path.Combine(directory, "HEAD")) && Directory.Exists(Path.Combine(directory, "objects")))
{
logger.LogWarning(
"{Directory} looks like a bare repository, but libgit2 will not open it. Check that "
+ "the user this site runs as can read it",
directory);
}
return 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();
}