commit 9b28e0a9c18e8d9817376f48a8772269e75b2c98
parent f265eea34d1984d43bd51b2fa50e7222fb7a35e8
Author: Chris Bracken <chris@bracken.jp>
Date: Sat, 21 Feb 2026 01:07:27 +0900
writer: implement a gemini writer
Diffstat:
40 files changed, 1868 insertions(+), 11 deletions(-)
diff --git a/BUILD.gn b/BUILD.gn
@@ -81,6 +81,14 @@ executable("gout_tests") {
"src/utils_tests.c",
"src/writer/atom/atom_tests.c",
"src/writer/cache/cache_tests.c",
+ "src/writer/gemini/log_tests.c",
+ "src/writer/gemini/refs_tests.c",
+ "src/writer/gemini/files_tests.c",
+ "src/writer/gemini/fileblob_tests.c",
+ "src/writer/gemini/commit_tests.c",
+ "src/writer/gemini/repo_index_tests.c",
+ "src/writer/gemini/repo_writer_tests.c",
+ "src/writer/gemini/page_tests.c",
"src/writer/gopher/log_tests.c",
"src/writer/gopher/refs_tests.c",
"src/writer/gopher/files_tests.c",
diff --git a/README.md b/README.md
@@ -3,9 +3,7 @@ gout
`gout` is a static git repository builder designed to be output-compatible with
the excellent `stagit` tool, but adds support for multiple output formats in the
-same binary: html (-H) and gopher (-G).
-
-Early support for Gemini is available on the `gemini` branch.
+same binary: html (-H), gopher (-G), and gemini (-M).
Prerequisites
-------------
@@ -69,9 +67,10 @@ gopher support into the same tool, and eventually add gemini support. Things got
a bit out of hand and I'm embarrassed to say the code now reads a bit more like
Enterprise Stagit.
-In my defence, the final compiled binary size is still small; under 46 kB on
-FreeBSD. It would be worthwhile trying to simplify the code a bit, now that
-things are separated out and the formatters are now effectively pluggable.
+In my defence, the final compiled binary is still small; under 56 kB on FreeBSD,
+yet it includes support for HTML, gopher, and gemini output. It would be
+worthwhile trying to simplify the code a bit, now that things are separated out
+and the formatters are now effectively pluggable.
Patches welcome
diff --git a/src/git/git_tests.c b/src/git/git_tests.c
@@ -1,5 +1,5 @@
-#include <sys/stat.h>
#include <stdlib.h>
+#include <sys/stat.h>
#include "git/internal.h"
#include "utest.h"
diff --git a/src/gout.c b/src/gout.c
@@ -32,7 +32,7 @@ GoutOptions* gout_options_create(int argc, const char* argv[]) {
int ch;
optind = 1;
opterr = 0;
- while ((ch = getopt(argc, (char* const*)argv, "c:l:u:HG")) != -1) {
+ while ((ch = getopt(argc, (char* const*)argv, "c:l:u:HGM")) != -1) {
switch (ch) {
case 'c':
// Mutually exclusive with -l.
@@ -62,6 +62,9 @@ GoutOptions* gout_options_create(int argc, const char* argv[]) {
case 'G':
options.writer_type = kRepoWriterTypeGopher;
break;
+ case 'M':
+ options.writer_type = kRepoWriterTypeGemini;
+ break;
default:
return NULL;
}
diff --git a/src/gout.h b/src/gout.h
@@ -6,6 +6,7 @@
typedef enum {
kRepoWriterTypeHtml,
kRepoWriterTypeGopher,
+ kRepoWriterTypeGemini,
} RepoWriterType;
typedef struct GoutOptions {
diff --git a/src/gout_index.c b/src/gout_index.c
@@ -26,7 +26,7 @@ GoutIndexOptions* gout_index_options_create(int argc, const char* argv[]) {
int ch;
optind = 1;
opterr = 0;
- while ((ch = getopt(argc, (char* const*)argv, "m:HG")) != -1) {
+ while ((ch = getopt(argc, (char* const*)argv, "m:HGM")) != -1) {
switch (ch) {
case 'm':
options.me_url = optarg;
@@ -37,6 +37,9 @@ GoutIndexOptions* gout_index_options_create(int argc, const char* argv[]) {
case 'G':
options.writer_type = kIndexWriterTypeGopher;
break;
+ case 'M':
+ options.writer_type = kIndexWriterTypeGemini;
+ break;
default:
return NULL;
}
diff --git a/src/gout_index.h b/src/gout_index.h
@@ -6,6 +6,7 @@
typedef enum {
kIndexWriterTypeHtml,
kIndexWriterTypeGopher,
+ kIndexWriterTypeGemini,
} IndexWriterType;
typedef struct GoutIndexOptions {
diff --git a/src/gout_index_main.c b/src/gout_index_main.c
@@ -7,7 +7,8 @@
#include "fs_posix.h"
static void gout_index_usage(const char* program_name) {
- fprintf(stderr, "usage: %s [repodir...]\n", program_name);
+ fprintf(stderr, "usage: %s [-H | -G | -M] [-m me_url] [repodir...]\n",
+ program_name);
}
int main(int argc, const char* argv[]) {
diff --git a/src/gout_main.c b/src/gout_main.c
@@ -8,7 +8,8 @@
static void gout_usage(const char* program_name) {
fprintf(stderr,
- "usage: %s [-c cachefile | -l commits] [-u baseurl] repodir\n",
+ "usage: %s [-H | -G | -M] [-c cachefile | -l commits] [-u baseurl] "
+ "repodir\n",
program_name);
}
diff --git a/src/writer/BUILD.gn b/src/writer/BUILD.gn
@@ -5,6 +5,7 @@ source_set("index_writer") {
]
configs += [ "//:gout_config" ]
deps = [
+ "//src/writer/gemini:index_writer",
"//src/writer/gopher:index_writer",
"//src/writer/html:index_writer",
]
@@ -18,6 +19,7 @@ source_set("repo_writer") {
]
configs += [ "//:gout_config" ]
deps = [
+ "//src/writer/gemini:repo_writer",
"//src/writer/gopher:repo_writer",
"//src/writer/html:repo_writer",
]
diff --git a/src/writer/gemini/BUILD.gn b/src/writer/gemini/BUILD.gn
@@ -0,0 +1,41 @@
+source_set("index_writer") {
+ sources = [
+ "index_writer.c",
+ "index_writer.h",
+ "repo_index.c",
+ "repo_index.h",
+ ]
+ configs += [ "//:gout_config" ]
+ deps = [
+ "//src:format",
+ "//src:utils",
+ ]
+ public_deps = [ "//src/git" ]
+}
+
+source_set("repo_writer") {
+ sources = [
+ "commit.c",
+ "commit.h",
+ "fileblob.c",
+ "fileblob.h",
+ "files.c",
+ "files.h",
+ "log.c",
+ "log.h",
+ "page.c",
+ "page.h",
+ "refs.c",
+ "refs.h",
+ "repo_writer.c",
+ "repo_writer.h",
+ ]
+ configs += [ "//:gout_config" ]
+ deps = [
+ "//src:format",
+ "//src:utils",
+ "//src/writer/atom",
+ "//src/writer/cache",
+ ]
+ public_deps = [ "//src/git" ]
+}
diff --git a/src/writer/gemini/commit.c b/src/writer/gemini/commit.c
@@ -0,0 +1,262 @@
+#include "writer/gemini/commit.h"
+
+#include <assert.h>
+#include <err.h>
+#include <limits.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "format.h"
+#include "git/delta.h"
+#include "utils.h"
+#include "writer/gemini/page.h"
+
+struct GeminiCommit {
+ FILE* out;
+ const FileSystem* fs;
+ GeminiPage* page;
+ DiffLimits limits;
+};
+
+static void gemini_commit_write_summary(GeminiCommit* commit,
+ const GitCommit* git_commit);
+static void gemini_commit_write_diffstat(GeminiCommit* commit,
+ const GitCommit* git_commit);
+static void gemini_commit_write_diffstat_row(GeminiCommit* commit,
+ const GitDelta* delta);
+static void gemini_commit_write_diff_content(GeminiCommit* commit,
+ const GitCommit* git_commit);
+static void gemini_commit_write_diff_delta(GeminiCommit* commit,
+ const GitDelta* delta);
+static void gemini_commit_write_diff_hunk(GeminiCommit* commit,
+ const GitHunk* hunk);
+
+GeminiCommit* gemini_commit_create(const GitRepo* repo,
+ const FileSystem* fs,
+ const char* oid,
+ const char* title) {
+ assert(repo != NULL);
+ assert(fs != NULL);
+ assert(oid != NULL);
+ GeminiCommit* commit = ecalloc(1, sizeof(GeminiCommit));
+ commit->fs = fs;
+ char filename[PATH_MAX];
+ int r = snprintf(filename, sizeof(filename), "%s.gmi", oid);
+ if (r < 0 || (size_t)r >= sizeof(filename)) {
+ errx(1, "snprintf: filename truncated or error");
+ }
+ char* path = path_concat("commit", filename);
+ commit->out = fs->fopen(path, "w");
+ if (!commit->out) {
+ err(1, "fopen: %s", path);
+ }
+ free(path);
+ commit->page = gemini_page_create(commit->out, repo, fs, title, "../");
+ commit->limits.max_files = 1000;
+ commit->limits.max_deltas = 1000;
+ commit->limits.max_delta_lines = 100000;
+ return commit;
+}
+
+void gemini_commit_free(GeminiCommit* commit) {
+ if (!commit) {
+ return;
+ }
+ commit->fs->fclose(commit->out);
+ commit->out = NULL;
+ gemini_page_free(commit->page);
+ commit->page = NULL;
+ free(commit);
+}
+
+void gemini_commit_begin(GeminiCommit* commit) {
+ assert(commit != NULL);
+ gemini_page_begin(commit->page);
+}
+
+void gemini_commit_add_commit(GeminiCommit* commit,
+ const GitCommit* git_commit) {
+ assert(commit != NULL);
+ assert(git_commit != NULL);
+ FILE* out = commit->out;
+
+ gemini_commit_write_summary(commit, git_commit);
+
+ size_t deltas_len = git_commit->deltas_len;
+ if (deltas_len == 0) {
+ return;
+ }
+ size_t addcount = git_commit->addcount;
+ size_t delcount = git_commit->delcount;
+ size_t filecount = git_commit->filecount;
+ if (filecount > commit->limits.max_files ||
+ deltas_len > commit->limits.max_deltas ||
+ addcount > commit->limits.max_delta_lines ||
+ delcount > commit->limits.max_delta_lines) {
+ fprintf(out, "\nDiff is too large, output suppressed.\n");
+ return;
+ }
+
+ gemini_commit_write_diffstat(commit, git_commit);
+ gemini_commit_write_diff_content(commit, git_commit);
+}
+
+void gemini_commit_set_diff_limits(GeminiCommit* commit,
+ const DiffLimits* limits) {
+ assert(commit != NULL);
+ assert(limits != NULL);
+ commit->limits = *limits;
+}
+
+void gemini_commit_end(GeminiCommit* commit) {
+ assert(commit != NULL);
+ gemini_page_end(commit->page);
+}
+
+static void gemini_commit_write_summary(GeminiCommit* commit,
+ const GitCommit* git_commit) {
+ FILE* out = commit->out;
+ const char* oid = git_commit->oid;
+ fprintf(out, "=> ../commit/");
+ print_percent_encoded(out, oid);
+ fprintf(out, ".gmi commit %s\n", oid);
+
+ const char* parentoid = git_commit->parentoid;
+ if (parentoid && parentoid[0] != '\0') {
+ fprintf(out, "=> ../commit/");
+ print_percent_encoded(out, parentoid);
+ fprintf(out, ".gmi parent %s\n", parentoid);
+ }
+
+ fprintf(out, "\n```\n");
+ fprintf(out, "Author: %s <%s>\n", git_commit->author_name,
+ git_commit->author_email);
+ fprintf(out, "Date: ");
+ print_time(out, git_commit->author_time, git_commit->author_timezone_offset);
+ fprintf(out, "\n");
+
+ const char* message = git_commit->message;
+ if (message) {
+ fprintf(out, "\n%s\n", message);
+ }
+ fprintf(out, "```\n\n");
+}
+
+static void gemini_commit_write_diffstat(GeminiCommit* commit,
+ const GitCommit* git_commit) {
+ fprintf(commit->out, "### Diffstat\n\n```\n");
+ size_t delta_count = git_commit->deltas_len;
+ for (size_t i = 0; i < delta_count; i++) {
+ gemini_commit_write_diffstat_row(commit, git_commit->deltas[i]);
+ }
+ fprintf(commit->out, "```\n\n");
+}
+
+static void gemini_commit_write_diffstat_row(GeminiCommit* commit,
+ const GitDelta* delta) {
+ static const size_t kGraphWidth = 30;
+ FILE* out = commit->out;
+
+ fprintf(out, " %c ", delta->status);
+ char filename[PATH_MAX];
+ const char* old_file_path = delta->old_file_path;
+ const char* new_file_path = delta->new_file_path;
+ int r;
+ if (strcmp(old_file_path, new_file_path) == 0) {
+ r = snprintf(filename, sizeof(filename), "%s", old_file_path);
+ } else {
+ r = snprintf(filename, sizeof(filename), "%s -> %s", old_file_path,
+ new_file_path);
+ }
+ if (r < 0 || (size_t)r >= sizeof(filename)) {
+ errx(1, "snprintf: filename truncated or error");
+ }
+ fprintf(out, "%-35.35s", filename);
+
+ size_t changed = delta->addcount + delta->delcount;
+ fprintf(out, " | ");
+ fprintf(out, "%7zu ", changed);
+ char* added_graph = gitdelta_added_graph(delta, kGraphWidth);
+ fprintf(out, "%s", added_graph);
+ free(added_graph);
+ char* deleted_graph = gitdelta_deleted_graph(delta, kGraphWidth);
+ fprintf(out, "%s", deleted_graph);
+ free(deleted_graph);
+ fprintf(out, "\n");
+}
+
+static void gemini_commit_write_diff_content(GeminiCommit* commit,
+ const GitCommit* git_commit) {
+ FILE* out = commit->out;
+ size_t addcount = git_commit->addcount;
+ size_t delcount = git_commit->delcount;
+ size_t filecount = git_commit->filecount;
+ fprintf(out, "%zu file%s changed, %zu insertion%s(+), %zu deletion%s(-)\n\n",
+ filecount, filecount == 1 ? "" : "s", //
+ addcount, addcount == 1 ? "" : "s", //
+ delcount, delcount == 1 ? "" : "s");
+
+ size_t delta_count = git_commit->deltas_len;
+ for (size_t i = 0; i < delta_count; i++) {
+ gemini_commit_write_diff_delta(commit, git_commit->deltas[i]);
+ }
+}
+
+static void gemini_commit_write_diff_delta(GeminiCommit* commit,
+ const GitDelta* delta) {
+ FILE* out = commit->out;
+ fprintf(out, "=> ../file/");
+ print_percent_encoded(out, delta->new_file_path);
+ fprintf(out, ".gmi diff --git a/%s b/%s\n", delta->old_file_path,
+ delta->new_file_path);
+
+ if (delta->is_binary) {
+ fprintf(out, "Binary files differ.\n\n");
+ } else {
+ fprintf(out, "```\n");
+ size_t hunk_count = delta->hunks_len;
+ for (size_t i = 0; i < hunk_count; i++) {
+ gemini_commit_write_diff_hunk(commit, delta->hunks[i]);
+ }
+ fprintf(out, "```\n\n");
+ }
+}
+
+static void gemini_commit_write_diff_hunk(GeminiCommit* commit,
+ const GitHunk* hunk) {
+ FILE* out = commit->out;
+
+ // Output header. e.g. @@ -0,0 +1,3 @@
+ fprintf(out, "%s\n", hunk->header);
+
+ // Iterate over lines in hunk.
+ size_t line_count = hunk->lines_len;
+ for (size_t i = 0; i < line_count; i++) {
+ const GitHunkLine* line = hunk->lines[i];
+
+ const char* content = line->content;
+ size_t content_len = line->content_len;
+
+ // Strip trailing newline/CR from content.
+ while (content_len > 0 && (content[content_len - 1] == '\n' ||
+ content[content_len - 1] == '\r')) {
+ content_len--;
+ }
+
+ int old_lineno = line->old_lineno;
+ int new_lineno = line->new_lineno;
+ if (old_lineno == -1) {
+ // Added line. Prefix with +.
+ fprintf(out, "+%.*s\n", (int)content_len, content);
+ } else if (new_lineno == -1) {
+ // Removed line. Prefix with -.
+ fprintf(out, "-%.*s\n", (int)content_len, content);
+ } else {
+ // Unchanged line. Prefix with ' '.
+ fprintf(out, " %.*s\n", (int)content_len, content);
+ }
+ }
+}
diff --git a/src/writer/gemini/commit.h b/src/writer/gemini/commit.h
@@ -0,0 +1,24 @@
+#ifndef GOUT_WRITER_GEMINI_COMMIT_H_
+#define GOUT_WRITER_GEMINI_COMMIT_H_
+
+#include <stdint.h>
+
+#include "git/commit.h"
+#include "git/repo.h"
+#include "utils.h"
+
+typedef struct GeminiCommit GeminiCommit;
+
+GeminiCommit* gemini_commit_create(const GitRepo* repo,
+ const FileSystem* fs,
+ const char* oid,
+ const char* title);
+void gemini_commit_free(GeminiCommit* commit);
+void gemini_commit_begin(GeminiCommit* commit);
+void gemini_commit_add_commit(GeminiCommit* commit,
+ const GitCommit* git_commit);
+void gemini_commit_set_diff_limits(GeminiCommit* commit,
+ const DiffLimits* limits);
+void gemini_commit_end(GeminiCommit* commit);
+
+#endif // GOUT_WRITER_GEMINI_COMMIT_H_
diff --git a/src/writer/gemini/commit_tests.c b/src/writer/gemini/commit_tests.c
@@ -0,0 +1,52 @@
+#include "writer/gemini/commit.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "fs_inmemory.h"
+#include "git/commit.h"
+#include "git/delta.h"
+#include "git/repo.h"
+#include "test_utils.h"
+#include "utest.h"
+
+struct gemini_commit {
+ int dummy;
+};
+
+UTEST_F_SETUP(gemini_commit) {
+ inmemory_fs_clear();
+}
+
+UTEST_F_TEARDOWN(gemini_commit) {}
+
+UTEST_F(gemini_commit, basic) {
+ GitRepo repo = {.short_name = "test-repo"};
+ GeminiCommit* commit_writer =
+ gemini_commit_create(&repo, g_fs_inmemory, "sha123", "Commit Title");
+ ASSERT_NE(NULL, commit_writer);
+
+ GitCommit commit = {
+ .oid = "sha123",
+ .parentoid = "parent456",
+ .summary = "Fix a bug",
+ .message = "Detailed description.",
+ .author_name = "Author Name",
+ .author_email = "author@example.com",
+ .author_time = 1702031400,
+ .author_timezone_offset = 0,
+ };
+
+ gemini_commit_begin(commit_writer);
+ gemini_commit_add_commit(commit_writer, &commit);
+ gemini_commit_end(commit_writer);
+ gemini_commit_free(commit_writer);
+
+ const char* buf = inmemory_fs_get_buffer("commit/sha123.gmi");
+ ASSERT_NE(NULL, buf);
+
+ EXPECT_STR_SEQUENCE(buf, "Commit Title",
+ "=> ../commit/sha123.gmi commit sha123", "Author Name",
+ "Detailed description.");
+}
diff --git a/src/writer/gemini/fileblob.c b/src/writer/gemini/fileblob.c
@@ -0,0 +1,140 @@
+#include "writer/gemini/fileblob.h"
+
+#include <assert.h>
+#include <err.h>
+#include <libgen.h>
+#include <limits.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "format.h"
+#include "utils.h"
+#include "writer/gemini/page.h"
+
+struct GeminiFileBlob {
+ const GitRepo* repo;
+ const FileSystem* fs;
+ FILE* out;
+ GeminiPage* page;
+};
+
+GeminiFileBlob* gemini_fileblob_create(const GitRepo* repo,
+ const FileSystem* fs,
+ const char* path) {
+ assert(repo != NULL);
+ assert(fs != NULL);
+ assert(path != NULL);
+ if (!is_safe_repo_path(path)) {
+ errx(1, "unsafe path: %s", path);
+ }
+ GeminiFileBlob* blob = ecalloc(1, sizeof(GeminiFileBlob));
+ blob->repo = repo;
+ blob->fs = fs;
+
+ // Create directories.
+ char filename_buffer[PATH_MAX];
+ int r = snprintf(filename_buffer, sizeof(filename_buffer), "%s.gmi", path);
+ if (r < 0 || (size_t)r >= sizeof(filename_buffer)) {
+ errx(1, "snprintf: filename truncated or error");
+ }
+
+ char* out_path = path_concat("file", filename_buffer);
+ char* dir_copy = estrdup(out_path);
+ const char* d = dirname(dir_copy);
+ if (!d) {
+ err(1, "dirname");
+ }
+ fs->mkdirp(d);
+ blob->out = fs->fopen(out_path, "w");
+ if (!blob->out) {
+ err(1, "fopen: %s", out_path);
+ }
+
+ // Compute the relative path.
+ char* relpath = relpath_from_dir(d);
+ free(dir_copy);
+
+ char* path_copy = estrdup(path);
+ const char* title = basename(path_copy);
+ if (!title) {
+ err(1, "basename");
+ }
+ blob->page = gemini_page_create(blob->out, repo, fs, title, relpath);
+
+ free(out_path);
+ free(path_copy);
+ free(relpath);
+
+ return blob;
+}
+
+void gemini_fileblob_free(GeminiFileBlob* blob) {
+ if (!blob) {
+ return;
+ }
+ blob->fs->fclose(blob->out);
+ blob->out = NULL;
+ gemini_page_free(blob->page);
+ blob->page = NULL;
+ free(blob);
+}
+
+void gemini_fileblob_begin(GeminiFileBlob* blob) {
+ assert(blob != NULL);
+ gemini_page_begin(blob->page);
+}
+
+void gemini_fileblob_add_file(GeminiFileBlob* blob, const GitFile* file) {
+ assert(blob != NULL);
+ assert(file != NULL);
+ FILE* out = blob->out;
+
+ char path[PATH_MAX];
+ estrlcpy(path, file->repo_path, sizeof(path));
+ const char* filename = basename(path);
+ if (!filename) {
+ err(1, "basename");
+ }
+ fprintf(out, "File: %s (%zdB)\n\n", filename, file->size_bytes);
+
+ ssize_t size_lines = file->size_lines;
+ if (size_lines == -1) {
+ fprintf(out, "Binary file.\n");
+ return;
+ }
+ if (size_lines == -2) {
+ fprintf(out, "File too large to display.\n");
+ return;
+ }
+
+ assert(file->content != NULL);
+ fprintf(out, "```\n");
+ size_t i = 0;
+ const char* end = file->content + file->size_bytes;
+ const char* cur_line = file->content;
+ while (cur_line < end) {
+ const char* next_line = memchr(cur_line, '\n', end - cur_line);
+ size_t len = (next_line ? next_line : end) - cur_line;
+
+ i++;
+ size_t display_len = len;
+ if (display_len > 0 && cur_line[display_len - 1] == '\r') {
+ display_len--;
+ }
+ fprintf(out, "%6zu %.*s\n", i, (int)display_len, cur_line);
+
+ if (next_line) {
+ cur_line = next_line + 1;
+ } else {
+ break;
+ }
+ }
+ fprintf(out, "```\n");
+}
+
+void gemini_fileblob_end(GeminiFileBlob* blob) {
+ assert(blob != NULL);
+ gemini_page_end(blob->page);
+}
diff --git a/src/writer/gemini/fileblob.h b/src/writer/gemini/fileblob.h
@@ -0,0 +1,18 @@
+#ifndef GOUT_WRITER_GEMINI_FILEBLOB_H_
+#define GOUT_WRITER_GEMINI_FILEBLOB_H_
+
+#include "git/file.h"
+#include "git/repo.h"
+#include "utils.h"
+
+typedef struct GeminiFileBlob GeminiFileBlob;
+
+GeminiFileBlob* gemini_fileblob_create(const GitRepo* repo,
+ const FileSystem* fs,
+ const char* path);
+void gemini_fileblob_free(GeminiFileBlob* blob);
+void gemini_fileblob_begin(GeminiFileBlob* blob);
+void gemini_fileblob_add_file(GeminiFileBlob* blob, const GitFile* file);
+void gemini_fileblob_end(GeminiFileBlob* blob);
+
+#endif // GOUT_WRITER_GEMINI_FILEBLOB_H_
diff --git a/src/writer/gemini/fileblob_tests.c b/src/writer/gemini/fileblob_tests.c
@@ -0,0 +1,47 @@
+#include "writer/gemini/fileblob.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "fs_inmemory.h"
+#include "git/file.h"
+#include "git/repo.h"
+#include "test_utils.h"
+#include "utest.h"
+
+struct gemini_fileblob {
+ int dummy;
+};
+
+UTEST_F_SETUP(gemini_fileblob) {
+ inmemory_fs_clear();
+}
+
+UTEST_F_TEARDOWN(gemini_fileblob) {}
+
+UTEST_F(gemini_fileblob, basic) {
+ GitRepo repo = {.short_name = "test-repo"};
+ GeminiFileBlob* blob_writer =
+ gemini_fileblob_create(&repo, g_fs_inmemory, "src/main.c");
+ ASSERT_NE(NULL, blob_writer);
+
+ const char* content = "int main() {\n return 0;\n}\n";
+ GitFile file = {
+ .repo_path = "src/main.c",
+ .content = (char*)content,
+ .size_bytes = strlen(content),
+ .size_lines = 3,
+ };
+
+ gemini_fileblob_begin(blob_writer);
+ gemini_fileblob_add_file(blob_writer, &file);
+ gemini_fileblob_end(blob_writer);
+ gemini_fileblob_free(blob_writer);
+
+ const char* buf = inmemory_fs_get_buffer("file/src/main.c.gmi");
+ ASSERT_NE(NULL, buf);
+
+ EXPECT_STR_SEQUENCE(buf, "File: main.c", "```", " 1 int main()",
+ " 2 return 0;", " 3 }", "```");
+}
diff --git a/src/writer/gemini/files.c b/src/writer/gemini/files.c
@@ -0,0 +1,75 @@
+#include "writer/gemini/files.h"
+
+#include <assert.h>
+#include <err.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <sys/types.h>
+
+#include "format.h"
+#include "utils.h"
+#include "writer/gemini/page.h"
+
+struct GeminiFiles {
+ const GitRepo* repo;
+ const FileSystem* fs;
+ FILE* out;
+ GeminiPage* page;
+};
+
+GeminiFiles* gemini_files_create(const GitRepo* repo, const FileSystem* fs) {
+ assert(repo != NULL);
+ assert(fs != NULL);
+ GeminiFiles* files = ecalloc(1, sizeof(GeminiFiles));
+ files->repo = repo;
+ files->fs = fs;
+ files->out = fs->fopen("files.gmi", "w");
+ if (!files->out) {
+ err(1, "fopen: files.gmi");
+ }
+ files->page = gemini_page_create(files->out, repo, fs, "Files", "");
+ return files;
+}
+
+void gemini_files_free(GeminiFiles* files) {
+ if (!files) {
+ return;
+ }
+ files->fs->fclose(files->out);
+ files->out = NULL;
+ gemini_page_free(files->page);
+ files->page = NULL;
+ free(files);
+}
+
+void gemini_files_begin(GeminiFiles* files) {
+ assert(files != NULL);
+ gemini_page_begin(files->page);
+}
+
+void gemini_files_add_file(GeminiFiles* files, const GitFile* file) {
+ assert(files != NULL);
+ assert(file != NULL);
+ FILE* out = files->out;
+ fprintf(out, "=> file/");
+ print_percent_encoded(out, file->repo_path);
+ fprintf(out, ".gmi %s %s", file->mode, file->display_path);
+
+ ssize_t size_lines = file->size_lines;
+ ssize_t size_bytes = file->size_bytes;
+ if (size_lines >= 0) {
+ fprintf(out, " (%zdL)", size_lines);
+ } else if (size_bytes >= 0) {
+ fprintf(out, " (%zdB)", size_bytes);
+ }
+ const char* oid = file->commit_oid;
+ if (oid[0] != '\0') {
+ fprintf(out, " @ %s", oid);
+ }
+ fprintf(out, "\n");
+}
+
+void gemini_files_end(GeminiFiles* files) {
+ assert(files != NULL);
+ gemini_page_end(files->page);
+}
diff --git a/src/writer/gemini/files.h b/src/writer/gemini/files.h
@@ -0,0 +1,16 @@
+#ifndef GOUT_WRITER_GEMINI_FILES_H_
+#define GOUT_WRITER_GEMINI_FILES_H_
+
+#include "git/file.h"
+#include "git/repo.h"
+#include "utils.h"
+
+typedef struct GeminiFiles GeminiFiles;
+
+GeminiFiles* gemini_files_create(const GitRepo* repo, const FileSystem* fs);
+void gemini_files_free(GeminiFiles* files);
+void gemini_files_begin(GeminiFiles* files);
+void gemini_files_add_file(GeminiFiles* files, const GitFile* file);
+void gemini_files_end(GeminiFiles* files);
+
+#endif // GOUT_WRITER_GEMINI_FILES_H_
diff --git a/src/writer/gemini/files_tests.c b/src/writer/gemini/files_tests.c
@@ -0,0 +1,71 @@
+#include "writer/gemini/files.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "fs_inmemory.h"
+#include "git/file.h"
+#include "git/repo.h"
+#include "test_utils.h"
+#include "utest.h"
+
+struct gemini_files {
+ int dummy;
+};
+
+UTEST_F_SETUP(gemini_files) {
+ inmemory_fs_clear();
+}
+
+UTEST_F_TEARDOWN(gemini_files) {}
+
+UTEST_F(gemini_files, basic) {
+ GitRepo repo = {.short_name = "test-repo"};
+ GeminiFiles* files = gemini_files_create(&repo, g_fs_inmemory);
+ ASSERT_NE(NULL, files);
+
+ GitFile f1 = {
+ .type = kFileTypeFile,
+ .mode = "-rw-r--r--",
+ .display_path = "README.md",
+ .repo_path = "README.md",
+ .size_bytes = 100,
+ .size_lines = 5,
+ };
+
+ gemini_files_begin(files);
+ gemini_files_add_file(files, &f1);
+ gemini_files_end(files);
+ gemini_files_free(files);
+
+ const char* buf = inmemory_fs_get_buffer("files.gmi");
+ ASSERT_NE(NULL, buf);
+ EXPECT_STR_SEQUENCE(buf, "=> file/README.md.gmi", "-rw-r--r--", "README.md",
+ "(5L)");
+}
+
+UTEST_F(gemini_files, percent_encoding) {
+ GitRepo repo = {.short_name = "test-repo"};
+ GeminiFiles* files = gemini_files_create(&repo, g_fs_inmemory);
+
+ GitFile f1 = {
+ .type = kFileTypeFile,
+ .mode = "-rw-r--r--",
+ .display_path = "path with spaces.txt",
+ .repo_path = "path with spaces.txt",
+ .size_bytes = 10,
+ .size_lines = 1,
+ };
+
+ gemini_files_begin(files);
+ gemini_files_add_file(files, &f1);
+ gemini_files_end(files);
+ gemini_files_free(files);
+
+ const char* buf = inmemory_fs_get_buffer("files.gmi");
+ ASSERT_NE(NULL, buf);
+ /* "path with spaces.txt" -> "path%20with%20spaces.txt" */
+ EXPECT_STR_SEQUENCE(buf, "=> file/path%20with%20spaces.txt.gmi",
+ "path with spaces.txt");
+}
diff --git a/src/writer/gemini/index_writer.c b/src/writer/gemini/index_writer.c
@@ -0,0 +1,46 @@
+#include "writer/gemini/index_writer.h"
+
+#include <assert.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "git/repo.h"
+#include "utils.h"
+#include "writer/gemini/repo_index.h"
+
+struct GeminiIndexWriter {
+ GeminiRepoIndex* index;
+};
+
+GeminiIndexWriter* gemini_indexwriter_create(FILE* out) {
+ assert(out != NULL);
+ GeminiIndexWriter* writer = ecalloc(1, sizeof(GeminiIndexWriter));
+ writer->index = gemini_repoindex_create(out);
+ return writer;
+}
+
+void gemini_indexwriter_free(GeminiIndexWriter* writer) {
+ if (!writer) {
+ return;
+ }
+ gemini_repoindex_free(writer->index);
+ writer->index = NULL;
+ free(writer);
+}
+
+void gemini_indexwriter_begin(GeminiIndexWriter* writer) {
+ assert(writer != NULL);
+ gemini_repoindex_begin(writer->index);
+}
+
+void gemini_indexwriter_add_repo(GeminiIndexWriter* writer,
+ const GitRepo* repo) {
+ assert(writer != NULL);
+ assert(repo != NULL);
+ gemini_repoindex_add_repo(writer->index, repo);
+}
+
+void gemini_indexwriter_end(GeminiIndexWriter* writer) {
+ assert(writer != NULL);
+ gemini_repoindex_end(writer->index);
+}
diff --git a/src/writer/gemini/index_writer.h b/src/writer/gemini/index_writer.h
@@ -0,0 +1,17 @@
+#ifndef GOUT_WRITER_GEMINI_INDEX_WRITER_H_
+#define GOUT_WRITER_GEMINI_INDEX_WRITER_H_
+
+#include "git/repo.h"
+
+#include <stdio.h>
+
+typedef struct GeminiIndexWriter GeminiIndexWriter;
+
+GeminiIndexWriter* gemini_indexwriter_create(FILE* out);
+void gemini_indexwriter_free(GeminiIndexWriter* writer);
+void gemini_indexwriter_begin(GeminiIndexWriter* writer);
+void gemini_indexwriter_add_repo(GeminiIndexWriter* writer,
+ const GitRepo* repo);
+void gemini_indexwriter_end(GeminiIndexWriter* writer);
+
+#endif // GOUT_WRITER_GEMINI_INDEX_WRITER_H_
diff --git a/src/writer/gemini/log.c b/src/writer/gemini/log.c
@@ -0,0 +1,118 @@
+#include "writer/gemini/log.h"
+
+#include <assert.h>
+#include <err.h>
+#include <stdint.h>
+#include <stdlib.h>
+
+#include "format.h"
+#include "utils.h"
+#include "writer/cache/cache.h"
+#include "writer/gemini/page.h"
+
+#include <sys/stat.h>
+#include <unistd.h>
+
+struct GeminiLog {
+ const GitRepo* repo;
+ const FileSystem* fs;
+ FILE* out;
+ Cache* cache;
+ GeminiPage* page;
+ size_t remaining_commits;
+ size_t unlogged_commits;
+};
+
+static void write_commit_row(FILE* out, const GitCommit* commit);
+
+GeminiLog* gemini_log_create(const GitRepo* repo, const FileSystem* fs) {
+ assert(repo != NULL);
+ assert(fs != NULL);
+ GeminiLog* log = ecalloc(1, sizeof(GeminiLog));
+ log->repo = repo;
+ log->fs = fs;
+ log->out = fs->fopen("log.gmi", "w");
+ if (!log->out) {
+ err(1, "fopen: log.gmi");
+ }
+ log->page = gemini_page_create(log->out, repo, fs, "Log", "");
+ log->remaining_commits = SIZE_MAX;
+ log->unlogged_commits = 0;
+ return log;
+}
+
+void gemini_log_free(GeminiLog* log) {
+ if (!log) {
+ return;
+ }
+ log->fs->fclose(log->out);
+ log->out = NULL;
+ cache_free(log->cache);
+ log->cache = NULL;
+ gemini_page_free(log->page);
+ log->page = NULL;
+ free(log);
+}
+
+void gemini_log_set_cachefile(GeminiLog* log, const char* cachefile) {
+ assert(log != NULL);
+ assert(cachefile != NULL);
+ log->cache = cache_open(log->fs, cachefile, write_commit_row);
+}
+
+void gemini_log_set_commit_limit(GeminiLog* log, size_t count) {
+ assert(log != NULL);
+ log->remaining_commits = count;
+}
+
+bool gemini_log_can_add_commits(const GeminiLog* log) {
+ assert(log != NULL);
+ return !log->cache || cache_can_add_commits(log->cache);
+}
+
+void gemini_log_begin(GeminiLog* log) {
+ assert(log != NULL);
+ gemini_page_begin(log->page);
+}
+
+void gemini_log_add_commit(GeminiLog* log, const GitCommit* commit) {
+ assert(log != NULL);
+ assert(commit != NULL);
+ if (log->cache) {
+ cache_add_commit_row(log->cache, commit);
+ } else if (log->remaining_commits > 0) {
+ write_commit_row(log->out, commit);
+ log->remaining_commits--;
+ } else {
+ log->unlogged_commits++;
+ }
+}
+
+void gemini_log_end(GeminiLog* log) {
+ assert(log != NULL);
+ if (log->cache) {
+ cache_close_and_replace(log->cache, log->out);
+ } else if (log->unlogged_commits > 0) {
+ size_t count = log->unlogged_commits;
+ fprintf(log->out, "\n%zu more commits remaining, fetch the repository\n",
+ count);
+ }
+ fprintf(log->out, "\n=> atom.xml Atom feed\n");
+ fprintf(log->out, "=> tags.xml Atom feed (tags)\n");
+ gemini_page_end(log->page);
+}
+
+static void write_commit_row(FILE* out, const GitCommit* commit) {
+ assert(out != NULL);
+ assert(commit != NULL);
+ fprintf(out, "=> commit/");
+ print_percent_encoded(out, commit->oid);
+ fprintf(out, ".gmi ");
+ print_time_short(out, commit->author_time);
+ fprintf(out, " ");
+ const char* summary = commit->summary;
+ if (summary) {
+ fprintf(out, "%s", summary);
+ }
+ fprintf(out, " [%s]\n", commit->author_name);
+}
diff --git a/src/writer/gemini/log.h b/src/writer/gemini/log.h
@@ -0,0 +1,22 @@
+#ifndef GOUT_WRITER_GEMINI_LOG_H_
+#define GOUT_WRITER_GEMINI_LOG_H_
+
+#include <stdbool.h>
+#include <stdio.h>
+
+#include "git/commit.h"
+#include "git/repo.h"
+#include "utils.h"
+
+typedef struct GeminiLog GeminiLog;
+
+GeminiLog* gemini_log_create(const GitRepo* repo, const FileSystem* fs);
+void gemini_log_free(GeminiLog* log);
+void gemini_log_set_cachefile(GeminiLog* log, const char* cachefile);
+void gemini_log_set_commit_limit(GeminiLog* log, size_t count);
+bool gemini_log_can_add_commits(const GeminiLog* log);
+void gemini_log_begin(GeminiLog* log);
+void gemini_log_add_commit(GeminiLog* log, const GitCommit* commit);
+void gemini_log_end(GeminiLog* log);
+
+#endif // GOUT_WRITER_GEMINI_LOG_H_
diff --git a/src/writer/gemini/log_tests.c b/src/writer/gemini/log_tests.c
@@ -0,0 +1,57 @@
+#include "writer/gemini/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "fs_inmemory.h"
+#include "git/commit.h"
+#include "git/repo.h"
+#include "test_utils.h"
+#include "utest.h"
+
+struct gemini_log {
+ int dummy;
+};
+
+UTEST_F_SETUP(gemini_log) {
+ inmemory_fs_clear();
+}
+
+UTEST_F_TEARDOWN(gemini_log) {}
+
+UTEST_F(gemini_log, begin) {
+ GitRepo repo = {.short_name = "test-repo"};
+ GeminiLog* log = gemini_log_create(&repo, g_fs_inmemory);
+ ASSERT_NE(NULL, log);
+ gemini_log_begin(log);
+ gemini_log_free(log);
+
+ const char* buf = inmemory_fs_get_buffer("log.gmi");
+ ASSERT_NE(NULL, buf);
+ EXPECT_STR_SEQUENCE(buf, "# Log - test-repo");
+}
+
+UTEST_F(gemini_log, add_commit) {
+ GitRepo repo = {.short_name = "test-repo"};
+ GeminiLog* log = gemini_log_create(&repo, g_fs_inmemory);
+ ASSERT_NE(NULL, log);
+
+ GitCommit commit = {
+ .oid = "abc1234",
+ .summary = "Fix bug",
+ .author_name = "User",
+ .author_time = 1702031400,
+ };
+
+ gemini_log_begin(log);
+ gemini_log_add_commit(log, &commit);
+ gemini_log_end(log);
+ gemini_log_free(log);
+
+ const char* buf = inmemory_fs_get_buffer("log.gmi");
+ ASSERT_NE(NULL, buf);
+ /* Gemini format: => commit/abc1234.gmi 2023-12-08 10:30 Fix bug [User] */
+ EXPECT_STR_SEQUENCE(buf, "=> commit/abc1234.gmi", "2023-12-08 10:30",
+ "Fix bug", "[User]");
+}
diff --git a/src/writer/gemini/page.c b/src/writer/gemini/page.c
@@ -0,0 +1,89 @@
+#include "writer/gemini/page.h"
+
+#include <assert.h>
+#include <stdbool.h>
+#include <stdlib.h>
+
+#include "format.h"
+#include "utils.h"
+
+struct GeminiPage {
+ FILE* out;
+ const GitRepo* repo;
+ const FileSystem* fs;
+ char* title;
+ char* relpath;
+};
+
+GeminiPage* gemini_page_create(FILE* out,
+ const GitRepo* repo,
+ const FileSystem* fs,
+ const char* title,
+ const char* relpath) {
+ assert(out != NULL);
+ assert(repo != NULL);
+ assert(fs != NULL);
+ assert(title != NULL);
+ assert(relpath != NULL);
+ GeminiPage* page = ecalloc(1, sizeof(GeminiPage));
+ page->out = out;
+ page->repo = repo;
+ page->fs = fs;
+ page->title = estrdup(title);
+ page->relpath = estrdup(relpath);
+ return page;
+}
+
+void gemini_page_free(GeminiPage* page) {
+ if (!page) {
+ return;
+ }
+ free(page->title);
+ page->title = NULL;
+ free(page->relpath);
+ page->relpath = NULL;
+ free(page);
+}
+
+void gemini_page_begin(GeminiPage* page) {
+ assert(page != NULL);
+ FILE* out = page->out;
+ fprintf(out, "# ");
+ if (page->title[0] != '\0') {
+ fprintf(out, "%s", page->title);
+ const char* short_name = page->repo->short_name;
+ if (short_name && short_name[0] != '\0') {
+ fprintf(out, " - %s", short_name);
+ }
+ } else if (page->repo->short_name) {
+ fprintf(out, "%s", page->repo->short_name);
+ }
+ fprintf(out, "\n\n");
+
+ const char* description = page->repo->description;
+ if (description && description[0] != '\0') {
+ fprintf(out, "> %s\n\n", description);
+ }
+
+ const char* clone_url = page->repo->clone_url;
+ if (clone_url && clone_url[0] != '\0') {
+ fprintf(out, "git clone %s\n\n", clone_url);
+ }
+
+ fprintf(out, "=> %slog.gmi Log\n", page->relpath);
+ fprintf(out, "=> %sfiles.gmi Files\n", page->relpath);
+ fprintf(out, "=> %srefs.gmi Refs\n", page->relpath);
+
+ for (size_t i = 0; i < page->repo->special_files_len; i++) {
+ RepoSpecialFile* sf = &page->repo->special_files[i];
+ fprintf(out, "=> %sfile/", page->relpath);
+ print_percent_encoded(out, sf->path);
+ fprintf(out, ".gmi %s\n", sf->label);
+ }
+ fprintf(out, "\n---\n\n");
+}
+
+void gemini_page_end(GeminiPage* page) {
+ assert(page != NULL);
+ (void)page;
+}
diff --git a/src/writer/gemini/page.h b/src/writer/gemini/page.h
@@ -0,0 +1,20 @@
+#ifndef GOUT_WRITER_GEMINI_PAGE_H_
+#define GOUT_WRITER_GEMINI_PAGE_H_
+
+#include <stdio.h>
+
+#include "git/repo.h"
+#include "utils.h"
+
+typedef struct GeminiPage GeminiPage;
+
+GeminiPage* gemini_page_create(FILE* out,
+ const GitRepo* repo,
+ const FileSystem* fs,
+ const char* title,
+ const char* relpath);
+void gemini_page_free(GeminiPage* page);
+void gemini_page_begin(GeminiPage* page);
+void gemini_page_end(GeminiPage* page);
+
+#endif // GOUT_WRITER_GEMINI_PAGE_H_
diff --git a/src/writer/gemini/page_tests.c b/src/writer/gemini/page_tests.c
@@ -0,0 +1,48 @@
+#include "writer/gemini/page.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "fs_inmemory.h"
+#include "git/repo.h"
+#include "test_utils.h"
+#include "utest.h"
+
+struct gemini_page {
+ int dummy;
+};
+
+UTEST_F_SETUP(gemini_page) {
+ inmemory_fs_clear();
+}
+
+UTEST_F_TEARDOWN(gemini_page) {}
+
+UTEST_F(gemini_page, basic) {
+ RepoSpecialFile sf = {.label = "LICENSE", .path = "LICENSE"};
+ GitRepo repo = {
+ .short_name = "test-repo",
+ .description = "Repo description",
+ .clone_url = "git://example.com/repo.git",
+ .special_files = &sf,
+ .special_files_len = 1,
+ };
+
+ FILE* out = g_fs_inmemory->fopen("test.gmi", "w");
+ GeminiPage* page =
+ gemini_page_create(out, &repo, g_fs_inmemory, "Page Title", "");
+ ASSERT_NE(NULL, page);
+
+ gemini_page_begin(page);
+ gemini_page_end(page);
+ g_fs_inmemory->fclose(out);
+ gemini_page_free(page);
+
+ const char* buf = inmemory_fs_get_buffer("test.gmi");
+ ASSERT_NE(NULL, buf);
+
+ EXPECT_STR_SEQUENCE(buf, "# Page Title - test-repo", "> Repo description",
+ "git clone", "=> log.gmi Log", "=> files.gmi Files",
+ "=> refs.gmi Refs", "=> file/LICENSE.gmi LICENSE");
+}
diff --git a/src/writer/gemini/refs.c b/src/writer/gemini/refs.c
@@ -0,0 +1,99 @@
+#include "writer/gemini/refs.h"
+
+#include <assert.h>
+#include <err.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "format.h"
+#include "git/commit.h"
+#include "utils.h"
+#include "writer/gemini/page.h"
+
+struct GeminiRefs {
+ const GitRepo* repo;
+ const FileSystem* fs;
+ FILE* out;
+ GeminiPage* page;
+ bool has_branches;
+ bool has_tags;
+ bool in_block;
+};
+
+GeminiRefs* gemini_refs_create(const GitRepo* repo, const FileSystem* fs) {
+ assert(repo != NULL);
+ assert(fs != NULL);
+ GeminiRefs* refs = ecalloc(1, sizeof(GeminiRefs));
+ refs->repo = repo;
+ refs->fs = fs;
+ refs->out = fs->fopen("refs.gmi", "w");
+ if (!refs->out) {
+ err(1, "fopen: refs.gmi");
+ }
+ refs->page = gemini_page_create(refs->out, repo, fs, "Refs", "");
+ return refs;
+}
+
+void gemini_refs_free(GeminiRefs* refs) {
+ if (!refs) {
+ return;
+ }
+ refs->fs->fclose(refs->out);
+ refs->out = NULL;
+ gemini_page_free(refs->page);
+ refs->page = NULL;
+ free(refs);
+}
+
+void gemini_refs_begin(GeminiRefs* refs) {
+ assert(refs != NULL);
+ gemini_page_begin(refs->page);
+}
+
+static void ensure_block_closed(GeminiRefs* refs) {
+ if (refs->in_block) {
+ fprintf(refs->out, "```\n");
+ refs->in_block = false;
+ }
+}
+
+static void ensure_block_open(GeminiRefs* refs) {
+ if (!refs->in_block) {
+ fprintf(refs->out, "```\n");
+ fprintf(refs->out, "%-32.32s %-16.16s %s\n", "Name", "Last commit date",
+ "Author");
+ refs->in_block = true;
+ }
+}
+
+void gemini_refs_add_ref(GeminiRefs* refs, const GitReference* ref) {
+ assert(refs != NULL);
+ assert(ref != NULL);
+ FILE* out = refs->out;
+
+ if (ref->type == kReftypeBranch && !refs->has_branches) {
+ ensure_block_closed(refs);
+ fprintf(out, "## Branches\n\n");
+ refs->has_branches = true;
+ ensure_block_open(refs);
+ } else if (ref->type == kReftypeTag && !refs->has_tags) {
+ ensure_block_closed(refs);
+ if (refs->has_branches) {
+ fprintf(out, "\n");
+ }
+ fprintf(out, "## Tags\n\n");
+ refs->has_tags = true;
+ ensure_block_open(refs);
+ }
+
+ GitCommit* commit = ref->commit;
+ fprintf(out, "%-32.32s ", ref->shorthand);
+ print_time_short(out, commit->author_time);
+ fprintf(out, " %s\n", commit->author_name);
+}
+
+void gemini_refs_end(GeminiRefs* refs) {
+ assert(refs != NULL);
+ ensure_block_closed(refs);
+ gemini_page_end(refs->page);
+}
diff --git a/src/writer/gemini/refs.h b/src/writer/gemini/refs.h
@@ -0,0 +1,16 @@
+#ifndef GOUT_WRITER_GEMINI_REFS_H_
+#define GOUT_WRITER_GEMINI_REFS_H_
+
+#include "git/reference.h"
+#include "git/repo.h"
+#include "utils.h"
+
+typedef struct GeminiRefs GeminiRefs;
+
+GeminiRefs* gemini_refs_create(const GitRepo* repo, const FileSystem* fs);
+void gemini_refs_free(GeminiRefs* refs);
+void gemini_refs_begin(GeminiRefs* refs);
+void gemini_refs_add_ref(GeminiRefs* refs, const GitReference* ref);
+void gemini_refs_end(GeminiRefs* refs);
+
+#endif // GOUT_WRITER_GEMINI_REFS_H_
diff --git a/src/writer/gemini/refs_tests.c b/src/writer/gemini/refs_tests.c
@@ -0,0 +1,49 @@
+#include "writer/gemini/refs.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "fs_inmemory.h"
+#include "git/commit.h"
+#include "git/reference.h"
+#include "git/repo.h"
+#include "test_utils.h"
+#include "utest.h"
+
+struct gemini_refs {
+ int dummy;
+};
+
+UTEST_F_SETUP(gemini_refs) {
+ inmemory_fs_clear();
+}
+
+UTEST_F_TEARDOWN(gemini_refs) {}
+
+UTEST_F(gemini_refs, branches) {
+ GitRepo repo = {.short_name = "test-repo"};
+ GeminiRefs* refs = gemini_refs_create(&repo, g_fs_inmemory);
+ ASSERT_NE(NULL, refs);
+
+ GitCommit commit = {
+ .author_name = "User A",
+ .author_time = 1702031400,
+ };
+ GitReference ref = {
+ .type = kReftypeBranch,
+ .shorthand = "main",
+ .commit = &commit,
+ };
+
+ gemini_refs_begin(refs);
+ gemini_refs_add_ref(refs, &ref);
+ gemini_refs_end(refs);
+ gemini_refs_free(refs);
+
+ const char* buf = inmemory_fs_get_buffer("refs.gmi");
+ ASSERT_NE(NULL, buf);
+ EXPECT_STR_SEQUENCE(buf, "## Branches", "```", "Name", "Last commit date",
+ "Author", "main", "2023-12-08 10:30", "User A", "```");
+ EXPECT_EQ(NULL, strstr(buf, "=> commit/"));
+}
diff --git a/src/writer/gemini/repo_index.c b/src/writer/gemini/repo_index.c
@@ -0,0 +1,55 @@
+#include "writer/gemini/repo_index.h"
+
+#include <assert.h>
+#include <stdlib.h>
+
+#include "format.h"
+#include "utils.h"
+
+struct GeminiRepoIndex {
+ FILE* out;
+};
+
+GeminiRepoIndex* gemini_repoindex_create(FILE* out) {
+ assert(out != NULL);
+ GeminiRepoIndex* index = ecalloc(1, sizeof(GeminiRepoIndex));
+ index->out = out;
+ return index;
+}
+
+void gemini_repoindex_free(GeminiRepoIndex* index) {
+ if (!index) {
+ return;
+ }
+ index->out = NULL;
+ free(index);
+}
+
+void gemini_repoindex_begin(GeminiRepoIndex* index) {
+ assert(index != NULL);
+ FILE* out = index->out;
+ fprintf(out, "# Repositories\n\n");
+}
+
+void gemini_repoindex_add_repo(GeminiRepoIndex* index, const GitRepo* repo) {
+ assert(index != NULL);
+ assert(repo != NULL);
+ FILE* out = index->out;
+ fprintf(out, "=> ");
+ print_percent_encoded(out, repo->short_name);
+ fprintf(out, "/log.gmi %s", repo->short_name);
+ if (repo->description && repo->description[0] != '\0') {
+ fprintf(out, " - %s", repo->description);
+ }
+ if (repo->last_commit_time > 0) {
+ fprintf(out, " (last commit: ");
+ print_time_short(out, repo->last_commit_time);
+ fprintf(out, ")");
+ }
+ fprintf(out, "\n");
+}
+
+void gemini_repoindex_end(GeminiRepoIndex* index) {
+ assert(index != NULL);
+ (void)index;
+}
diff --git a/src/writer/gemini/repo_index.h b/src/writer/gemini/repo_index.h
@@ -0,0 +1,16 @@
+#ifndef GOUT_WRITER_GEMINI_REPOINDEX_H_
+#define GOUT_WRITER_GEMINI_REPOINDEX_H_
+
+#include "git/repo.h"
+
+#include <stdio.h>
+
+typedef struct GeminiRepoIndex GeminiRepoIndex;
+
+GeminiRepoIndex* gemini_repoindex_create(FILE* out);
+void gemini_repoindex_free(GeminiRepoIndex* index);
+void gemini_repoindex_begin(GeminiRepoIndex* index);
+void gemini_repoindex_add_repo(GeminiRepoIndex* index, const GitRepo* repo);
+void gemini_repoindex_end(GeminiRepoIndex* index);
+
+#endif // GOUT_WRITER_GEMINI_REPOINDEX_H_
diff --git a/src/writer/gemini/repo_index_tests.c b/src/writer/gemini/repo_index_tests.c
@@ -0,0 +1,67 @@
+#include "writer/gemini/repo_index.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "git/repo.h"
+#include "test_utils.h"
+#include "utest.h"
+
+UTEST(gemini_repo_index, basic) {
+ char* buf = NULL;
+ size_t size = 0;
+ FILE* out = open_memstream(&buf, &size);
+
+ GeminiRepoIndex* index = gemini_repoindex_create(out);
+ ASSERT_NE(NULL, index);
+
+ GitRepo r1 = {
+ .short_name = "repo1",
+ .description = "Desc 1",
+ .owner = "Owner 1",
+ .last_commit_time = 1702031400,
+ };
+
+ gemini_repoindex_begin(index);
+ gemini_repoindex_add_repo(index, &r1);
+ gemini_repoindex_end(index);
+ gemini_repoindex_free(index);
+ fclose(out);
+
+ ASSERT_NE(NULL, buf);
+
+ /* Verify Header */
+ EXPECT_STR_SEQUENCE(buf, "# Repositories");
+
+ /* Verify Repo Row */
+ EXPECT_STR_SEQUENCE(buf, "=> repo1/log.gmi repo1", "Desc 1",
+ "2023-12-08 10:30");
+
+ free(buf);
+}
+
+UTEST(gemini_repo_index, percent_encoding) {
+ char* buf = NULL;
+ size_t size = 0;
+ FILE* out = open_memstream(&buf, &size);
+
+ GeminiRepoIndex* index = gemini_repoindex_create(out);
+
+ GitRepo r1 = {
+ .short_name = "repo with spaces",
+ .description = "Desc",
+ };
+
+ gemini_repoindex_begin(index);
+ gemini_repoindex_add_repo(index, &r1);
+ gemini_repoindex_end(index);
+ gemini_repoindex_free(index);
+ fclose(out);
+
+ ASSERT_NE(NULL, buf);
+ /* "repo with spaces" -> "repo%20with%20spaces" */
+ EXPECT_STR_SEQUENCE(buf, "=> repo%20with%20spaces/log.gmi repo with spaces");
+
+ free(buf);
+}
diff --git a/src/writer/gemini/repo_writer.c b/src/writer/gemini/repo_writer.c
@@ -0,0 +1,177 @@
+#include "writer/gemini/repo_writer.h"
+
+#include <assert.h>
+#include <err.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+
+#include "git/commit.h"
+#include "git/file.h"
+#include "git/reference.h"
+#include "utils.h"
+#include "writer/atom/atom.h"
+#include "writer/gemini/commit.h"
+#include "writer/gemini/fileblob.h"
+#include "writer/gemini/files.h"
+#include "writer/gemini/log.h"
+#include "writer/gemini/refs.h"
+
+struct GeminiRepoWriter {
+ const GitRepo* repo;
+ const FileSystem* fs;
+ GeminiRefs* refs;
+ GeminiLog* log;
+ Atom* atom;
+ FILE* atom_out;
+ Atom* tags;
+ FILE* tags_out;
+ GeminiFiles* files;
+};
+
+GeminiRepoWriter* gemini_repowriter_create(const GitRepo* repo,
+ const FileSystem* fs) {
+ assert(repo != NULL);
+ assert(fs != NULL);
+ GeminiRepoWriter* writer = ecalloc(1, sizeof(GeminiRepoWriter));
+ writer->repo = repo;
+ writer->fs = fs;
+ writer->refs = gemini_refs_create(repo, fs);
+ writer->log = gemini_log_create(repo, fs);
+ writer->atom_out = fs->fopen("atom.xml", "w");
+ if (!writer->atom_out) {
+ err(1, "fopen: atom.xml");
+ }
+ writer->atom = atom_create(repo, writer->atom_out);
+ writer->tags_out = fs->fopen("tags.xml", "w");
+ if (!writer->tags_out) {
+ err(1, "fopen: tags.xml");
+ }
+ writer->tags = atom_create(repo, writer->tags_out);
+ writer->files = gemini_files_create(repo, fs);
+ return writer;
+}
+
+void gemini_repowriter_free(GeminiRepoWriter* writer) {
+ if (!writer) {
+ return;
+ }
+ gemini_refs_free(writer->refs);
+ writer->refs = NULL;
+ gemini_log_free(writer->log);
+ writer->log = NULL;
+ atom_free(writer->atom);
+ writer->atom = NULL;
+ if (writer->atom_out) {
+ writer->fs->fclose(writer->atom_out);
+ writer->atom_out = NULL;
+ }
+ atom_free(writer->tags);
+ writer->tags = NULL;
+ if (writer->tags_out) {
+ writer->fs->fclose(writer->tags_out);
+ writer->tags_out = NULL;
+ }
+ gemini_files_free(writer->files);
+ writer->files = NULL;
+ free(writer);
+}
+
+void gemini_repowriter_set_log_cachefile(GeminiRepoWriter* writer,
+ const char* cachefile) {
+ assert(writer != NULL);
+ assert(cachefile != NULL);
+ gemini_log_set_cachefile(writer->log, cachefile);
+}
+
+void gemini_repowriter_set_log_commit_limit(GeminiRepoWriter* writer,
+ size_t count) {
+ assert(writer != NULL);
+ gemini_log_set_commit_limit(writer->log, count);
+}
+
+void gemini_repowriter_set_baseurl(GeminiRepoWriter* writer,
+ const char* baseurl) {
+ assert(writer != NULL);
+ assert(baseurl != NULL);
+ atom_set_baseurl(writer->atom, baseurl);
+ atom_set_baseurl(writer->tags, baseurl);
+}
+
+void gemini_repowriter_begin(GeminiRepoWriter* writer) {
+ assert(writer != NULL);
+ writer->fs->mkdir("commit", S_IRWXU | S_IRWXG | S_IRWXO);
+ writer->fs->mkdir("file", S_IRWXU | S_IRWXG | S_IRWXO);
+
+ gemini_refs_begin(writer->refs);
+ gemini_log_begin(writer->log);
+ atom_begin(writer->atom);
+ atom_begin(writer->tags);
+ gemini_files_begin(writer->files);
+}
+
+void gemini_repowriter_add_commit(GeminiRepoWriter* writer,
+ const GitCommit* git_commit) {
+ assert(writer != NULL);
+ assert(git_commit != NULL);
+ char filename[PATH_MAX];
+ int r = snprintf(filename, sizeof(filename), "%s.gmi", git_commit->oid);
+ if (r < 0 || (size_t)r >= sizeof(filename)) {
+ errx(1, "snprintf: filename truncated or error");
+ }
+ char* path = path_concat("commit", filename);
+ atom_add_commit(writer->atom, git_commit, path, "", "");
+ free(path);
+
+ if (gemini_log_can_add_commits(writer->log)) {
+ gemini_log_add_commit(writer->log, git_commit);
+ GeminiCommit* commit = gemini_commit_create(
+ writer->repo, writer->fs, git_commit->oid, git_commit->summary);
+ gemini_commit_begin(commit);
+ gemini_commit_add_commit(commit, git_commit);
+ gemini_commit_end(commit);
+ gemini_commit_free(commit);
+ }
+}
+
+void gemini_repowriter_add_reference(GeminiRepoWriter* writer,
+ const GitReference* ref) {
+ assert(writer != NULL);
+ assert(ref != NULL);
+ gemini_refs_add_ref(writer->refs, ref);
+ if (ref->type == kReftypeTag) {
+ GitCommit* commit = ref->commit;
+ char filename[PATH_MAX];
+ int r = snprintf(filename, sizeof(filename), "%s.gmi", commit->oid);
+ if (r < 0 || (size_t)r >= sizeof(filename)) {
+ errx(1, "snprintf: filename truncated or error");
+ }
+ char* path = path_concat("commit", filename);
+ atom_add_commit(writer->tags, commit, path, "", ref->shorthand);
+ free(path);
+ }
+}
+
+void gemini_repowriter_add_file(GeminiRepoWriter* writer, const GitFile* file) {
+ assert(writer != NULL);
+ assert(file != NULL);
+ gemini_files_add_file(writer->files, file);
+
+ GeminiFileBlob* blob =
+ gemini_fileblob_create(writer->repo, writer->fs, file->repo_path);
+ gemini_fileblob_begin(blob);
+ gemini_fileblob_add_file(blob, file);
+ gemini_fileblob_end(blob);
+ gemini_fileblob_free(blob);
+}
+
+void gemini_repowriter_end(GeminiRepoWriter* writer) {
+ assert(writer != NULL);
+ gemini_refs_end(writer->refs);
+ gemini_log_end(writer->log);
+ atom_end(writer->atom);
+ atom_end(writer->tags);
+ gemini_files_end(writer->files);
+}
diff --git a/src/writer/gemini/repo_writer.h b/src/writer/gemini/repo_writer.h
@@ -0,0 +1,31 @@
+#ifndef GOUT_WRITER_GEMINI_REPO_WRITER_H_
+#define GOUT_WRITER_GEMINI_REPO_WRITER_H_
+
+#include <stddef.h>
+
+#include "git/commit.h"
+#include "git/file.h"
+#include "git/reference.h"
+#include "git/repo.h"
+#include "utils.h"
+
+typedef struct GeminiRepoWriter GeminiRepoWriter;
+
+GeminiRepoWriter* gemini_repowriter_create(const GitRepo* repo,
+ const FileSystem* fs);
+void gemini_repowriter_free(GeminiRepoWriter* writer);
+void gemini_repowriter_set_log_cachefile(GeminiRepoWriter* writer,
+ const char* cachefile);
+void gemini_repowriter_set_log_commit_limit(GeminiRepoWriter* writer,
+ size_t count);
+void gemini_repowriter_set_baseurl(GeminiRepoWriter* writer,
+ const char* baseurl);
+void gemini_repowriter_begin(GeminiRepoWriter* writer);
+void gemini_repowriter_add_commit(GeminiRepoWriter* writer,
+ const GitCommit* commit);
+void gemini_repowriter_add_reference(GeminiRepoWriter* writer,
+ const GitReference* ref);
+void gemini_repowriter_add_file(GeminiRepoWriter* writer, const GitFile* file);
+void gemini_repowriter_end(GeminiRepoWriter* writer);
+
+#endif // GOUT_WRITER_GEMINI_REPO_WRITER_H_
diff --git a/src/writer/gemini/repo_writer_tests.c b/src/writer/gemini/repo_writer_tests.c
@@ -0,0 +1,59 @@
+#include "writer/gemini/repo_writer.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "fs_inmemory.h"
+#include "git/commit.h"
+#include "git/repo.h"
+#include "utest.h"
+
+struct gemini_repo_writer {
+ int dummy;
+};
+
+UTEST_F_SETUP(gemini_repo_writer) {
+ inmemory_fs_clear();
+}
+
+UTEST_F_TEARDOWN(gemini_repo_writer) {}
+
+UTEST_F(gemini_repo_writer, orchestration) {
+ GitRepo repo = {
+ .name = "test-repo.git",
+ .short_name = "test-repo",
+ .description = "A test repository",
+ .owner = "Test Owner",
+ .clone_url = "git://example.com/test-repo.git",
+ };
+ GeminiRepoWriter* writer = gemini_repowriter_create(&repo, g_fs_inmemory);
+ ASSERT_NE(NULL, writer);
+
+ GitCommit commit = {
+ .oid = "sha123",
+ .parentoid = "",
+ .summary = "Test commit",
+ .message = "Detailed message.",
+ .author_name = "User",
+ .author_email = "user@example.com",
+ };
+
+ gemini_repowriter_begin(writer);
+ gemini_repowriter_add_commit(writer, &commit);
+ gemini_repowriter_end(writer);
+ gemini_repowriter_free(writer);
+
+ /* Verify orchestration by checking multiple output files. */
+
+ /* 1. Log file exists and contains the commit message. */
+ const char* log_buf = inmemory_fs_get_buffer("log.gmi");
+ ASSERT_NE(NULL, log_buf);
+ EXPECT_NE(NULL, strstr(log_buf, "Test commit"));
+
+ /* 2. Individual commit page exists. */
+ const char* commit_buf = inmemory_fs_get_buffer("commit/sha123.gmi");
+ ASSERT_NE(NULL, commit_buf);
+ EXPECT_NE(NULL, strstr(commit_buf, "Test commit"));
+ EXPECT_NE(NULL, strstr(commit_buf, "sha123"));
+}
diff --git a/src/writer/index_writer.c b/src/writer/index_writer.c
@@ -5,6 +5,7 @@
#include <stdlib.h>
#include "utils.h"
+#include "writer/gemini/index_writer.h"
#include "writer/gopher/index_writer.h"
#include "writer/html/index_writer.h"
@@ -73,6 +74,30 @@ static const IndexWriterOps kGopherIndexWriterOps = {
.free = gopher_idx_free,
};
+static void gemini_idx_begin(void* impl) {
+ gemini_indexwriter_begin(impl);
+}
+
+static void gemini_idx_add_repo(void* impl, const GitRepo* repo) {
+ gemini_indexwriter_add_repo(impl, repo);
+}
+
+static void gemini_idx_end(void* impl) {
+ gemini_indexwriter_end(impl);
+}
+
+static void gemini_idx_free(void* impl) {
+ gemini_indexwriter_free(impl);
+}
+
+static const IndexWriterOps kGeminiIndexWriterOps = {
+ .set_me_url = NULL,
+ .begin = gemini_idx_begin,
+ .add_repo = gemini_idx_add_repo,
+ .end = gemini_idx_end,
+ .free = gemini_idx_free,
+};
+
IndexWriter* indexwriter_create(IndexWriterType type, FILE* out) {
assert(out != NULL);
IndexWriter* writer = ecalloc(1, sizeof(IndexWriter));
@@ -85,6 +110,10 @@ IndexWriter* indexwriter_create(IndexWriterType type, FILE* out) {
writer->ops = &kGopherIndexWriterOps;
writer->impl = gopher_indexwriter_create(out);
return writer;
+ case kIndexWriterTypeGemini:
+ writer->ops = &kGeminiIndexWriterOps;
+ writer->impl = gemini_indexwriter_create(out);
+ return writer;
}
free(writer);
errx(1, "unknown IndexWriterType %d", type);
diff --git a/src/writer/index_writer_tests.c b/src/writer/index_writer_tests.c
@@ -58,3 +58,27 @@ UTEST(index_writer, gopher) {
EXPECT_STR_SEQUENCE(buf, "Repositories", "test-repo", "test-desc");
free(buf);
}
+
+UTEST(index_writer, gemini) {
+ char* buf = NULL;
+ size_t size = 0;
+ FILE* out = open_memstream(&buf, &size);
+
+ IndexWriter* writer = indexwriter_create(kIndexWriterTypeGemini, out);
+ ASSERT_NE(NULL, writer);
+ indexwriter_set_me_url(writer, "https://me.example.com");
+
+ GitRepo repo = {.short_name = "test-repo",
+ .description = "test-desc",
+ .owner = "test-owner"};
+
+ indexwriter_begin(writer);
+ indexwriter_add_repo(writer, &repo);
+ indexwriter_end(writer);
+ indexwriter_free(writer);
+ fclose(out);
+
+ ASSERT_NE(NULL, buf);
+ EXPECT_STR_SEQUENCE(buf, "Repositories", "test-repo", "test-desc");
+ free(buf);
+}
diff --git a/src/writer/repo_writer.c b/src/writer/repo_writer.c
@@ -5,6 +5,7 @@
#include <stdlib.h>
#include "utils.h"
+#include "writer/gemini/repo_writer.h"
#include "writer/gopher/repo_writer.h"
#include "writer/html/repo_writer.h"
@@ -121,6 +122,54 @@ static const RepoWriterOps kGopherRepoWriterOps = {
.free = gopher_repo_free,
};
+static void gemini_repo_set_log_cachefile(void* impl, const char* cachefile) {
+ gemini_repowriter_set_log_cachefile(impl, cachefile);
+}
+
+static void gemini_repo_set_log_commit_limit(void* impl, size_t count) {
+ gemini_repowriter_set_log_commit_limit(impl, count);
+}
+
+static void gemini_repo_set_baseurl(void* impl, const char* baseurl) {
+ gemini_repowriter_set_baseurl(impl, baseurl);
+}
+
+static void gemini_repo_begin(void* impl) {
+ gemini_repowriter_begin(impl);
+}
+
+static void gemini_repo_add_commit(void* impl, const GitCommit* commit) {
+ gemini_repowriter_add_commit(impl, commit);
+}
+
+static void gemini_repo_add_reference(void* impl, const GitReference* ref) {
+ gemini_repowriter_add_reference(impl, ref);
+}
+
+static void gemini_repo_add_file(void* impl, const GitFile* file) {
+ gemini_repowriter_add_file(impl, file);
+}
+
+static void gemini_repo_end(void* impl) {
+ gemini_repowriter_end(impl);
+}
+
+static void gemini_repo_free(void* impl) {
+ gemini_repowriter_free(impl);
+}
+
+static const RepoWriterOps kGeminiRepoWriterOps = {
+ .set_log_cachefile = gemini_repo_set_log_cachefile,
+ .set_log_commit_limit = gemini_repo_set_log_commit_limit,
+ .set_baseurl = gemini_repo_set_baseurl,
+ .begin = gemini_repo_begin,
+ .add_commit = gemini_repo_add_commit,
+ .add_reference = gemini_repo_add_reference,
+ .add_file = gemini_repo_add_file,
+ .end = gemini_repo_end,
+ .free = gemini_repo_free,
+};
+
RepoWriter* repowriter_create(RepoWriterType type,
GitRepo* repo,
const FileSystem* fs) {
@@ -136,6 +185,10 @@ RepoWriter* repowriter_create(RepoWriterType type,
writer->ops = &kGopherRepoWriterOps;
writer->impl = gopher_repowriter_create(repo, fs);
return writer;
+ case kRepoWriterTypeGemini:
+ writer->ops = &kGeminiRepoWriterOps;
+ writer->impl = gemini_repowriter_create(repo, fs);
+ return writer;
}
free(writer);
errx(1, "unknown RepoWriterType %d", type);