1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
|
.Dd August 1, 2026
.Dt SOURCE-FORGE 1
.Os
.Sh NAME
.Nm source-forge
.Nd on-demand source browser backed by SQLite
.Sh SYNOPSIS
.Nm
.Cm project add
.Fl -db Ar path
.Fl -repos Ar dir
.Fl -project Ar name
.Op Fl -branch Ar name
.Op Fl -base-url Ar url
.Nm
.Cm project set-branch
.Fl -db Ar path
.Op Fl -repos Ar dir
.Fl -project Ar name
.Fl -branch Ar name
.Nm
.Cm project list
.Fl -db Ar path
.Nm
.Cm ingest
.Fl -db Ar path
.Fl -project Ar name
.Op Fl -branch Ar name
.Op Fl -git-dir Ar path
.Aq Pa archive.tar
.Nm
.Cm serve
.Fl -db Ar path
.Op Fl -addr Ar host:port
.Op Fl -base-url Ar url
.Nm
.Cm git-post-receive-hook
.Fl -db Ar path
.Op Fl -git-dir Ar path
.Op Fl -base-url Ar url
.Sh DESCRIPTION
.Nm
publishes the source tree of a project as a browseable, syntax-highlighted
static site, rendered on demand from SQLite.
It is split into subcommands: one that
.Cm project Ns s
are declared in (and their bare repos created), one that
.Cm ingest Ns s
a snapshot of a tree into the database, one that
.Cm serve Ns s
that tree over HTTP, and a
.Cm git-post-receive-hook
that re-ingests on every push.
.Pp
The design goal is scraper resilience.
Only rendered static content is ever exposed \(en there is no git history
to walk and no per-request computation after the first render of a file \(en
so crawlers hitting every link cost almost nothing.
Each project is stored as a series of
.Em generations :
ingesting a new tree writes every row under a fresh generation and then
atomically flips the project to it, so a reader never observes a
half-written tree, and superseded generations are pruned.
A project that has been declared but never pushed to sits at generation 0
and is shown on the site as pending.
.Ss Project
.Nm
.Cm project
declares, redirects, or lists published projects.
A project must be declared with
.Cm project add
before anything can be pushed to it; see
.Sx Git post-receive hook .
The sub-subcommands are as follows:
.Bl -tag -width Ds
.It Cm add
Creates a bare git repository at
.Pa <repos>/<project>.git ,
points its
.Ar HEAD
at
.Fl -branch ,
and inserts the project's row into the database at generation 0 (pending).
Fails if the repository or the database row already exists, so the two can
never drift out of sync.
.Bl -tag -width Ds
.It Fl -db Ar path
Path to the SQLite database.
Created if it does not exist.
Required.
.It Fl -repos Ar dir
State directory the bare repository is created under.
Required.
.It Fl -project Ar name
Project name.
Must start with a letter or digit and contain only letters, digits,
.Sq \&. ,
.Sq _ ,
or
.Sq - ,
since the post-receive hook derives the project name back out of the
repository's directory name.
Required.
.It Fl -branch Ar name
Branch that will be published on push.
Defaults to
.Ar canon .
.It Fl -base-url Ar url
Public base URL of the site, e.g.
.Ar https://sources.example.org .
Optional, and used only to print the URL the project will appear at once it
is pushed to; omitted from the output when not given.
.El
.It Cm set-branch
Changes which branch a declared project publishes.
Updates the database row; if
.Fl -repos
is also given, re-points the repository's
.Ar HEAD
too.
.Bl -tag -width Ds
.It Fl -db Ar path
Path to the SQLite database.
Required.
.It Fl -repos Ar dir
State directory the bare repository lives under.
Optional; when omitted only the database row is updated (the next push still
re-points
.Ar HEAD ,
see
.Sx Git post-receive hook ) .
.It Fl -project Ar name
Project name.
Required.
.It Fl -branch Ar name
New branch to publish.
Required.
.El
.It Cm list
Prints every declared project, its published branch, whether it has been
ingested yet, and whether a description is set.
Takes only
.Fl -db Ar path .
.El
.Ss Ingest
.Nm
.Cm ingest
reads a
.Xr git-archive 1
tar stream on standard input and stores the tree in SQLite under a new
generation, then makes it the live tree.
The arguments are as follows:
.Bl -tag -width Ds
.It Fl -db Ar path
Path to the SQLite database.
It is created if it does not exist.
Required.
.It Fl -project Ar name
Project name the tree is stored under.
A single database can hold many projects, keyed by this name.
Required.
.It Fl -branch Ar name
Branch that was archived, recorded on the project row and shown in the
listing.
.It Fl -git-dir Ar path
Path to the source git repository.
When given, two extra artifacts of
.Fl -branch
are built and stored alongside the tree: a clonable git bundle
.Pq Xr git-bundle 1 ,
so visitors can clone the full history offline, and a gzipped tarball
.Pq Xr git-archive 1
usable directly as a Nix flake (see
.Sx URLS ) .
Requires
.Fl -branch ,
which selects what to bundle and archive.
.El
.Ss Serve
.Nm
.Cm serve
serves directory listings and highlighted files over HTTP, rendering each
file lazily and caching the resulting HTML back into the database.
The arguments are as follows:
.Bl -tag -width Ds
.It Fl -db Ar path
Path to the SQLite database to serve.
Required.
.It Fl -addr Ar host:port
Address to listen on.
Defaults to
.Ar 127.0.0.1:8790 .
.It Fl -base-url Ar url
Public base URL used for absolute links, most importantly the git bundle
clone hint and the Nix flake tarball hint.
When omitted, a URL is reconstructed per request from the scheme and
.Ar Host
header, so this is only needed when that guess would be wrong (for example
behind a reverse proxy that rewrites the host).
.El
.Ss Git post-receive hook
.Nm
.Cm git-post-receive-hook
is meant to be installed
.Em once, generically ,
via
.Xr git-config 1 Ns 's
.Ar core.hooksPath ,
rather than symlinked into each repository's
.Pa hooks/post-receive
individually \(en see
.Sx EXAMPLES .
That way it applies to every bare repository under the state directory,
present or future, with no per-project setup beyond
.Cm project add .
.Pp
Because it is installed generically, neither the project name nor the branch
is passed on the command line: the project name is taken from the
repository's own directory name
.Pq Pa <project>.git ,
and the published branch is read from that project's row in the database,
which must already exist (see
.Cm project add
above).
A push to a repository with no such row is rejected with an error pointing at
.Cm project add ;
this is what prevents an arbitrary push from silently publishing an
undeclared repository, or an unintended branch.
.Pp
The hook reads the pushed ref updates from standard input (one
.Dq Ar old new ref
line per updated ref, as git provides) and, for every update to the declared
branch, re-points the repository's
.Ar HEAD
at it and re-ingests it into the database using the same code path as
.Cm ingest .
Pushes to any other ref (including other branches of a declared project) are
ignored, so the served site tracks the declared branch automatically on
every push.
.Pp
After a successful ingest the hook writes the project's published URL to
standard error, which git relays back to whoever pushed (prefixed, as git
prefixes all hook output, with
.Ql remote ) ,
so a push ends with a link to the page it just updated:
.Bd -literal -offset indent
remote: source-forge: published at https://sources.example.org/Profpatsch/
.Ed
.Pp
This requires
.Fl -base-url ;
without it the line is simply omitted, rather than a guessed URL printed.
The arguments are as follows:
.Bl -tag -width Ds
.It Fl -db Ar path
Path to the SQLite database.
Required.
.It Fl -git-dir Ar path
Path to the bare git repository.
Defaults to
.Ev GIT_DIR ,
which git sets when it invokes a hook, so it usually need not be given
explicitly.
.It Fl -base-url Ar url
Public base URL of the site, as passed to
.Cm serve .
Optional, and used only for the published-URL line described above.
.El
.Sh URLS
The
.Cm serve
subcommand exposes the following paths:
.Bl -tag -width Ds
.It Pa /
The project index, listing every published project.
.It Pa /<project>/
A directory listing.
A trailing slash means directory.
At the project root, the description declared in the root
.Pa .source-forge
(if any) is shown between the page header and the clone/tarball
instructions.
Single-child directory chains are collapsed GitHub-style, extra entries
declared by a
.Pa .source-forge
file are spliced in (see
.Sx FILES ) ,
and the directory's prose is rendered below the listing: its
.Pa README.md
first, if it has one, then every manpage it holds, each under a
.Dq name(section)
heading linking to that page's own source file.
A README and a manpage are shown together rather than one standing in for
the other, since they answer different questions \(en what this directory
is, and how the program in it is used.
Manpages are ordered with the directory's own page first (one whose name
matches the directory, or the project at the root), then by section
number, then by path; at most eight are rendered, so a directory holding
a whole tree of pages cannot turn one request into an unbounded number of
.Xr mandoc 1
runs.
Any beyond that are still listed, and still readable as ordinary files.
.It Pa /<project>/<path>
A single file: raw bytes for binaries (streamed straight from the
database), or cached syntax-highlighted HTML for text.
.It Pa /<project>/<dir>/?full=
The recursive
.Dq full contents
view, rendering every file under a directory on one page.
Offered only for subtrees within a fixed size limit, so a crawler
appending
.Ar ?full=
to every directory cannot trigger huge renders.
.It Pa /<project>.bundle
The clonable git bundle for a project, if one was stored at ingest time.
.It Pa /<project>.tar.gz
The project tree as a gzipped tarball, if one was stored at ingest time.
The archive wraps the whole tree in a single top-level
.Pa <project>/
directory, which makes it directly usable as a Nix flake, for example
.Ic nix run https://host/<project>.tar.gz#<package> .
It carries an
.Ar ETag
of the live generation and answers a matching
.Ar If-None-Match
with
.Ar 304 Not Modified ,
so Nix, which re-validates flake inputs on every evaluation, transfers the
body only when the tree actually changed.
.El
.Sh IMPLEMENTATION NOTES
Syntax highlighting is done with the
.Em chroma
library, using CSS classes rather than inline styles so a single
stylesheet can support both light and dark themes via
.Ar prefers-color-scheme .
Note that chroma emits a rule only for those tokens a theme actually
overrides, so the two themes do not yield the same set of rules; both
theme blocks are therefore wrapped in mutually exclusive media queries.
Emitting one of them unscoped, in the expectation that the other shadows
it, leaves every token the other theme is silent about at the wrong
theme's colour.
Markdown is rendered with
.Em goldmark
(GitHub-flavored, with highlighted fenced code blocks).
The manpages rendered below a directory listing are formatted by shelling
out to
.Xr mandoc 1 ,
so it must be available on
.Ev PATH
for that feature;
its Markdown output is then fed through the same
.Em goldmark
pipeline, so a rendered manpage looks like a rendered README.
The heading above each one is built from the filename rather than taken
from mandoc's own title line, which is stripped along with the date
footer: the filename is always present and always right, while that line
carries a volume field that means nothing outside a manual.
Headings in either carry a slugified
.Ar id
and link to themselves, so a section can be linked to directly, as in
.Pa /<project>/#implementation-notes .
Ids are unique only within one rendered document, so a page showing a
README and a manpage together may repeat one \(en both are likely to have
a
.Dq NAME
heading.
A browser jumps to the first, which is the price of keeping anchors short
enough to be worth linking to.
.Pp
Bundle downloads, tarball downloads and raw binary files are streamed
incrementally from the database using SQLite's blob API, so large payloads
are never buffered wholly in memory.
.Sh FILES
.Bl -tag -width Ds
.It Pa .source-forge
An optional, checked-in metadata file describing how the directory
containing it is displayed.
It is read during
.Cm ingest
\(en it arrives in the tar stream like any other file \(en and its effects
are baked into the stored listing, so nothing is parsed at request time.
The file itself remains browseable like any other file in the tree.
.Pp
The format is one
.Ar key = value
per line;
blank lines and lines beginning with
.Ql #
are ignored, and only the
.Em first
.Ql =
separates the key from the value, so a value may contain further ones.
A key that takes a list is simply repeated \(en there is no
comma-separated form, so no value need worry about containing a comma:
.Bd -literal -offset indent
# ./.source-forge
description = Profpatsch's personal monorepo.
shortcut = users/Profpatsch
shortcut = users/Profpatsch/git-blimey
.Ed
.Pp
The recognised keys are:
.Bl -tag -width Ds
.It Cm shortcut Ar path
Add an extra entry to this directory's listing, pointing at the descendant
.Ar path
(given relative to the directory holding the file).
The entry is placed directly after the listing entry it lives under, so a
shortcut to
.Pa users/Profpatsch
appears just below
.Pa users/ ,
not at the end of the page.
May be repeated; the entries keep their declared order.
.Pp
Shortcuts are purely
.Em additive :
they can only add links, never remove or replace one, so no declaration can
make part of the tree unreachable.
This is what distinguishes them from the automatic collapsing of
single-child directory chains, which is lossless by construction: a
directory with two or more children is always listed as itself, and a
shortcut is how an interesting subtree deeper down is surfaced anyway.
The target must exist and be a strict descendant of the declaring
directory;
.Ql \&. ,
.Ql \&.. ,
and absolute paths are rejected.
.It Cm description Ar html
A short phrase describing the directory the file sits in.
It is shown on that directory's entry in
.Em every
listing the directory appears in \(en including one reached by a
.Cm shortcut
from further up \(en following the size and file count, in the same muted
style, and again above the directory's own listing when it is browsed.
Allowed once per file.
.Pp
The value is inline
.Em HTML ,
not markdown, because of where it lands: on the entry's own line, inside the
listing's
.Ql <span> ,
where only phrasing content is valid.
A markdown renderer's natural output is a
.Ql <p>
block, which is not.
So it may carry
.Ql <code> ,
.Ql <em> ,
a link and so on, but not headings, lists or paragraphs.
It is stored verbatim and emitted unescaped, with no sanitisation \(en on
the understanding that whoever can push to the published branch already
controls every byte the site serves, a far more powerful capability than
injecting HTML into one page of it.
.Pp
Keep it to a phrase.
It shares a line with the entry's name and stats, so a sentence that runs
long simply wraps and pushes the row apart:
.Bd -literal -offset indent
description = An interactive <code>git blame</code> TUI
.Ed
.Pp
Because it describes the directory rather than any particular listing, it
lives with the thing it describes: a project's blurb belongs in that
project's own
.Pa .source-forge ,
where it stays correct however the directory is reached, and a parent
listing becomes an annotated index of what is beneath it.
.Pp
In the repository root, which has no parent listing to appear in, it is the
whole project's blurb instead, wrapped in a paragraph and shown on the
project's page and on the site index.
.Pp
This is the only way to set that blurb, and it is applied on every push, so
deleting the line and pushing removes it.
A
.Cm project set-description
subcommand once set it by hand on the server; it has been removed, because
a description that lives only in the database is invisible from the
repository, impossible to review alongside the change that motivates it, and
raced with this file on every push.
.El
.Pp
Note that adding this file to a directory that holds exactly one other entry
stops that directory from collapsing into its child, since collapsing
applies only while there is a single entry to collapse into.
That is usually what is wanted \(en a directory worth describing is a
directory worth showing \(en but it does mean the file changes the listing
merely by existing.
.Pp
Nothing in this file is ever fatal.
An unparseable line, an unknown key, or an unresolvable shortcut target
produces a warning on standard error and
.Em that line alone
is ignored;
the ingest proceeds.
This is deliberate: the post-receive hook runs
.Em after
git has already moved the ref, so failing an ingest would leave the
repository updated but the published site stuck on the previous generation
\(en a considerably worse outcome than one ignored directive.
Since git relays hook output back over the wire, whoever pushed still sees
the warning:
.Bd -literal -offset indent
remote: source-forge: users/.source-forge:3: ignoring unknown key "collapes"
.Ed
.El
.Sh EXIT STATUS
.Ex -std
.Sh EXAMPLES
Declare a new project, creating its bare repository:
.Bd -literal -offset indent
$ source-forge project add --db forge.db --repos /var/lib/source-forge \e
--project Profpatsch --branch canon
.Ed
.Pp
Install the post-receive hook once, generically, so it applies to every
project's repository under the state directory:
.Bd -literal -offset indent
$ git config --global core.hooksPath /var/lib/source-forge/hooks
$ mkdir -p /var/lib/source-forge/hooks
$ cat >/var/lib/source-forge/hooks/post-receive <<'EOF'
#!/bin/sh
exec source-forge git-post-receive-hook --db forge.db \e
--base-url https://sources.example.org
EOF
$ chmod +x /var/lib/source-forge/hooks/post-receive
.Ed
.Pp
(In the NixOS module this
.Ar core.hooksPath
config and the hook script are both managed declaratively; see
nixos-module.nix.)
.Pp
Pushing to the declared repository now ingests it automatically:
.Bd -literal -offset indent
$ git push ssh://forge@host/var/lib/source-forge/Profpatsch.git canon
.Ed
.Pp
Give the project a short description, shown on its root page, by committing
it to the tree and pushing again:
.Bd -literal -offset indent
$ echo "description = Profpatsch's personal monorepo." >>.source-forge
$ git add .source-forge && git commit -m 'describe the project'
$ git push origin canon
.Ed
.Pp
Serve the database locally:
.Bd -literal -offset indent
$ source-forge serve --db forge.db --addr 127.0.0.1:8790 \e
--base-url http://localhost:8790
.Ed
.Pp
Ingest a one-off snapshot by hand instead (bypassing the hook and the
declared-branch check; useful for testing):
.Bd -literal -offset indent
$ git archive canon | source-forge ingest --db forge.db \e
--project Profpatsch --branch canon --git-dir .git
.Ed
.Sh SEE ALSO
.Xr git-archive 1 ,
.Xr git-bundle 1 ,
.Xr mandoc 1
.Sh AUTHORS
.An Profpatsch
|