Browse the git repositories at /git instead of linking to cgit

The footer pointed at git.bes.is, a cgit install sitting beside this site. This replaces the link with pages of our own: an index, a repository summary, log, commit with diff, tree and blob, and refs, all under /git. Cloning is unchanged and still goes over ssh to git.bes.is. GitService reads the repositories with LibGit2Sharp - the first native dependency here, which is why the self-contained linux-x64 publish now carries a libgit2 .so alongside the managed assemblies. Every method opens a Repository, copies what it needs into the plain records in Models/GitRepositories.cs and closes it again. libgit2's objects are handles into an open repository and Repository is not thread-safe, so holding one across requests would mean a lock around the whole site or handles outliving what they point into. Nothing is cached as a result, which is the opposite of WarframeDropService and for the opposite reason: that input is a file that only changes on deploy, this one changes on every push, and re-opening a repository is a couple of file reads. Repository names arrive from the URL, so GitService.Resolve is the only thing that turns one into a path: it has to match a plain directory name and resolve to a directory whose parent is the root exactly. Paths inside a repository never reach the filesystem at all - they are tree lookups. The raw endpoint must never answer text/html. These repositories hold .html and .svg files, and serving one inline from this origin would run whatever a commit put in it as a page of mb.bes.is. Text goes out as text/plain with nosniff, which a browser will not reinterpret, and everything else is an attachment. Diffs are parsed back out of libgit2's patch text rather than printed raw, because the line numbers exist only in the @@ headers and the numbers are half of what makes a diff readable. Both budgets in GitOptions matter and run out independently: the commit that vendored droptables.html is 22k added lines of long machine-generated HTML, and it emptied the character budget at a fifth of the line one. Without the character budget that one page was 2.5 MB. The pages are driven by the route and the query string, so everything is linkable, and there is no JavaScript on any of them. They also do not use MainLayout. GitLayout links wwwroot/git.css through HeadContent, so no other page loads it, and git.css overrides app.css down to the base rather than extending it: root font size, spacing scale, table padding, link decoration. app.css is set for reading an 80ch column and this is a wall of rows you scan. That is also why <body> no longer carries class="center" - the measure moved onto the div.page that each layout wraps itself in, so /git can be full width. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-09-13 19:22 UTC
commit
1157d8790d3fc44621e42e67436b3758e692149a
parent
60f0aebc39
tree
browse at this commit

22 files changed +2540 -11

Blog/Blog.csproj +1 -0

@@ -8,6 +8,7 @@
8 8 </PropertyGroup>
9 9
10 10 <ItemGroup>
11 + <PackageReference Include="LibGit2Sharp" Version="0.32.0" />
11 12 <PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.9.0" />
12 13 </ItemGroup>
13 14

Blog/Components/App.razor +1 -1

@@ -13,7 +13,7 @@
13 13 <HeadOutlet/>
14 14 </head>
15 15
16 -<body class="center">
16 +<body>
17 17 <Routes/>
18 18 </body>
19 19

Blog/Components/Layout/GitLayout.razor +18 -0

@@ -0,0 +1,18 @@
1 +@inherits LayoutComponentBase
2 +
3 +@* /git is laid out to be scanned, not read, so it does not use MainLayout: no logo, no 80ch
4 + measure, and a status bar instead of a header. git.css is linked here rather than in App.razor
5 + because it overrides app.css down to the base — it has no business on the rest of the site. *@
6 +
7 +<HeadContent>
8 + <link rel="stylesheet" href="@Assets["git.css"]"/>
9 +</HeadContent>
10 +
11 +<div class="git">
12 + @Body
13 +
14 + <footer class="git-foot">
15 + <span><a href="/">mb.bes.is</a></span>
16 + <span>clone over ssh, browse over http</span>
17 + </footer>
18 +</div>

Blog/Components/Layout/MainLayout.razor +7 -3

@@ -1,7 +1,11 @@
1 1 @inherits LayoutComponentBase
2 2
3 -<SiteHeader/>
3 +@* The measure and the page's vertical rhythm live here rather than on <body>: /git uses its own
4 + layout, and a dense repository browser wants neither. *@
5 +<div class="page center">
6 + <SiteHeader/>
4 7
5 -@Body
8 + @Body
6 9
7 -<SiteFooter/>
10 + <SiteFooter/>
11 +</div>

Blog/Components/Layout/SiteFooter.razor +1 -1

@@ -13,7 +13,7 @@
13 13 <li><NavLink href="/Storage">Storage</NavLink></li>
14 14 <li><NavLink href="/Warframe">Warframe drops</NavLink></li>
15 15 @* <li><NavLink href="/rvrb">rvrb bot stats</NavLink></li> *@
16 - <li><NavLink href="https://git.bes.is">Git</NavLink></li>
16 + <li><NavLink href="/git">Git repositories</NavLink></li>
17 17 <!-- <li><a href="/webrtc.html">WebRTC</a></li> -->
18 18 <!-- <li><a href="/spotify/index.html">Spotify</a></li> -->
19 19 </ul>

Blog/Components/Pages/Git/GitBar.razor +62 -0

@@ -0,0 +1,62 @@
1 +@* The one row of chrome a git page gets: where you are on the left, where else you can go on the
2 + right. Inverted, like a terminal's status line — see git.css. *@
3 +
4 +<nav class="git-bar">
5 + <span class="git-crumbs">
6 + <a href="/git">git</a>
7 +
8 + @if (Repository is { Length: > 0 })
9 + {
10 + <span class="sep">/</span>
11 + <a href="@RepoHref"><strong>@Repository</strong></a>
12 +
13 + @foreach (var crumb in Path)
14 + {
15 + <span class="sep">/</span>
16 + @if (crumb.Href is null)
17 + {
18 + <strong>@crumb.Name</strong>
19 + }
20 + else
21 + {
22 + <a href="@crumb.Href">@crumb.Name</a>
23 + }
24 + }
25 + }
26 + </span>
27 +
28 + @if (Repository is { Length: > 0 })
29 + {
30 + <span class="git-tabs">
31 + <a class="@Active("log")" href="@($"{RepoHref}/log{Query}")">log</a>
32 + <a class="@Active("tree")" href="@($"{RepoHref}/tree{Query}")">tree</a>
33 + <a class="@Active("refs")" href="@($"{RepoHref}/refs")">refs</a>
34 + </span>
35 + }
36 +</nav>
37 +
38 +@code {
39 + /// <summary>One step of the path under the repository. A null href is the step you are on.</summary>
40 + public sealed record Crumb(string Name, string? Href);
41 +
42 + /// <summary>The repository's directory name. Empty on the index, which has no tabs.</summary>
43 + [Parameter]
44 + public string? Repository { get; set; }
45 +
46 + /// <summary>Which tab to punch out: <c>log</c>, <c>tree</c> or <c>refs</c>.</summary>
47 + [Parameter]
48 + public string? Tab { get; set; }
49 +
50 + /// <summary>The branch the tabs should stay on, when the page is on one.</summary>
51 + [Parameter]
52 + public string? Reference { get; set; }
53 +
54 + [Parameter]
55 + public IReadOnlyList<Crumb> Path { get; set; } = [];
56 +
57 + private string RepoHref => $"/git/{Uri.EscapeDataString(Repository ?? "")}";
58 +
59 + private string Query => string.IsNullOrEmpty(Reference) ? "" : $"?h={Uri.EscapeDataString(Reference)}";
60 +
61 + private string? Active(string tab) => tab == Tab ? "on" : null;
62 +}

Blog/Components/Pages/Git/GitCommit.razor +227 -0

@@ -0,0 +1,227 @@
1 +@page "/git/{Repository}/commit/{Sha}"
2 +@inherits GitPage
3 +
4 +<PageTitle>@Title — git</PageTitle>
5 +
6 +<GitBar Repository="@Repository" Reference="@Reference" Tab="log"
7 + Path="@(new[] { new GitBar.Crumb(Short, null) })"/>
8 +
9 +<main class="git-main">
10 + @if (View is not { } view)
11 + {
12 + <section class="block">
13 + <p class="empty">No commit @Sha in @Repository.</p>
14 + </section>
15 + }
16 + else
17 + {
18 + <section class="block">
19 + <h2 class="block-head">
20 + <span class="subject">@view.Commit.Summary</span>
21 + <span class="block-note">
22 + @foreach (var name in view.Commit.Refs)
23 + {
24 + <span class="ref">@name</span>
25 + }
26 + </span>
27 + </h2>
28 +
29 + <div class="block-body">
30 + @if (view.Commit.Body is { Length: > 0 } body)
31 + {
32 + <p class="message">@body</p>
33 + }
34 +
35 + <dl class="pairs">
36 + <dt>author</dt>
37 + <dd>
38 + @view.Commit.Author.Name <span class="dim">&lt;@view.Commit.Author.Email&gt;</span>
39 + <span class="dim">· @Exact(view.Commit.Author.When)</span>
40 + </dd>
41 +
42 + @if (Differs(view.Commit))
43 + {
44 + <dt>committer</dt>
45 + <dd>
46 + @view.Commit.Committer.Name <span class="dim">&lt;@view.Commit.Committer.Email&gt;</span>
47 + <span class="dim">· @Exact(view.Commit.Committer.When)</span>
48 + </dd>
49 + }
50 +
51 + <dt>commit</dt>
52 + <dd class="sha copyable">@view.Commit.Sha</dd>
53 +
54 + @if (view.Commit.Parents.Count > 0)
55 + {
56 + <dt>parent@(view.Commit.Parents.Count == 1 ? "" : "s")</dt>
57 + <dd>
58 + @foreach (var parent in view.Commit.Parents)
59 + {
60 + <a class="sha" href="@CommitLink(parent)">@Shorten(parent)</a>
61 + <text> </text>
62 + }
63 + </dd>
64 + }
65 +
66 + <dt>tree</dt>
67 + <dd><a href="@TreeLink("", view.Commit.Sha)">browse at this commit</a></dd>
68 + </dl>
69 + </div>
70 + </section>
71 +
72 + <section class="block">
73 + <h2 class="block-head">
74 + <span>@Changed(view.Diff)</span>
75 + <span class="block-note stat">
76 + <span class="plus">+@view.Diff.Added</span>
77 + <span class="minus">-@view.Diff.Deleted</span>
78 + </span>
79 + </h2>
80 +
81 + @if (view.Diff.Files.Count == 0)
82 + {
83 + <p class="empty">
84 + @(view.Commit.IsMerge
85 + ? "Nothing changed against the first parent — the other side brought it all."
86 + : "No changes.")
87 + </p>
88 + }
89 + else
90 + {
91 + <div class="scroll">
92 + <table>
93 + <tbody>
94 + @for (var index = 0; index < view.Diff.Files.Count; index++)
95 + {
96 + var file = view.Diff.Files[index];
97 + <tr>
98 + <td class="wide"><a href="#@Anchor(index)">@file.Path</a></td>
99 + <td class="dim">@ChangeLabel(file.Change)</td>
100 + <td class="num stat">
101 + <span class="plus">+@file.Added</span>
102 + <span class="minus">-@file.Deleted</span>
103 + </td>
104 + </tr>
105 + }
106 + </tbody>
107 + </table>
108 + </div>
109 + }
110 +
111 + @if (view.Diff.Truncated)
112 + {
113 + <p class="empty">
114 + This diff is longer than one page will show. The files below it keep their
115 + counts; read them at
116 + <a href="@TreeLink("", view.Commit.Sha)">this commit's tree</a> instead.
117 + </p>
118 + }
119 + </section>
120 +
121 + @for (var index = 0; index < view.Diff.Files.Count; index++)
122 + {
123 + var file = view.Diff.Files[index];
124 +
125 + <section class="block diff-file" id="@Anchor(index)">
126 + <h2 class="block-head">
127 + <span>
128 + @if (file.OldPath is { } old)
129 + {
130 + <span class="dim">@old → </span>
131 + }
132 + <a href="@TreeLink(file.Path, view.Commit.Sha)">@file.Path</a>
133 + </span>
134 + <span class="block-note stat">
135 + <span class="plus">+@file.Added</span>
136 + <span class="minus">-@file.Deleted</span>
137 + </span>
138 + </h2>
139 +
140 + @if (file.IsBinary)
141 + {
142 + <p class="empty">
143 + Binary file.
144 + <a href="@RawLink(file.Path, view.Commit.Sha)">Download it</a> as it was here.
145 + </p>
146 + }
147 + else if (file.Skipped)
148 + {
149 + <p class="empty">Too far down a long diff to render. @file.Added added, @file.Deleted removed.</p>
150 + }
151 + else if (file.Hunks.Count == 0)
152 + {
153 + <p class="empty">@(ChangeLabel(file.Change) is { Length: > 0 } label ? label : "No textual change") — no lines changed.</p>
154 + }
155 + else
156 + {
157 + <div class="scroll">
158 + <table class="code-table diff">
159 + <tbody>
160 + @foreach (var hunk in file.Hunks)
161 + {
162 + <tr class="hunk">
163 + <td colspan="3">@hunk.Header</td>
164 + </tr>
165 +
166 + @foreach (var line in hunk.Lines)
167 + {
168 + @if (line.Origin == '\\')
169 + {
170 + <tr class="note">
171 + <td colspan="3">@line.Text</td>
172 + </tr>
173 + }
174 + else
175 + {
176 + <tr class="@Row(line.Origin)">
177 + <td class="ln">@line.OldNumber</td>
178 + <td class="ln">@line.NewNumber</td>
179 + <td class="code"><span class="sign">@line.Origin</span>@line.Text</td>
180 + </tr>
181 + }
182 + }
183 + }
184 + </tbody>
185 + </table>
186 + </div>
187 + }
188 + </section>
189 + }
190 + }
191 +</main>
192 +
193 +@code {
194 + [Parameter]
195 + public string Sha { get; set; } = "";
196 +
197 + private GitCommitView? View { get; set; }
198 +
199 + private string Short => Shorten(View?.Commit.Sha ?? Sha);
200 +
201 + private string Title => View is { } view ? $"{view.Commit.Summary} · {Repository}" : Repository;
202 +
203 + protected override void OnParametersSet()
204 + {
205 + View = Service.GetCommit(Repository, Sha);
206 +
207 + if (View is null) NotFound();
208 + }
209 +
210 + private static string Shorten(string sha) => sha.Length >= 10 ? sha[..10] : sha;
211 +
212 + /// <summary>A committer line worth printing is one that says something the author's didn't.</summary>
213 + private static bool Differs(GitCommitInfo commit) =>
214 + commit.Committer.Email != commit.Author.Email || commit.Committer.When != commit.Author.When;
215 +
216 + private static string Changed(GitDiff diff) =>
217 + diff.Files.Count == 1 ? "1 file changed" : $"{diff.Files.Count} files changed";
218 +
219 + private static string Anchor(int index) => $"f{index}";
220 +
221 + private static string Row(char origin) => origin switch
222 + {
223 + '+' => "add",
224 + '-' => "del",
225 + _ => "ctx"
226 + };
227 +}

Blog/Components/Pages/Git/GitIndex.razor +68 -0

@@ -0,0 +1,68 @@
1 +@page "/git"
2 +@inherits GitPage
3 +
4 +<PageTitle>git — mb.bes.is</PageTitle>
5 +
6 +<GitBar/>
7 +
8 +<main class="git-main">
9 + <section class="block">
10 + <h2 class="block-head">
11 + <span>repositories</span>
12 + <span class="block-note">@Repositories.Count</span>
13 + </h2>
14 +
15 + @if (Repositories.Count == 0)
16 + {
17 + <p class="empty">Nothing to browse: there are no repositories under @Service.Root.</p>
18 + }
19 + else
20 + {
21 + <div class="scroll">
22 + <table>
23 + <thead>
24 + <tr>
25 + <th>name</th>
26 + <th class="wide">description</th>
27 + <th class="when-wide">branch</th>
28 + @if (Owned)
29 + {
30 + <th class="when-wide">owner</th>
31 + }
32 + <th class="num">idle</th>
33 + </tr>
34 + </thead>
35 + <tbody>
36 + @foreach (var repository in Repositories)
37 + {
38 + <tr>
39 + <td><a href="/git/@Uri.EscapeDataString(repository.Name)">@repository.DisplayName</a></td>
40 + <td class="wide dim">@(repository.Description ?? "")</td>
41 + <td class="dim when-wide">@(repository.Head ?? "")</td>
42 + @if (Owned)
43 + {
44 + <td class="dim when-wide">@(repository.Owner ?? "")</td>
45 + }
46 + <td class="num dim" title="@Exact(repository.Tip?.Committer.When)">
47 + @Age(repository.Tip?.Committer.When)
48 + </td>
49 + </tr>
50 + }
51 + </tbody>
52 + </table>
53 + </div>
54 + }
55 + </section>
56 +</main>
57 +
58 +@code {
59 + private IReadOnlyList<GitRepoSummary> Repositories { get; set; } = [];
60 +
61 + /// <summary>
62 + /// Owner comes from each repository's <c>gitweb.owner</c>, and most never set it. A column of
63 + /// nothing is the opposite of what this page is for, so it only appears once it says something.
64 + /// </summary>
65 + private bool Owned => Repositories.Any(repository => repository.Owner is { Length: > 0 });
66 +
67 + protected override void OnParametersSet() => Repositories = Service.ListRepositories();
68 +}

Blog/Components/Pages/Git/GitLog.razor +153 -0

@@ -0,0 +1,153 @@
1 +@page "/git/{Repository}/log"
2 +@inherits GitPage
3 +
4 +<PageTitle>@Repository log — git</PageTitle>
5 +
6 +<GitBar Repository="@Repository" Reference="@Current" Tab="log"/>
7 +
8 +<main class="git-main">
9 + @if (Page is not { } page)
10 + {
11 + <section class="block">
12 + <p class="empty">
13 + @(BranchNames.Count == 0
14 + ? $"Nothing has been pushed to {Repository} yet."
15 + : $"No revision called {Reference ?? "HEAD"} in {Repository}.")
16 + </p>
17 + </section>
18 + }
19 + else
20 + {
21 + <section class="block">
22 + <h2 class="block-head">
23 + <span>log</span>
24 + <span class="block-note">@Where</span>
25 + </h2>
26 +
27 + @* A plain GET form: the log is a URL, and narrowing it should produce one you can
28 + link to. Nothing here needs script. *@
29 + <div class="block-body">
30 + <form method="get" action="@($"{RepoLink()}/log")" class="controls">
31 + <label>
32 + branch
33 + <select name="h">
34 + @foreach (var branch in BranchNames)
35 + {
36 + <option value="@branch" selected="@(branch == Current)">@branch</option>
37 + }
38 + </select>
39 + </label>
40 + <label>
41 + path
42 + <input type="text" name="path" value="@Path" placeholder="Blog/Services" size="24"/>
43 + </label>
44 + <button type="submit">show</button>
45 + </form>
46 + </div>
47 +
48 + @if (page.Commits.Count == 0)
49 + {
50 + <p class="empty">No commits@(string.IsNullOrEmpty(Path) ? "" : $" touching {Path}").</p>
51 + }
52 + else
53 + {
54 + <div class="scroll">
55 + <table>
56 + <thead>
57 + <tr>
58 + <th>age</th>
59 + <th>commit</th>
60 + <th class="wide">subject</th>
61 + <th class="when-wide">author</th>
62 + </tr>
63 + </thead>
64 + <tbody>
65 + @foreach (var commit in page.Commits)
66 + {
67 + <tr>
68 + <td class="dim" title="@Exact(commit.Author.When)">@Age(commit.Author.When)</td>
69 + <td class="sha dim"><a href="@CommitLink(commit.Sha)">@commit.ShortSha</a></td>
70 + <td class="wide">
71 + <a href="@CommitLink(commit.Sha)" class="subject">@commit.Summary</a>
72 + @if (commit.IsMerge)
73 + {
74 + <span class="ref">merge</span>
75 + }
76 + @foreach (var name in commit.Refs)
77 + {
78 + <span class="ref @(name == Current ? "head" : null)">@name</span>
79 + }
80 + </td>
81 + <td class="dim when-wide" title="@commit.Author.Email">@commit.Author.Name</td>
82 + </tr>
83 + }
84 + </tbody>
85 + </table>
86 + </div>
87 +
88 + <div class="pager">
89 + @if (Skip > 0)
90 + {
91 + <a href="@LogLink(Current, Path, Math.Max(0, Skip - PageSize))">newer</a>
92 + }
93 + else
94 + {
95 + <span>newest</span>
96 + }
97 +
98 + @if (page.HasMore)
99 + {
100 + <a href="@LogLink(Current, Path, Skip + PageSize)">older</a>
101 + }
102 + else
103 + {
104 + <span>oldest</span>
105 + }
106 + </div>
107 + }
108 + </section>
109 + }
110 +</main>
111 +
112 +@code {
113 + private const int PageSize = 50;
114 +
115 + /// <summary>Narrows the log to the commits that touched one file or directory.</summary>
116 + [SupplyParameterFromQuery(Name = "path")]
117 + public string? Path { get; set; }
118 +
119 + /// <summary>How many commits to skip — the offset cgit paged with, under the name it used.</summary>
120 + [SupplyParameterFromQuery(Name = "ofs")]
121 + public int Skip { get; set; }
122 +
123 + private GitLogPage? Page { get; set; }
124 +
125 + private IReadOnlyList<string> BranchNames { get; set; } = [];
126 +
127 + /// <summary>The branch this page is of, which is HEAD's when the query string named none.</summary>
128 + private string? Current { get; set; }
129 +
130 + private string Where => string.IsNullOrEmpty(Path) ? Current ?? "" : $"{Current} · {Path}";
131 +
132 + protected override void OnParametersSet()
133 + {
134 + Skip = Math.Max(0, Skip);
135 +
136 + // GetRefs lists HEAD's branch first, so the picker's first entry is also what this page
137 + // defaults to — one repository open rather than two.
138 + if (Service.GetRefs(Repository) is not { } refs)
139 + {
140 + NotFound();
141 + return;
142 + }
143 +
144 + BranchNames = refs.Branches.Select(branch => branch.Name).ToArray();
145 + Current = Reference ?? BranchNames.FirstOrDefault();
146 +
147 + Page = Service.GetLog(Repository, Reference, Path, Skip, PageSize);
148 +
149 + // A repository with branches but nothing at this revision was reached by a bad URL. One
150 + // with no branches at all is a repository nobody has pushed to, which is not an error.
151 + if (Page is null && BranchNames.Count > 0) NotFound();
152 + }
153 +}

Blog/Components/Pages/Git/GitPage.cs +153 -0

@@ -0,0 +1,153 @@
1 +using System.Globalization;
2 +using Blog.Models;
3 +using Blog.Services;
4 +using Microsoft.AspNetCore.Components;
5 +
6 +namespace Blog.Components.Pages.Git;
7 +
8 +/// <summary>
9 +/// What every page under <c>/git</c> shares: the service, the repository the route named, the
10 +/// branch the query string asked for, and the handful of renderings a repository browser repeats
11 +/// on every screen.
12 +/// </summary>
13 +/// <remarks>
14 +/// A base class rather than a set of static helpers because the link builders need the repository,
15 +/// and threading that through a dozen call sites per page is noise. Nothing here holds state
16 +/// between requests.
17 +/// </remarks>
18 +public abstract class GitPage : ComponentBase
19 +{
20 + /// <summary>
21 + /// Named Service rather than Git: this namespace is itself called Git, and a property that
22 + /// shadows the namespace it lives in is a puzzle nobody needs to solve twice.
23 + /// </summary>
24 + [Inject]
25 + public required GitService Service { get; set; }
26 +
27 + /// <summary>
28 + /// The response, so a missing repository or revision can answer 404 rather than 200 with an
29 + /// apology on it. Null outside a request.
30 + /// </summary>
31 + [CascadingParameter]
32 + public HttpContext? HttpContext { get; set; }
33 +
34 + /// <summary>The repository's directory name, straight off the route. Empty on the index.</summary>
35 + [Parameter]
36 + public string Repository { get; set; } = "";
37 +
38 + /// <summary>
39 + /// Which branch, tag or commit the page is about. <c>h</c> is the name cgit used and the one
40 + /// every link here carries, so old bookmarks keep working.
41 + /// </summary>
42 + [SupplyParameterFromQuery(Name = "h")]
43 + public string? Reference { get; set; }
44 +
45 + /// <summary>Set by a page that found nothing, to answer 404 before the body goes out.</summary>
46 + protected void NotFound()
47 + {
48 + if (HttpContext is { Response.HasStarted: false } context) context.Response.StatusCode = 404;
49 + }
50 +
51 + // -- Links ---------------------------------------------------------------
52 + //
53 + // Every path segment is escaped separately: a branch may be `feature/thing` and a file may be
54 + // called anything at all, and neither should be able to change the shape of the URL.
55 +
56 + protected string RepoLink() => $"/git/{Escape(Repository)}";
57 +
58 + protected string LogLink(string? reference = null, string? path = null, int skip = 0)
59 + {
60 + var query = Query(
61 + ("h", reference ?? Reference),
62 + ("path", path),
63 + ("ofs", skip > 0 ? skip.ToString(CultureInfo.InvariantCulture) : null));
64 +
65 + return $"{RepoLink()}/log{query}";
66 + }
67 +
68 + protected string TreeLink(string path = "", string? reference = null) =>
69 + $"{RepoLink()}/tree{EscapePath(path)}{Query(("h", reference ?? Reference))}";
70 +
71 + protected string RawLink(string path, string? reference = null) =>
72 + $"{RepoLink()}/raw{EscapePath(path)}{Query(("h", reference ?? Reference))}";
73 +
74 + protected string CommitLink(string sha) => $"{RepoLink()}/commit/{Escape(sha)}";
75 +
76 + protected string RefsLink() => $"{RepoLink()}/refs";
77 +
78 + private static string EscapePath(string path) =>
79 + path.Length == 0 ? "" : "/" + string.Join('/', path.Split('/').Select(Escape));
80 +
81 + private static string Escape(string value) => Uri.EscapeDataString(value);
82 +
83 + private static string Query(params (string Key, string? Value)[] parts)
84 + {
85 + var set = parts
86 + .Where(part => !string.IsNullOrEmpty(part.Value))
87 + .Select(part => $"{part.Key}={Escape(part.Value!)}")
88 + .ToArray();
89 +
90 + return set.Length == 0 ? "" : "?" + string.Join('&', set);
91 + }
92 +
93 + // -- Renderings ----------------------------------------------------------
94 +
95 + /// <summary>
96 + /// How long ago, in the one unit that carries the answer. A repository browser asks this of
97 + /// every row, and "3 months" is the whole of what a reader wants from a commit that old.
98 + /// </summary>
99 + protected static string Age(DateTimeOffset? when)
100 + {
101 + if (when is not { } at) return "";
102 +
103 + var span = DateTimeOffset.UtcNow - at;
104 + if (span < TimeSpan.Zero) span = TimeSpan.Zero;
105 +
106 + return span switch
107 + {
108 + { TotalSeconds: < 60 } => "just now",
109 + { TotalMinutes: < 60 } => $"{(int)span.TotalMinutes} min",
110 + { TotalHours: < 24 } => Count((int)span.TotalHours, "hour"),
111 + { TotalDays: < 14 } => Count((int)span.TotalDays, "day"),
112 + { TotalDays: < 60 } => Count((int)(span.TotalDays / 7), "week"),
113 + { TotalDays: < 730 } => Count((int)(span.TotalDays / 30), "month"),
114 + _ => Count((int)(span.TotalDays / 365), "year")
115 + };
116 + }
117 +
118 + private static string Count(int n, string unit) => $"{n} {unit}{(n == 1 ? "" : "s")}";
119 +
120 + /// <summary>The exact time, for the title of whatever <see cref="Age"/> rendered.</summary>
121 + protected static string Exact(DateTimeOffset? when) =>
122 + when?.ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) ?? "";
123 +
124 + protected static string Size(long bytes) => bytes switch
125 + {
126 + < 1024 => $"{bytes} B",
127 + < 1024 * 1024 => (bytes / 1024.0).ToString("0.#", CultureInfo.InvariantCulture) + " K",
128 + _ => (bytes / (1024.0 * 1024)).ToString("0.#", CultureInfo.InvariantCulture) + " M"
129 + };
130 +
131 + /// <summary>
132 + /// git's own octal file mode. There are only five of them, and to anyone reading a tree the
133 + /// difference between 100644 and 100755 is the point of the column.
134 + /// </summary>
135 + protected static string Mode(GitEntryKind kind) => kind switch
136 + {
137 + GitEntryKind.Directory => "040000",
138 + GitEntryKind.Executable => "100755",
139 + GitEntryKind.Symlink => "120000",
140 + GitEntryKind.Submodule => "160000",
141 + _ => "100644"
142 + };
143 +
144 + protected static string ChangeLabel(GitChange change) => change switch
145 + {
146 + GitChange.Added => "added",
147 + GitChange.Deleted => "deleted",
148 + GitChange.Renamed => "renamed",
149 + GitChange.Copied => "copied",
150 + GitChange.TypeChanged => "mode",
151 + _ => ""
152 + };
153 +}

Blog/Components/Pages/Git/GitRefs.razor +122 -0

@@ -0,0 +1,122 @@
1 +@page "/git/{Repository}/refs"
2 +@inherits GitPage
3 +
4 +<PageTitle>@Repository refs — git</PageTitle>
5 +
6 +<GitBar Repository="@Repository" Reference="@Reference" Tab="refs"/>
7 +
8 +<main class="git-main">
9 + @if (Refs is not { } refs)
10 + {
11 + <section class="block">
12 + <p class="empty">No repository called @Repository.</p>
13 + </section>
14 + }
15 + else
16 + {
17 + <section class="block">
18 + <h2 class="block-head">
19 + <span>branches</span>
20 + <span class="block-note">@refs.Branches.Count</span>
21 + </h2>
22 +
23 + @if (refs.Branches.Count == 0)
24 + {
25 + <p class="empty">No branches.</p>
26 + }
27 + else
28 + {
29 + <div class="scroll">
30 + <table>
31 + <thead>
32 + <tr>
33 + <th>name</th>
34 + <th>commit</th>
35 + <th class="wide">subject</th>
36 + <th class="when-wide">author</th>
37 + <th class="num">age</th>
38 + </tr>
39 + </thead>
40 + <tbody>
41 + @foreach (var branch in refs.Branches)
42 + {
43 + <tr>
44 + <td><a href="@LogLink(branch.Name)">@branch.Name</a></td>
45 + <td class="sha dim"><a href="@CommitLink(branch.Sha)">@Shorten(branch.Sha)</a></td>
46 + <td class="wide">@branch.Tip?.Summary</td>
47 + <td class="dim when-wide">@branch.Tip?.Author.Name</td>
48 + <td class="num dim" title="@Exact(branch.Tip?.Committer.When)">
49 + @Age(branch.Tip?.Committer.When)
50 + </td>
51 + </tr>
52 + }
53 + </tbody>
54 + </table>
55 + </div>
56 + }
57 + </section>
58 +
59 + <section class="block">
60 + <h2 class="block-head">
61 + <span>tags</span>
62 + <span class="block-note">@refs.Tags.Count</span>
63 + </h2>
64 +
65 + @if (refs.Tags.Count == 0)
66 + {
67 + <p class="empty">No tags.</p>
68 + }
69 + else
70 + {
71 + <div class="scroll">
72 + <table>
73 + <thead>
74 + <tr>
75 + <th>name</th>
76 + <th>commit</th>
77 + <th class="wide">message</th>
78 + <th class="num">age</th>
79 + </tr>
80 + </thead>
81 + <tbody>
82 + @foreach (var tag in refs.Tags)
83 + {
84 + <tr>
85 + <td><a href="@TreeLink("", tag.Name)">@tag.Name</a></td>
86 + <td class="sha dim">
87 + @if (tag.Tip is { } tip)
88 + {
89 + <a href="@CommitLink(tip.Sha)">@Shorten(tip.Sha)</a>
90 + }
91 + </td>
92 + @* An annotated tag's own message, falling back to the subject of
93 + what it points at — which is all a lightweight tag has. *@
94 + <td class="wide">@(First(tag.Message) ?? tag.Tip?.Summary)</td>
95 + <td class="num dim" title="@Exact(tag.Tip?.Committer.When)">
96 + @Age(tag.Tip?.Committer.When)
97 + </td>
98 + </tr>
99 + }
100 + </tbody>
101 + </table>
102 + </div>
103 + }
104 + </section>
105 + }
106 +</main>
107 +
108 +@code {
109 + private GitRefsView? Refs { get; set; }
110 +
111 + protected override void OnParametersSet()
112 + {
113 + Refs = Service.GetRefs(Repository);
114 +
115 + if (Refs is null) NotFound();
116 + }
117 +
118 + private static string Shorten(string sha) => sha.Length >= 10 ? sha[..10] : sha;
119 +
120 + private static string? First(string? message) =>
121 + message?.Split('\n', 2)[0].Trim() is { Length: > 0 } line ? line : null;
122 +}

Blog/Components/Pages/Git/GitSummary.razor +122 -0

@@ -0,0 +1,122 @@
1 +@page "/git/{Repository}"
2 +@inherits GitPage
3 +
4 +<PageTitle>@Repository — git</PageTitle>
5 +
6 +<GitBar Repository="@Repository" Reference="@(Reference ?? Info?.Head)" Tab="log"/>
7 +
8 +<main class="git-main">
9 + @if (Info is not { } info)
10 + {
11 + <section class="block">
12 + <p class="empty">No repository called @Repository.</p>
13 + </section>
14 + }
15 + else
16 + {
17 + <section class="block">
18 + <h2 class="block-head">
19 + <span>@info.DisplayName</span>
20 + @if (info.Head is { } head)
21 + {
22 + <span class="block-note">on @head</span>
23 + }
24 + </h2>
25 +
26 + <div class="block-body">
27 + <dl class="pairs">
28 + @if (info.Description is { } description)
29 + {
30 + <dt>about</dt>
31 + <dd>@description</dd>
32 + }
33 + @if (info.Owner is { } owner)
34 + {
35 + <dt>owner</dt>
36 + <dd>@owner</dd>
37 + }
38 + @if (Service.CloneUrl(info.Name) is { } clone)
39 + {
40 + <dt>clone</dt>
41 + <dd class="copyable">@clone</dd>
42 + }
43 + @if (Refs is { } refs)
44 + {
45 + <dt>refs</dt>
46 + <dd>
47 + <a href="@RefsLink()">@Branches(refs), @Tags(refs)</a>
48 + </dd>
49 + }
50 + </dl>
51 + </div>
52 + </section>
53 +
54 + @if (info.Tip is null)
55 + {
56 + <section class="block">
57 + <p class="empty">Nothing committed yet.</p>
58 + </section>
59 + }
60 + else
61 + {
62 + <section class="block">
63 + <h2 class="block-head">
64 + <span>recent commits</span>
65 + <span class="block-note"><a href="@LogLink(info.Head)">full log</a></span>
66 + </h2>
67 +
68 + <div class="scroll">
69 + <table>
70 + <tbody>
71 + @foreach (var commit in Recent)
72 + {
73 + <tr>
74 + <td class="dim" title="@Exact(commit.Author.When)">@Age(commit.Author.When)</td>
75 + <td class="sha dim"><a href="@CommitLink(commit.Sha)">@commit.ShortSha</a></td>
76 + <td class="wide">
77 + <a href="@CommitLink(commit.Sha)" class="subject">@commit.Summary</a>
78 + @foreach (var name in commit.Refs)
79 + {
80 + <span class="ref @(name == info.Head ? "head" : null)">@name</span>
81 + }
82 + </td>
83 + <td class="dim when-wide">@commit.Author.Name</td>
84 + </tr>
85 + }
86 + </tbody>
87 + </table>
88 + </div>
89 + </section>
90 + }
91 + }
92 +</main>
93 +
94 +@code {
95 + private const int Recently = 15;
96 +
97 + private GitRepoSummary? Info { get; set; }
98 +
99 + private GitRefsView? Refs { get; set; }
100 +
101 + private IReadOnlyList<GitCommitInfo> Recent { get; set; } = [];
102 +
103 + protected override void OnParametersSet()
104 + {
105 + Info = Service.GetRepository(Repository);
106 +
107 + if (Info is null)
108 + {
109 + NotFound();
110 + return;
111 + }
112 +
113 + Refs = Service.GetRefs(Repository);
114 + Recent = Service.GetLog(Repository, Reference, null, 0, Recently)?.Commits ?? [];
115 + }
116 +
117 + private static string Branches(GitRefsView refs) =>
118 + refs.Branches.Count == 1 ? "1 branch" : $"{refs.Branches.Count} branches";
119 +
120 + private static string Tags(GitRefsView refs) =>
121 + refs.Tags.Count == 1 ? "1 tag" : $"{refs.Tags.Count} tags";
122 +}

Blog/Components/Pages/Git/GitTree.razor +188 -0

@@ -0,0 +1,188 @@
1 +@page "/git/{Repository}/tree"
2 +@page "/git/{Repository}/tree/{*Path}"
3 +@inherits GitPage
4 +
5 +<PageTitle>@Title — git</PageTitle>
6 +
7 +<GitBar Repository="@Repository" Reference="@At" Tab="tree" Path="@Crumbs"/>
8 +
9 +<main class="git-main">
10 + @if (View is not { } view)
11 + {
12 + <section class="block">
13 + <p class="empty">No revision @(Reference ?? "HEAD") in @Repository.</p>
14 + </section>
15 + }
16 + else if (view.Entries is { } entries)
17 + {
18 + <section class="block tree">
19 + <h2 class="block-head">
20 + <span>@(Path is { Length: > 0 } ? Path : "/")</span>
21 + <span class="block-note">
22 + @Counted(entries) at
23 + <a class="sha" href="@CommitLink(view.Commit.Sha)">@view.Commit.ShortSha</a>
24 + </span>
25 + </h2>
26 +
27 + <div class="scroll">
28 + <table>
29 + <thead>
30 + <tr>
31 + <th>mode</th>
32 + <th class="wide">name</th>
33 + <th class="num">size</th>
34 + </tr>
35 + </thead>
36 + <tbody>
37 + @if (Parent is { } parent)
38 + {
39 + <tr>
40 + <td class="mode"></td>
41 + <td class="wide"><a href="@TreeLink(parent, At)">..</a></td>
42 + <td class="num"></td>
43 + </tr>
44 + }
45 + @foreach (var entry in entries)
46 + {
47 + <tr class="@(entry.Kind == GitEntryKind.Directory ? "dir" : null)">
48 + <td class="mode dim">@Mode(entry.Kind)</td>
49 + <td class="wide">
50 + @if (entry.Kind == GitEntryKind.Submodule)
51 + {
52 + @entry.Name
53 + <span class="ref">@entry.Sha[..10]</span>
54 + }
55 + else
56 + {
57 + <a href="@TreeLink(entry.Path, At)">@entry.Name</a>
58 + }
59 + </td>
60 + <td class="num dim">@(entry.Kind == GitEntryKind.Directory ? "" : Size(entry.Size))</td>
61 + </tr>
62 + }
63 + </tbody>
64 + </table>
65 + </div>
66 + </section>
67 + }
68 + else if (view.Blob is { } blob)
69 + {
70 + <section class="block">
71 + <h2 class="block-head">
72 + <span>@blob.Path</span>
73 + <span class="block-note">
74 + @Size(blob.Size)@(blob.Lines is { } counted ? $" · {Counted(counted.Count, "line")}" : "")
75 + · <a href="@RawLink(blob.Path, At)">raw</a>
76 + · <a href="@LogLink(At, blob.Path)">history</a>
77 + </span>
78 + </h2>
79 +
80 + @if (blob.IsBinary)
81 + {
82 + <p class="empty">
83 + Binary file. <a href="@RawLink(blob.Path, At)">Download it</a> to look inside.
84 + </p>
85 + }
86 + else if (blob.TooLarge)
87 + {
88 + <p class="empty">
89 + Too large to print here. <a href="@RawLink(blob.Path, At)">Read it raw</a>.
90 + </p>
91 + }
92 + else if (blob.Lines is { Count: 0 })
93 + {
94 + <p class="empty">Empty file.</p>
95 + }
96 + else if (blob.Lines is { } lines)
97 + {
98 + @* Every line is addressable: #L42 scrolls to it and marks it, so a line of code
99 + can be linked to from anywhere. *@
100 + <div class="scroll">
101 + <table class="code-table">
102 + <tbody>
103 + @for (var number = 1; number <= lines.Count; number++)
104 + {
105 + <tr id="L@(number)">
106 + <td class="ln"><a href="#L@(number)">@number</a></td>
107 + <td class="code">@lines[number - 1]</td>
108 + </tr>
109 + }
110 + </tbody>
111 + </table>
112 + </div>
113 + }
114 + </section>
115 + }
116 + else
117 + {
118 + <section class="block">
119 + <p class="empty">Nothing browsable at @Path in @view.Reference.</p>
120 + </section>
121 + }
122 +</main>
123 +
124 +@code {
125 + /// <summary>The path inside the repository, straight off the catch-all route segment.</summary>
126 + [Parameter]
127 + public string? Path { get; set; }
128 +
129 + private GitPathView? View { get; set; }
130 +
131 + /// <summary>
132 + /// The revision every link on this page stays on: what was asked for, or the branch the
133 + /// repository's HEAD is on. Following a tree into a directory should not silently move you.
134 + /// </summary>
135 + private string? At => Reference ?? View?.Reference;
136 +
137 + private string Title =>
138 + Path is { Length: > 0 } path ? $"{path} · {Repository}" : $"{Repository} tree";
139 +
140 + private IReadOnlyList<GitBar.Crumb> Crumbs { get; set; } = [];
141 +
142 + /// <summary>The directory above, or null at the root.</summary>
143 + private string? Parent
144 + {
145 + get
146 + {
147 + if (Path is not { Length: > 0 } path) return null;
148 + var cut = path.LastIndexOf('/');
149 + return cut < 0 ? "" : path[..cut];
150 + }
151 + }
152 +
153 + protected override void OnParametersSet()
154 + {
155 + Path = Path?.Trim('/');
156 + View = Service.GetPath(Repository, Reference, Path);
157 +
158 + if (View is null || (View.Entries is null && View.Blob is null)) NotFound();
159 +
160 + Crumbs = BuildCrumbs();
161 + }
162 +
163 + /// <summary>
164 + /// One step per path segment, the last of them the page you are on. The bar already holds the
165 + /// repository, so this starts inside it.
166 + /// </summary>
167 + private IReadOnlyList<GitBar.Crumb> BuildCrumbs()
168 + {
169 + if (Path is not { Length: > 0 } path) return [];
170 +
171 + var segments = path.Split('/');
172 + var crumbs = new List<GitBar.Crumb>(segments.Length);
173 + var walked = "";
174 +
175 + foreach (var segment in segments)
176 + {
177 + walked = walked.Length == 0 ? segment : $"{walked}/{segment}";
178 + crumbs.Add(new GitBar.Crumb(segment, walked == path ? null : TreeLink(walked, At)));
179 + }
180 +
181 + return crumbs;
182 + }
183 +
184 + private static string Counted(IReadOnlyList<GitTreeEntry> entries) => Counted(entries.Count, "entry", "entries");
185 +
186 + private static string Counted(int n, string one, string? many = null) =>
187 + n == 1 ? $"1 {one}" : $"{n} {many ?? one + "s"}";
188 +}

Blog/Components/Pages/Git/_Imports.razor +6 -0

@@ -0,0 +1,6 @@
1 +@using Blog.Components.Layout
2 +@using Blog.Models
3 +@using Blog.Services
4 +
5 +@* Every routable component in this folder is part of the repository browser. *@
6 +@layout GitLayout

Blog/Models/GitRepositories.cs +148 -0

@@ -0,0 +1,148 @@
1 +namespace Blog.Models;
2 +
3 +/// <summary>
4 +/// A name, an address and a time — git's author and committer lines, flattened out of libgit2's
5 +/// handles so a page can still read them after the repository has been closed.
6 +/// </summary>
7 +public sealed record GitSignature(string Name, string Email, DateTimeOffset When);
8 +
9 +/// <summary>One repository under the repository root, as the index lists it.</summary>
10 +/// <param name="Name">The directory name, which is also the route segment: <c>blog.git</c>.</param>
11 +/// <param name="DisplayName">The same without the suffix, for headings: <c>blog</c>.</param>
12 +/// <param name="Description">
13 +/// The <c>description</c> file, unless it still holds the placeholder <c>git init</c> writes.
14 +/// </param>
15 +/// <param name="Owner"><c>gitweb.owner</c> from the repository config, which is what cgit reads.</param>
16 +/// <param name="Head">The branch HEAD points at, or null when it is unborn.</param>
17 +/// <param name="Tip">The commit on that branch, and when it landed.</param>
18 +public sealed record GitRepoSummary(
19 + string Name,
20 + string DisplayName,
21 + string? Description,
22 + string? Owner,
23 + string? Head,
24 + GitCommitInfo? Tip);
25 +
26 +/// <summary>A commit, with everything a page shows about it.</summary>
27 +/// <param name="Refs">
28 +/// The branch and tag names whose tips are this commit. Filled in on the log, where the badges
29 +/// mark where each branch has got to; empty everywhere else.
30 +/// </param>
31 +public sealed record GitCommitInfo(
32 + string Sha,
33 + string Summary,
34 + string Body,
35 + GitSignature Author,
36 + GitSignature Committer,
37 + IReadOnlyList<string> Parents,
38 + IReadOnlyList<string> Refs)
39 +{
40 + /// <summary>Enough of the hash to be unambiguous, and short enough to sit in a table.</summary>
41 + public string ShortSha => Sha.Length >= 10 ? Sha[..10] : Sha;
42 +
43 + /// <summary>A commit with two or more parents is diffed against the first, as git does.</summary>
44 + public bool IsMerge => Parents.Count > 1;
45 +}
46 +
47 +/// <summary>A page of history, and whether there is another one behind it.</summary>
48 +public sealed record GitLogPage(IReadOnlyList<GitCommitInfo> Commits, bool HasMore);
49 +
50 +/// <summary>What a tree entry is, as far as the listing cares.</summary>
51 +public enum GitEntryKind
52 +{
53 + Directory,
54 + File,
55 +
56 + /// <summary>Mode 100755. Worth marking: it is the difference between a script and a text file.</summary>
57 + Executable,
58 +
59 + Symlink,
60 +
61 + /// <summary>Another repository, pinned at a commit. There is nothing here to browse into.</summary>
62 + Submodule
63 +}
64 +
65 +/// <param name="Path">Full path from the repository root, which is what the tree links carry.</param>
66 +public sealed record GitTreeEntry(string Name, string Path, GitEntryKind Kind, long Size, string Sha);
67 +
68 +/// <param name="IsBinary">
69 +/// Decided the way git decides it: a NUL byte anywhere in the first few kilobytes. Binary blobs
70 +/// are linked to the raw endpoint instead of being printed.
71 +/// </param>
72 +/// <param name="Lines">The blob's text split for numbering, or null when it is not shown.</param>
73 +public sealed record GitBlob(
74 + string Path,
75 + string Sha,
76 + long Size,
77 + bool IsBinary,
78 + bool TooLarge,
79 + IReadOnlyList<string>? Lines);
80 +
81 +/// <summary>
82 +/// One path in one revision: a directory listing, or a file. Which of the two is set says which
83 +/// the path turned out to be.
84 +/// </summary>
85 +/// <param name="Reference">The revision as it was asked for, for links that stay on this branch.</param>
86 +public sealed record GitPathView(
87 + GitCommitInfo Commit,
88 + string Reference,
89 + string Path,
90 + IReadOnlyList<GitTreeEntry>? Entries,
91 + GitBlob? Blob);
92 +
93 +public enum GitChange
94 +{
95 + Added,
96 + Deleted,
97 + Modified,
98 + Renamed,
99 + Copied,
100 + TypeChanged
101 +}
102 +
103 +/// <param name="Origin">
104 +/// <c>' '</c> context, <c>'+'</c> added, <c>'-'</c> removed, <c>'\'</c> for git's
105 +/// "No newline at end of file" note.
106 +/// </param>
107 +public sealed record GitDiffLine(char Origin, int? OldNumber, int? NewNumber, string Text);
108 +
109 +/// <param name="Header">The <c>@@ -a,b +c,d @@</c> line, section heading and all.</param>
110 +public sealed record GitDiffHunk(string Header, IReadOnlyList<GitDiffLine> Lines);
111 +
112 +/// <param name="Hunks">Empty for a binary file, and for one dropped to keep the page finite.</param>
113 +public sealed record GitDiffFile(
114 + string Path,
115 + string? OldPath,
116 + GitChange Change,
117 + int Added,
118 + int Deleted,
119 + bool IsBinary,
120 + bool Skipped,
121 + IReadOnlyList<GitDiffHunk> Hunks);
122 +
123 +/// <param name="Truncated">
124 +/// Set when the diff ran past the line budget and the rest of the files kept their stats but lost
125 +/// their hunks. A commit that vendors a 4 MB file should not render 4 MB of green.
126 +/// </param>
127 +public sealed record GitDiff(IReadOnlyList<GitDiffFile> Files, int Added, int Deleted, bool Truncated);
128 +
129 +/// <summary>
130 +/// One file's bytes at one revision, for the raw endpoint. <paramref name="IsBinary"/> decides
131 +/// whether it is handed over as text or as a download.
132 +/// </summary>
133 +public sealed record GitRawBlob(byte[] Bytes, bool IsBinary, string Name);
134 +
135 +/// <summary>A commit and the diff that goes with it — everything the commit page renders.</summary>
136 +public sealed record GitCommitView(GitCommitInfo Commit, GitDiff Diff);
137 +
138 +/// <summary>A branch or a tag, with the commit it points at.</summary>
139 +/// <param name="Message">An annotated tag's own message. Null for branches and lightweight tags.</param>
140 +public sealed record GitRef(
141 + string Name,
142 + bool IsTag,
143 + string Sha,
144 + GitCommitInfo? Tip,
145 + string? Message);
146 +
147 +/// <summary>Every ref in a repository, branches and tags kept apart as the refs page shows them.</summary>
148 +public sealed record GitRefsView(IReadOnlyList<GitRef> Branches, IReadOnlyList<GitRef> Tags);

Blog/Program.cs +29 -0

@@ -1,5 +1,6 @@
1 1 using Blog.Components;
2 2 using Blog.Services;
3 +using Microsoft.AspNetCore.Mvc;
3 4
4 5 var builder = WebApplication.CreateBuilder(args);
5 6
@@ -21,6 +22,12 @@ builder.Services.AddHttpClient<BrpService>(
21 22 builder.Services.Configure<WarframeOptions>(builder.Configuration.GetSection(WarframeOptions.Section));
22 23 builder.Services.AddSingleton<WarframeDropService>();
23 24
25 +// /git browses the bare repositories on this server, in place of the cgit that used to. The
26 +// service holds nothing: every read opens the repository it needs and closes it again, so a push
27 +// shows up with nothing to invalidate - see GitService.
28 +builder.Services.Configure<GitOptions>(builder.Configuration.GetSection(GitOptions.Section));
29 +builder.Services.AddSingleton<GitService>();
30 +
24 31 // /rvrb reads the rvrb bot's stats straight off its BEAM, over Erlang distribution. The node this
25 32 // site dials with is started on the first request, not here - see RvrbService.
26 33 builder.Services.Configure<RvrbOptions>(builder.Configuration.GetSection(RvrbOptions.Section));
@@ -46,6 +53,28 @@ app.UseAntiforgery();
46 53 app.MapStaticAssets();
47 54 app.MapRazorComponents<App>();
48 55
56 +// One file out of a repository, exactly as it was committed.
57 +//
58 +// Never inline HTML: these repositories hold .html and .svg files, and serving one from this
59 +// origin would run whatever a commit put in it as a page of mb.bes.is. Text goes out as
60 +// text/plain with nosniff, which a browser will not reinterpret, and everything else is a
61 +// download.
62 +app.MapGet("/git/{repository}/raw/{**path}", (
63 + string repository,
64 + string path,
65 + [FromQuery(Name = "h")] string? reference,
66 + GitService git,
67 + HttpResponse response) =>
68 +{
69 + if (git.GetRawBlob(repository, reference, path) is not { } blob) return Results.NotFound();
70 +
71 + response.Headers.XContentTypeOptions = "nosniff";
72 +
73 + return blob.IsBinary
74 + ? Results.File(blob.Bytes, "application/octet-stream", blob.Name)
75 + : Results.File(blob.Bytes, "text/plain; charset=utf-8");
76 +});
77 +
49 78 // Item names matching what has been typed, for the search box's suggestion list.
50 79 app.MapGet("/api/warframe/names", async (string? q, WarframeDropService drops, CancellationToken cancellationToken) =>
51 80 {

Blog/Services/GitService.cs +581 -0

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

Blog/appsettings.Development.json +3 -0

@@ -4,5 +4,8 @@
4 4 "Default": "Information",
5 5 "Microsoft.AspNetCore": "Warning"
6 6 }
7 + },
8 + "Git": {
9 + "RepositoryRoot": "~/Developer/csharp"
7 10 }
8 11 }

Blog/appsettings.json +4 -0

@@ -9,5 +9,9 @@
9 9 "Rvrb": {
10 10 "Node": "rvrb@127.0.0.1",
11 11 "LocalNode": "blog@127.0.0.1"
12 + },
13 + "Git": {
14 + "RepositoryRoot": "/home/git",
15 + "CloneUrl": "ssh://git@git.bes.is/{repo}"
12 16 }
13 17 }

Blog/wwwroot/app.css +11 -2

@@ -72,8 +72,6 @@ body {
72 72 min-block-size: 100dvh;
73 73 display: flex;
74 74 flex-direction: column;
75 - gap: var(--s0);
76 - padding-block: var(--s0);
77 75 background-color: var(--background);
78 76 color: var(--color);
79 77 }
@@ -164,6 +162,17 @@ a:focus-visible {
164 162
165 163 /* 4. Layout ================================================================ */
166 164
165 +/* The frame a layout wraps its whole page in: header, content and footer in one column, with
166 + `main` stretching so the footer sits at the bottom of a short page. It carries the rhythm that
167 + used to be on <body>, so that /git can lay itself out its own way. */
168 +.page {
169 + flex: 1;
170 + display: flex;
171 + flex-direction: column;
172 + gap: var(--s0);
173 + padding-block: var(--s0);
174 +}
175 +
167 176 .center {
168 177 box-sizing: content-box;
169 178 max-inline-size: var(--measure);

Blog/wwwroot/git.css +580 -0

@@ -0,0 +1,580 @@
1 +/* ==========================================================================
2 + mb.bes.is/git — the repository browser's own stylesheet.
3 +
4 + app.css dresses a page you read: an 80ch measure, type that grows with the
5 + viewport, and a spacing scale built on 1.5 so everything breathes. A
6 + repository browser is the opposite — a wall of fixed-width rows you scan,
7 + where a screenful of commits beats a well-set paragraph.
8 +
9 + GitLayout links this after app.css, on /git pages only, so it can override
10 + the base outright rather than fight it. What it keeps is the monospace, the
11 + light-dark() colours and the bracketed terminal idiom; what it replaces is
12 + every measurement.
13 +
14 + 1. Tokens the dense scale, and the colours only a diff needs
15 + 2. Base what app.css set for prose, undone
16 + 3. Frame page, status bar, footer
17 + 4. Blocks the bordered box every section is
18 + 5. Tables the shape most of these pages are
19 + 6. Code trees, blobs, diffs
20 + 7. Small parts badges, pagers, key/value lists
21 + ========================================================================== */
22 +
23 +
24 +/* 1. Tokens ================================================================ */
25 +
26 +/* app.css grows the root font with the viewport (`1rem + 0.4vw`), which lands
27 + near 21px on a desktop. That is a reading size. Back to the reader's own
28 + base, which the scale below then works down from. */
29 +html {
30 + font-size: 100%;
31 +}
32 +
33 +.git {
34 + /* One step under the base: small enough to fit a commit table on a laptop,
35 + still sized in rem so a reader who runs their browser at 20px gets a
36 + browser at 20px. */
37 + font-size: 0.875rem;
38 + line-height: 1.45;
39 +
40 + /* A flat four-step scale rather than a ratio. Density is a decision about
41 + absolute room — 4px between a label and its value — not a proportion. */
42 + --g0: 0.125rem;
43 + --g1: 0.25rem;
44 + --g2: 0.5rem;
45 + --g3: 1rem;
46 +
47 + /* Hairlines and fills. The page's own --color is too heavy for a rule
48 + between every row of a forty-row table. */
49 + --g-line: light-dark(#d5d5d5, #2b2b2b);
50 + --g-fill: light-dark(#f1f1f1, #131313);
51 + --g-dim: light-dark(#6b6b6b, #909090);
52 +
53 + /* A row the URL points at: #L42, or the file a diff was opened for. */
54 + --g-mark: light-dark(#fff6cc, #2c2611);
55 +
56 + /* Diff colours carry meaning, so they are stated rather than tinted: a
57 + wash behind the line, and a stronger shade for the sign in the gutter. */
58 + --g-add: light-dark(#e4f5e4, #0e2410);
59 + --g-add-mark: light-dark(#186c18, #77d177);
60 + --g-del: light-dark(#fbe9e9, #2a1212);
61 + --g-del-mark: light-dark(#9e1414, #ff8a8a);
62 + --g-hunk: light-dark(#e8eef6, #101720);
63 +}
64 +
65 +
66 +/* 2. Base ================================================================== */
67 +
68 +/* Every one of these exists only to undo a rule app.css set for prose. */
69 +
70 +.git :is(h1, h2, h3, h4, h5, h6) {
71 + margin-block: 0;
72 + font-size: 1em;
73 + line-height: 1.45;
74 + text-wrap: nowrap;
75 +}
76 +
77 +.git p {
78 + margin-block: 0;
79 + text-wrap: pretty;
80 +}
81 +
82 +.git :is(ul, ol) {
83 + margin-block: 0;
84 + padding-inline-start: 0;
85 + list-style: none;
86 +}
87 +
88 +/* app.css sets `pre-line` so indented examples can stay indented in the .razor
89 + source. Here the whitespace is the file's, and it is load-bearing. */
90 +.git pre {
91 + margin-block: 0;
92 + white-space: pre;
93 + overflow-wrap: normal;
94 +}
95 +
96 +/* Underlining every link in a table of hashes and paths turns the page into
97 + noise, so links here are plain until you point at one. */
98 +.git a {
99 + color: inherit;
100 + text-decoration: none;
101 +}
102 +
103 +.git a:hover,
104 +.git a:focus-visible {
105 + background-color: transparent;
106 + color: inherit;
107 + text-decoration: underline;
108 + text-decoration-thickness: 1px;
109 + text-underline-offset: 0.15em;
110 +}
111 +
112 +
113 +/* 3. Frame ================================================================= */
114 +
115 +.git {
116 + flex: 1;
117 + display: flex;
118 + flex-direction: column;
119 + gap: var(--g2);
120 + /* Wide, but not edge to edge: past this a commit table is a row of five
121 + words stranded at either end of a monitor. */
122 + max-inline-size: 100rem;
123 + inline-size: 100%;
124 + margin-inline: auto;
125 + padding: var(--g2);
126 +}
127 +
128 +/* The status line, the way a terminal does it: inverted, full width, one row.
129 + It is also the whole of the site header here — a logo and a tagline are
130 + exactly the vertical room this page is trying not to spend. */
131 +.git-bar {
132 + display: flex;
133 + flex-wrap: wrap;
134 + align-items: center;
135 + justify-content: space-between;
136 + gap: var(--g1) var(--g3);
137 + padding: var(--g1) var(--g2);
138 + background-color: var(--color);
139 + color: var(--background);
140 +}
141 +
142 +.git-bar a:focus-visible {
143 + outline-color: var(--background);
144 +}
145 +
146 +.git-crumbs {
147 + display: flex;
148 + flex-wrap: wrap;
149 + align-items: baseline;
150 + gap: var(--g1);
151 + min-inline-size: 0;
152 + /* A deep path breaks between segments rather than mid-name. */
153 + overflow-wrap: anywhere;
154 +}
155 +
156 +.git-crumbs .sep {
157 + opacity: 60%;
158 +}
159 +
160 +.git-crumbs strong {
161 + font-weight: 700;
162 +}
163 +
164 +/* Tabs read as keys you can press. The current one is punched out of the bar. */
165 +.git-tabs {
166 + display: flex;
167 + gap: var(--g1);
168 + flex: none;
169 +}
170 +
171 +.git-tabs a {
172 + padding-inline: var(--g2);
173 +}
174 +
175 +.git-tabs a::before {
176 + content: "[";
177 + opacity: 50%;
178 +}
179 +
180 +.git-tabs a::after {
181 + content: "]";
182 + opacity: 50%;
183 +}
184 +
185 +.git-tabs a.on {
186 + background-color: var(--background);
187 + color: var(--color);
188 + text-decoration: none;
189 +}
190 +
191 +.git-tabs a.on::before,
192 +.git-tabs a.on::after {
193 + opacity: 100%;
194 +}
195 +
196 +.git-main {
197 + flex: 1;
198 + display: flex;
199 + flex-direction: column;
200 + gap: var(--g2);
201 + /* main's rules in app.css frame a reading column with dotted rules. */
202 + border: none;
203 + padding-block: 0;
204 +}
205 +
206 +.git-foot {
207 + display: flex;
208 + flex-wrap: wrap;
209 + justify-content: space-between;
210 + gap: var(--g1) var(--g3);
211 + padding-block-start: var(--g2);
212 + border-block-start: 1px solid var(--g-line);
213 + color: var(--g-dim);
214 +}
215 +
216 +
217 +/* 4. Blocks ================================================================ */
218 +
219 +/* One bordered box per thing on the page. Square corners and a single hairline
220 + everywhere, so two boxes stacked read as one grid rather than as two cards. */
221 +.block {
222 + border: 1px solid var(--g-line);
223 + min-inline-size: 0;
224 +}
225 +
226 +.block-head {
227 + display: flex;
228 + flex-wrap: wrap;
229 + align-items: baseline;
230 + justify-content: space-between;
231 + gap: var(--g1) var(--g2);
232 + padding: var(--g1) var(--g2);
233 + background-color: var(--g-fill);
234 + border-block-end: 1px solid var(--g-line);
235 + font-weight: 700;
236 +}
237 +
238 +/* The right-hand half of a heading: counts, stats, a branch name. */
239 +.block-note {
240 + font-weight: 400;
241 + color: var(--g-dim);
242 + text-wrap: nowrap;
243 +}
244 +
245 +/* Padding for a block holding prose or a list rather than a table, which
246 + supplies its own cell padding and wants to run to the border. */
247 +.block-body {
248 + padding: var(--g2);
249 +}
250 +
251 +.block-body > * + * {
252 + margin-block-start: var(--g2);
253 +}
254 +
255 +/* Nothing to show is worth a row of its own rather than an empty box. */
256 +.empty {
257 + padding: var(--g2);
258 + color: var(--g-dim);
259 +}
260 +
261 +
262 +/* 5. Tables ================================================================ */
263 +
264 +/* A table wider than the screen scrolls inside its block. On a phone that is
265 + most of them, and it beats every alternative: wrapping a hash, hiding a
266 + column, or pushing the whole page sideways. */
267 +.scroll {
268 + overflow-x: auto;
269 +}
270 +
271 +.git table {
272 + inline-size: 100%;
273 + border-collapse: collapse;
274 + /* Numbers, hashes and sizes are compared down the column. */
275 + font-variant-numeric: tabular-nums;
276 +}
277 +
278 +.git :is(th, td) {
279 + padding: var(--g1) var(--g2);
280 + border-block-end: 1px solid var(--g-line);
281 + text-align: start;
282 + vertical-align: baseline;
283 + text-wrap: nowrap;
284 +}
285 +
286 +.git th {
287 + font-weight: 400;
288 + color: var(--g-dim);
289 +}
290 +
291 +.git tbody tr:last-child :is(th, td) {
292 + border-block-end: none;
293 +}
294 +
295 +.git tbody tr:hover {
296 + background-color: var(--g-fill);
297 +}
298 +
299 +/* The one column per table that carries the sentence: a commit subject, a
300 + description, a tag message. It takes the leftover width and gives up its
301 + own before any of the fixed columns do. */
302 +.git .wide {
303 + inline-size: 100%;
304 + text-wrap: wrap;
305 + overflow-wrap: anywhere;
306 +}
307 +
308 +/* Hashes, sizes and counts: right where they are compared, left where they
309 + are read. */
310 +.git .num {
311 + text-align: end;
312 + inline-size: 1%;
313 +}
314 +
315 +.git .dim {
316 + color: var(--g-dim);
317 +}
318 +
319 +/* A column that earns its place on a laptop and not on a phone. Dropping it is what lets the
320 + remaining columns fit, so the subject wraps in place instead of the whole row scrolling
321 + sideways to reach a name you were not reading anyway. */
322 +@media (width < 40rem) {
323 + .git .when-wide {
324 + display: none;
325 + }
326 +}
327 +
328 +.git .sha {
329 + font-variant-numeric: tabular-nums;
330 +}
331 +
332 +/* A row whose link fills the cell should feel clickable across the cell. */
333 +.git td > a {
334 + display: inline-block;
335 + max-inline-size: 100%;
336 +}
337 +
338 +
339 +/* 6. Code ================================================================== */
340 +
341 +/* -- Trees --------------------------------------------------------------- */
342 +
343 +/* The mode column is decoration until you need it, and then it is the whole
344 + answer, so it stays but stays quiet. */
345 +.tree .mode {
346 + color: var(--g-dim);
347 +}
348 +
349 +/* The link sits in the name cell, not straight under the row. */
350 +.tree tr.dir .wide a::after {
351 + content: "/";
352 + color: var(--g-dim);
353 +}
354 +
355 +/* -- Blobs and diffs ------------------------------------------------------ */
356 +
357 +/* Line numbers sit in their own cells and are marked unselectable, so copying
358 + a file or a hunk out of the page yields the code and nothing else. */
359 +.code-table {
360 + inline-size: max-content;
361 + min-inline-size: 100%;
362 +}
363 +
364 +.code-table td {
365 + padding-block: 0;
366 + border-block-end: none;
367 + vertical-align: top;
368 +}
369 +
370 +.code-table .ln {
371 + inline-size: 1%;
372 + padding-inline: var(--g2);
373 + text-align: end;
374 + color: var(--g-dim);
375 + background-color: var(--g-fill);
376 + user-select: none;
377 + -webkit-user-select: none;
378 +}
379 +
380 +.code-table .ln a {
381 + color: inherit;
382 +}
383 +
384 +.code-table .code {
385 + inline-size: 100%;
386 + padding-inline: var(--g2) var(--g3);
387 + white-space: pre;
388 +}
389 +
390 +/* Hovering a row of code should not repaint the whole line. */
391 +.code-table tbody tr:hover {
392 + background-color: transparent;
393 +}
394 +
395 +.code-table tr:target,
396 +.code-table tr:target .ln {
397 + background-color: var(--g-mark);
398 +}
399 +
400 +/* -- Diffs ---------------------------------------------------------------- */
401 +
402 +.diff .hunk td {
403 + padding-inline: var(--g2);
404 + background-color: var(--g-hunk);
405 + color: var(--g-dim);
406 + border-block: 1px solid var(--g-line);
407 +}
408 +
409 +/* The first hunk sits straight under the file's heading, which already has a
410 + rule of its own. */
411 +.diff tr:first-child.hunk td {
412 + border-block-start: none;
413 +}
414 +
415 +.diff .add .code {
416 + background-color: var(--g-add);
417 +}
418 +
419 +.diff .del .code {
420 + background-color: var(--g-del);
421 +}
422 +
423 +/* The sign is repeated from the gutter into the line so the diff survives
424 + being copied, pasted or read without colour — but never selected with it. */
425 +.diff .sign {
426 + user-select: none;
427 + -webkit-user-select: none;
428 +}
429 +
430 +.diff .add .sign {
431 + color: var(--g-add-mark);
432 +}
433 +
434 +.diff .del .sign {
435 + color: var(--g-del-mark);
436 +}
437 +
438 +.diff .note td {
439 + color: var(--g-dim);
440 +}
441 +
442 +/* One file's heading inside the commit's diff: path on the left, its own
443 + stat on the right. */
444 +.diff-file + .diff-file {
445 + margin-block-start: var(--g2);
446 +}
447 +
448 +.diff-file .block-head {
449 + overflow-wrap: anywhere;
450 + text-wrap: wrap;
451 +}
452 +
453 +
454 +/* 7. Small parts =========================================================== */
455 +
456 +/* A branch or tag name sitting next to a commit. Bracketed rather than
457 + pill-shaped: this page has no rounded corners anywhere else. */
458 +.ref {
459 + margin-inline-start: var(--g2);
460 + color: var(--g-dim);
461 + /* Breakable: a branch called release/2026-09-the-long-one is one unbreakable word, and on a
462 + phone it would otherwise set the width of the whole commit table. */
463 + overflow-wrap: anywhere;
464 +}
465 +
466 +.ref::before {
467 + content: "[";
468 +}
469 +
470 +.ref::after {
471 + content: "]";
472 +}
473 +
474 +.ref.head {
475 + color: inherit;
476 + font-weight: 700;
477 +}
478 +
479 +/* +42 -3, on a commit row or a file heading. */
480 +.stat {
481 + text-wrap: nowrap;
482 +}
483 +
484 +.stat .plus {
485 + color: var(--g-add-mark);
486 +}
487 +
488 +.stat .minus {
489 + color: var(--g-del-mark);
490 +}
491 +
492 +/* Key and value down the page: clone URL, author, committer, parents. */
493 +.pairs {
494 + display: grid;
495 + grid-template-columns: max-content minmax(0, 1fr);
496 + gap: var(--g1) var(--g3);
497 +}
498 +
499 +.pairs dt {
500 + color: var(--g-dim);
501 +}
502 +
503 +.pairs dd {
504 + margin: 0;
505 + overflow-wrap: anywhere;
506 +}
507 +
508 +/* The clone line is there to be selected in one go. */
509 +.pairs .copyable {
510 + user-select: all;
511 + -webkit-user-select: all;
512 +}
513 +
514 +/* Older / newer, at the foot of the log. */
515 +.pager {
516 + display: flex;
517 + gap: var(--g2);
518 + justify-content: space-between;
519 + padding: var(--g1) var(--g2);
520 + border-block-start: 1px solid var(--g-line);
521 +}
522 +
523 +.pager a::before,
524 +.pager span::before {
525 + content: "< ";
526 +}
527 +
528 +.pager :last-child::before {
529 + content: "";
530 +}
531 +
532 +.pager :last-child::after {
533 + content: " >";
534 +}
535 +
536 +.pager span {
537 + color: var(--g-dim);
538 +}
539 +
540 +/* A commit's message body, under its subject. Pre-wrapped: a commit message is
541 + written to a width and often has a list in it. */
542 +.message {
543 + white-space: pre-wrap;
544 + overflow-wrap: anywhere;
545 + color: var(--g-dim);
546 +}
547 +
548 +.subject {
549 + font-weight: 700;
550 + text-wrap: wrap;
551 + overflow-wrap: anywhere;
552 +}
553 +
554 +/* A branch picker and a path search are one short row of controls, not a form
555 + with labels stacked above fields. */
556 +.controls {
557 + display: flex;
558 + flex-wrap: wrap;
559 + align-items: center;
560 + gap: var(--g2);
561 +}
562 +
563 +.controls label {
564 + display: flex;
565 + align-items: center;
566 + gap: var(--g1);
567 + color: var(--g-dim);
568 +}
569 +
570 +.git :is(button, input, select) {
571 + font: inherit;
572 + border-radius: 0;
573 + border-color: var(--g-line);
574 + padding: var(--g0) var(--g1);
575 +}
576 +
577 +.git button {
578 + min-inline-size: 0;
579 + padding-inline: var(--g2);
580 +}

CLAUDE.md +55 -4

@@ -7,7 +7,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
7 7 `bes.is` — a Blazor Server app (single ASP.NET Core project, no test project) hosting a
8 8 collection of small, mostly self-contained JS/HTML experiments/tools (RPN calculator,
9 9 QR code scan/generate, a Netflix→Letterboxd converter, a JSON query playground, BRP
10 -lookup, etc.), each on its own route.
10 +lookup, etc.), each on its own route, plus a browser for the git repositories on the
11 +same server at `/git`.
11 12
12 13 ## Commands
13 14
@@ -32,7 +33,8 @@ runs `scripts/update-droptables.sh`, then "restore linux" restores for the targe
32 33 **One project, one layout, many independent page-features.** `Blog/Components/Pages/*.razor`
33 34 are the routes (`@page "/Xyz"`), each linked from `SiteFooter.razor`. Most pages are a
34 35 `.razor` file paired with a `.razor.js` file of the same name — this is the pattern to
35 -follow for any new interactive page.
36 +follow for any new interactive page. The one exception is `Pages/Git/`, a folder of pages
37 +that share a layout and a stylesheet of their own — see below.
36 38
37 39 ### The `.razor` + `.razor.js` pairing
38 40
@@ -148,6 +150,51 @@ results are linkable and work without JS. `Warframe.razor.js` only fills the sea
148 150 `<datalist>` from `/api/warframe/names` (mapped in `Program.cs`). It does *not* import
149 151 `/common.module.js`: that module binds to a `#log` element this page doesn't have.
150 152
153 +### Git browser (`/git`)
154 +
155 +`Components/Pages/Git/*.razor` replace the cgit that used to run at git.bes.is: an index, a
156 +repository summary, log, commit with diff, tree/blob and refs. They read through
157 +`Services/GitService.cs`, which uses **LibGit2Sharp** — the only feature here with a native
158 +dependency, and the reason the self-contained `linux-x64` publish has to carry
159 +`LibGit2Sharp.NativeBinaries`' `linux-x64` asset.
160 +
161 +Every method on the service opens a `Repository`, copies what it needs into the plain records in
162 +`Models/GitRepositories.cs`, and closes it again. libgit2's objects are handles into an open
163 +repository and `Repository` is not thread-safe, so nothing is held between requests and nothing is
164 +cached: a push is visible immediately, with nothing to invalidate. That is the opposite of
165 +`WarframeDropService` on purpose — its input is one file that only changes on deploy.
166 +
167 +Repository names come out of the URL, so `GitService.Resolve(name)` is the only thing that turns
168 +one into a path: it has to match `[A-Za-z0-9_][A-Za-z0-9._+-]*` and resolve to a directory whose
169 +parent is the root exactly. Paths *inside* a repository never reach the filesystem at all — they
170 +are tree lookups.
171 +
172 +`/git/{repo}/raw/{**path}` (mapped in `Program.cs`) serves a file as committed, and must never
173 +send `text/html`: these repositories hold `.html` and `.svg` files, and serving one inline from
174 +this origin would run whatever a commit put in it as a page of mb.bes.is. Text goes out as
175 +`text/plain` with `nosniff`; everything else is an attachment.
176 +
177 +Diffs are parsed back out of libgit2's patch text (`GitService.ParsePatch`) rather than printed
178 +raw, because the line numbers exist only in the `@@` headers. `MaxDiffLines` and
179 +`MaxDiffCharacters` both matter and run out independently: the commit that vendored
180 +`droptables.html` is 22k added lines of long machine-generated HTML, and it exhausts the character
181 +budget long before the line one.
182 +
183 +Configuration lives under `Git` (`GitOptions`): `RepositoryRoot` (`/home/git` in production,
184 +`~/Developer/csharp` in development — a leading `~/` is expanded), `CloneUrl`, and the display
185 +ceilings above.
186 +
187 +The pages are server-rendered and driven by the route and query string (`?h=` revision, `?path=`
188 +and `?ofs=` on the log), so everything is linkable and there is **no JavaScript at all** — no
189 +`.razor.js` beside any of them.
190 +
191 +**Its own layout and stylesheet.** `Layout/GitLayout.razor` replaces `MainLayout` for everything in
192 +`Components/Pages/Git/` (through that folder's `_Imports.razor`) and links `wwwroot/git.css` via
193 +`<HeadContent>`, so no other page loads it. git.css *overrides* app.css down to the base rather
194 +than extending it — root font size, spacing scale, table padding, link decoration — because
195 +app.css is set for reading an 80ch column and this is a wall of rows you scan. New rules for these
196 +pages belong there, not in app.css.
197 +
151 198 ### Styling
152 199
153 200 `wwwroot/app.css` is one hand-maintained stylesheet (no CSS framework, no build step),
@@ -155,5 +202,9 @@ organized into numbered sections (Tokens → Reset/base → Typography → Layou
155 202 Components → Media) with CSS custom properties as the single source of design tokens
156 203 (colors, spacing scale, fonts). It uses `light-dark()` and `color-scheme` for automatic
157 204 dark mode — don't hardcode light/dark colors, extend the token set in section 1 instead.
158 -There are no scoped `.razor.css` stylesheets, so `App.razor` links `app.css` only; adding one
159 -means adding the `Blog.styles.css` bundle link back.
205 +There are no scoped `.razor.css` stylesheets, so `App.razor` links `app.css` only — the `/git`
206 +pages pull in `wwwroot/git.css` from their own layout instead; adding a scoped stylesheet means
207 +adding the `Blog.styles.css` bundle link back.
208 +
209 +`<body>` carries no layout of its own: each layout wraps its page in a `div.page`, and
210 +`MainLayout` adds `.center` to that for the 80ch measure. `/git` deliberately does not.