aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat
-rw-r--r--.github/workflows/build.yml1+1 −0
-rw-r--r--Cargo.lock113+113 −0
-rw-r--r--Cargo.toml2+2 −0
-rw-r--r--Dockerfile7+6 −1
-rw-r--r--cgit/cgit.c206+96 −110
-rw-r--r--cgit/cgit.h32+0 −32
-rw-r--r--cgit/cmd.c131+11 −120
-rw-r--r--cgit/cmd.h3+1 −2
-rw-r--r--cgit/json.c118+0 −118
-rw-r--r--cgit/json.h16+0 −16
-rw-r--r--cgit/scan-tree.c210+0 −210
-rw-r--r--cgit/scan-tree.h5+0 −5
-rw-r--r--cgit/shared.c33+1 −32
-rw-r--r--cgit/ui-blame.c312+0 −312
-rw-r--r--cgit/ui-blame.h10+0 −10
-rw-r--r--cgit/ui-blob.c188+0 −188
-rw-r--r--cgit/ui-blob.h12+0 −12
-rw-r--r--cgit/ui-clone.c142+0 −142
-rw-r--r--cgit/ui-clone.h12+0 −12
-rw-r--r--cgit/ui-plain.c213+0 −213
-rw-r--r--cgit/ui-plain.h10+0 −10
-rw-r--r--cgit/ui-refs.c221+0 −221
-rw-r--r--cgit/ui-refs.h12+0 −12
-rw-r--r--cgit/ui-repolist.c480+0 −480
-rw-r--r--cgit/ui-repolist.h11+0 −11
-rw-r--r--cgit/ui-shared.h2+1 −1
-rw-r--r--cgit/ui-summary.c148+0 −148
-rw-r--r--cgit/ui-summary.h11+0 −11
-rw-r--r--cgit/ui-tag.c130+0 −130
-rw-r--r--cgit/ui-tag.h10+0 −10
-rw-r--r--cgit/ui-tree.c400+0 −400
-rw-r--r--cgit/ui-tree.h10+0 −10
-rw-r--r--crates/gilti-ssh/Cargo.toml1+1 −0
-rw-r--r--crates/gilti/Cargo.toml2+2 −0
-rw-r--r--crates/gilti/src/lfs.rs13+7 −6
-rw-r--r--crates/gilti/src/main.rs324+130 −194
-rw-r--r--crates/gilti/src/models/about.rs31+31 −0
-rw-r--r--crates/gilti/src/models/archive_signature.rs31+31 −0
-rw-r--r--crates/gilti/src/models/blame.rs114+114 −0
-rw-r--r--crates/gilti/src/models/mod.rs33+33 −0
-rw-r--r--crates/gilti/src/models/object.rs87+87 −0
-rw-r--r--crates/gilti/src/models/overview.rs122+122 −0
-rw-r--r--crates/gilti/src/models/refs.rs196+196 −0
-rw-r--r--crates/gilti/src/models/repositories.rs181+181 −0
-rw-r--r--crates/gilti/src/models/repository.rs81+81 −0
-rw-r--r--crates/gilti/src/models/revision.rs34+34 −0
-rw-r--r--crates/gilti/src/models/tag.rs112+112 −0
-rw-r--r--crates/gilti/src/models/tree.rs250+250 −0
-rw-r--r--crates/gilti/src/ui.rs296+0 −296
-rw-r--r--crates/gilti/src/views/about.rs36+36 −0
-rw-r--r--crates/gilti/src/views/archive_signature.rs53+53 −0
-rw-r--r--crates/gilti/src/views/blame.rs112+112 −0
-rw-r--r--crates/gilti/src/views/mod.rs39+39 −0
-rw-r--r--crates/gilti/src/views/object.rs52+52 −0
-rw-r--r--crates/gilti/src/views/overview.rs155+155 −0
-rw-r--r--crates/gilti/src/views/refs.rs87+87 −0
-rw-r--r--crates/gilti/src/views/repositories.rs170+170 −0
-rw-r--r--crates/gilti/src/views/shared.rs126+126 −0
-rw-r--r--crates/gilti/src/views/tag.rs76+76 −0
-rw-r--r--crates/gilti/src/views/tree.rs293+293 −0
60 files changed, 2843 insertions, 3475 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index b388d20..9253edd 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -81,6 +81,7 @@ jobs:
apk add --no-cache \
build-base=0.5-r3 \
cargo=1.87.0-r1 \
+ cmake \
rust=1.87.0-r1 \
git=2.49.1-r0 \
python3 \
diff --git a/Cargo.lock b/Cargo.lock
index 542fa41..0e053dd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -55,6 +55,12 @@ dependencies = [
]
[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -70,6 +76,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
+name = "cc"
+version = "1.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -115,6 +133,12 @@ dependencies = [
]
[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
+[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -158,10 +182,23 @@ dependencies = [
]
[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
name = "gilti"
version = "0.1.0"
dependencies = [
"axum",
+ "git2",
+ "libc",
"maud",
"percent-encoding",
"serde",
@@ -179,6 +216,18 @@ dependencies = [
]
[[package]]
+name = "git2"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e"
+dependencies = [
+ "bitflags",
+ "libc",
+ "libgit2-sys",
+ "log",
+]
+
+[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -265,12 +314,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
+name = "jobserver"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom",
+ "libc",
+]
+
+[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
+name = "libgit2-sys"
+version = "0.18.8+1.9.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50"
+dependencies = [
+ "cc",
+ "libc",
+ "libz-sys",
+ "pkg-config",
+]
+
+[[package]]
+name = "libz-sys"
+version = "1.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -334,6 +423,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -364,6 +459,12 @@ dependencies = [
]
[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -418,6 +519,12 @@ dependencies = [
]
[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -544,6 +651,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 14294b4..9cedeae 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,6 +8,7 @@ resolver = "3"
[workspace.package]
publish = false
edition = "2024"
+rust-version = "1.87"
version = "0.1.0"
license = "AGPL-3.0-or-later"
authors = ["Nikolay Govorov <me@govorov.online>"]
@@ -16,6 +17,7 @@ repository = "https://github.com/dimidiumlabs/gilti"
[workspace.dependencies]
axum = { version = "0.8.4", default-features = false, features = ["http1", "tokio"] }
+git2 = { version = "0.21.0", default-features = false, features = ["unstable-sha256", "vendored-libgit2"] }
libc = "0.2.172"
percent-encoding = "2.3.2"
tokio = { version = "1.44.2", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal"] }
diff --git a/Dockerfile b/Dockerfile
index bd6957a..9db5a5b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,12 +19,17 @@ LABEL org.opencontainers.image.title="Gilti" \
org.opencontainers.image.licenses="AGPL-3.0-or-later"
RUN apk add --no-cache \
+ bzip2=1.0.8-r6 \
git=2.49.1-r0 \
+ git-daemon=2.49.1-r0 \
libgcc=14.2.0-r6 \
+ lzip=1.25-r0 \
openssh-keygen=10.0_p1-r10 \
openssh-server=10.0_p1-r10 \
su-exec=0.2-r3 \
- tini=0.19.0-r3 && \
+ tini=0.19.0-r3 \
+ xz=5.8.1-r0 \
+ zstd=1.5.7-r0 && \
addgroup -S -g 10000 git && \
install -d -m 0755 -o root -g root /var/lib/gilti && \
adduser -S -D -u 10000 -G git -h /var/lib/gilti/git -s /bin/sh git && \
diff --git a/cgit/cgit.c b/cgit/cgit.c
index dbf8a0f..344ff55 100644
--- a/cgit/cgit.c
+++ b/cgit/cgit.c
@@ -17,9 +17,6 @@
#include "cmd.h"
#include "html.h"
#include "ui-shared.h"
-#include "ui-blob.h"
-#include "ui-summary.h"
-#include "scan-tree.h"
const char *cgit_version = CGIT_VERSION;
@@ -93,26 +90,17 @@ static void prepare_context(void)
const char *value;
memset(&ctx, 0, sizeof(ctx));
- ctx.cfg.agefile = config_string("CGIT_AGEFILE");
- ctx.cfg.branch_sort = config_integer("CGIT_BRANCH_SORT");
- ctx.cfg.case_sensitive_sort = config_integer("CGIT_CASE_SENSITIVE_SORT");
ctx.cfg.clone_prefix = config_optional_string("CGIT_CLONE_PREFIX");
ctx.cfg.clone_url = config_optional_string("CGIT_CLONE_URL");
ctx.cfg.commit_sort = config_integer("CGIT_COMMIT_SORT");
ctx.cfg.difftype = config_integer("CGIT_DIFFTYPE");
ctx.cfg.embedded = config_integer("CGIT_EMBEDDED");
- ctx.cfg.enable_blame = config_integer("CGIT_ENABLE_BLAME");
ctx.cfg.enable_commit_graph = config_integer("CGIT_ENABLE_COMMIT_GRAPH");
ctx.cfg.enable_follow_links = config_integer("CGIT_ENABLE_FOLLOW_LINKS");
- ctx.cfg.enable_html_serving = config_integer("CGIT_ENABLE_HTML_SERVING");
- ctx.cfg.enable_http_clone = config_integer("CGIT_ENABLE_HTTP_CLONE");
- ctx.cfg.enable_index_links = config_integer("CGIT_ENABLE_INDEX_LINKS");
- ctx.cfg.enable_index_owner = config_integer("CGIT_ENABLE_INDEX_OWNER");
ctx.cfg.enable_log_filecount = config_integer("CGIT_ENABLE_LOG_FILECOUNT");
ctx.cfg.enable_log_linecount = config_integer("CGIT_ENABLE_LOG_LINECOUNT");
ctx.cfg.enable_remote_branches = config_integer("CGIT_ENABLE_REMOTE_BRANCHES");
ctx.cfg.enable_subject_links = config_integer("CGIT_ENABLE_SUBJECT_LINKS");
- ctx.cfg.enable_tree_linenumbers = config_integer("CGIT_ENABLE_TREE_LINENUMBERS");
ctx.cfg.favicon = config_string("CGIT_FAVICON");
ctx.cfg.footer = config_optional_string("CGIT_FOOTER");
ctx.cfg.head_include = config_optional_string("CGIT_HEAD_INCLUDE");
@@ -121,34 +109,21 @@ static void prepare_context(void)
ctx.cfg.logo = config_string("CGIT_LOGO");
ctx.cfg.logo_link = config_optional_string("CGIT_LOGO_LINK");
ctx.cfg.max_atom_items = config_integer("CGIT_MAX_ATOM_ITEMS");
- ctx.cfg.max_blob_size = config_integer("CGIT_MAX_BLOB_SIZE");
ctx.cfg.max_commit_count = config_integer("CGIT_MAX_COMMIT_COUNT");
ctx.cfg.max_msg_len = config_integer("CGIT_MAX_MESSAGE_LENGTH");
- ctx.cfg.max_repo_count = config_integer("CGIT_MAX_REPO_COUNT");
- ctx.cfg.max_repodesc_len = config_integer("CGIT_MAX_REPODESC_LENGTH");
ctx.cfg.max_stats = config_integer("CGIT_MAX_STATS");
ctx.cfg.mimetype_file = config_optional_string("CGIT_MIMETYPE_FILE");
ctx.cfg.module_link = config_optional_string("CGIT_MODULE_LINK");
ctx.cfg.noheader = config_integer("CGIT_NOHEADER");
ctx.cfg.noplainemail = config_integer("CGIT_NOPLAINEMAIL");
- ctx.cfg.remove_suffix = config_integer("CGIT_REMOVE_SUFFIX");
cgit_default_repo_desc = config_string("CGIT_REPO_DEFAULT_DESC");
ctx.cfg.renamelimit = config_integer("CGIT_RENAMELIMIT");
- ctx.cfg.repository_sort = config_string("CGIT_REPOSITORY_SORT");
ctx.cfg.robots = config_string("CGIT_ROBOTS");
ctx.cfg.root_desc = config_string("CGIT_ROOT_DESC");
- ctx.cfg.root_readme = config_optional_string("CGIT_ROOT_README");
ctx.cfg.root_title = config_string("CGIT_ROOT_TITLE");
- ctx.cfg.scan_hidden_path = config_integer("CGIT_SCAN_HIDDEN_PATH");
ctx.cfg.script_name = config_string("SCRIPT_NAME");
ctx.cfg.section = config_string("CGIT_SECTION");
- ctx.cfg.section_from_path = config_integer("CGIT_SECTION_FROM_PATH");
- ctx.cfg.section_sort = config_integer("CGIT_SECTION_SORT");
ctx.cfg.snapshots = config_integer("CGIT_SNAPSHOTS");
- ctx.cfg.strict_export = config_optional_string("CGIT_STRICT_EXPORT");
- ctx.cfg.summary_branches = config_integer("CGIT_SUMMARY_BRANCHES");
- ctx.cfg.summary_log = config_integer("CGIT_SUMMARY_LOG");
- ctx.cfg.summary_tags = config_integer("CGIT_SUMMARY_TAGS");
ctx.cfg.virtual_root = ensure_end(config_value("CGIT_VIRTUAL_ROOT"), '/');
string_list_init_dup(&ctx.cfg.css);
string_list_init_dup(&ctx.cfg.js);
@@ -206,8 +181,30 @@ static void prepare_request(void)
ctx.qry.difftype = request_optional_integer("GILTI_QUERY_DIFFTYPE");
ctx.qry.has_difftype = 1;
}
- if (ctx.qry.repo)
- ctx.repo = cgit_get_repoinfo(ctx.qry.repo);
+}
+
+static void prepare_repository(void)
+{
+ char *description = NULL;
+ char *description_path;
+ char *separator;
+ struct passwd *owner;
+ struct stat stat;
+ size_t size;
+
+ ctx.repo = cgit_add_repo(ctx.qry.repo);
+ ctx.repo->path = config_string("GILTI_REPOSITORY_PATH");
+ description_path = fmtalloc("%s/description", ctx.repo->path);
+ if (!read_first_line(description_path, &description, &size))
+ ctx.repo->desc = description;
+ free(description_path);
+ if (!lstat(ctx.repo->path, &stat) && (owner = getpwuid(stat.st_uid))) {
+ ctx.repo->owner = strdup_first_line(owner->pw_gecos && *owner->pw_gecos ?
+ owner->pw_gecos : owner->pw_name);
+ separator = strchr(ctx.repo->owner, ',');
+ if (separator)
+ *separator = '\0';
+ }
}
static char *guess_defbranch(void)
@@ -222,83 +219,93 @@ static char *guess_defbranch(void)
return xstrdup(ref);
}
-/* The caller must free filename and ref after calling this. */
-static inline void parse_readme(const char *readme, char **filename, char **ref, struct cgit_repo *repo)
+struct walk_tree_context {
+ const char *match_path;
+ unsigned int found_path:1;
+};
+
+static int find_path(const struct object_id *oid UNUSED, struct strbuf *base,
+ const char *pathname, unsigned mode, void *cbdata)
+{
+ struct walk_tree_context *walk_tree_ctx = cbdata;
+
+ if (!S_ISREG(mode))
+ return READ_TREE_RECURSIVE;
+ if (strncmp(base->buf, walk_tree_ctx->match_path, base->len) ||
+ strcmp(walk_tree_ctx->match_path + base->len, pathname))
+ return READ_TREE_RECURSIVE;
+ walk_tree_ctx->found_path = 1;
+ return 0;
+}
+
+static int ref_path_exists(const char *path, const char *ref)
+{
+ struct object_id oid;
+ unsigned long size;
+ struct pathspec_item path_item = {
+ .match = xstrdup(path),
+ .len = strlen(path)
+ };
+ struct pathspec paths = {
+ .nr = 1,
+ .items = &path_item
+ };
+ struct walk_tree_context walk_tree_ctx = {
+ .match_path = path,
+ .found_path = 0
+ };
+
+ if (!repo_get_oid(the_repository, ref, &oid) &&
+ odb_read_object_info(the_repository->objects, &oid, &size) == OBJ_COMMIT)
+ read_tree(the_repository,
+ repo_get_commit_tree(the_repository,
+ lookup_commit_reference(the_repository, &oid)),
+ &paths, find_path, &walk_tree_ctx);
+ free(path_item.match);
+ return walk_tree_ctx.found_path;
+}
+
+static void parse_readme(const char *readme, char **filename, char **ref)
{
const char *colon;
*filename = NULL;
*ref = NULL;
-
- if (!readme || !readme[0])
+ if (!readme || !*readme)
return;
-
- /* Check if the readme is tracked in the git repo. */
colon = strchr(readme, ':');
- if (colon && strlen(colon) > 1) {
- /* If it starts with a colon, we want to use head given
- * from query or the default branch */
- if (colon == readme && ctx.qry.head)
- *ref = xstrdup(ctx.qry.head);
- else if (colon == readme && repo->defbranch)
- *ref = xstrdup(repo->defbranch);
+ if (colon && colon[1]) {
+ if (colon == readme)
+ *ref = xstrdup(ctx.qry.head ? ctx.qry.head : ctx.repo->defbranch);
else
*ref = xstrndup(readme, colon - readme);
readme = colon + 1;
}
-
- /* Prepend repo path to relative readme path unless tracked. */
- if (!(*ref) && readme[0] != '/')
- *filename = fmtalloc("%s/%s", repo->path, readme);
- else
- *filename = xstrdup(readme);
+ *filename = *ref || readme[0] == '/' ? xstrdup(readme) :
+ fmtalloc("%s/%s", ctx.repo->path, readme);
}
-static void choose_readme(struct cgit_repo *repo)
+
+static void choose_readme(void)
{
- int found;
char *filename, *ref;
struct string_list_item *entry;
- if (!repo->readme.nr)
- return;
-
- found = 0;
- for_each_string_list_item(entry, &repo->readme) {
- parse_readme(entry->string, &filename, &ref, repo);
- if (!filename) {
- free(filename);
- free(ref);
- continue;
- }
- if (ref) {
- if (cgit_ref_path_exists(filename, ref, 1)) {
- found = 1;
- break;
- }
- }
- else if (!access(filename, R_OK)) {
- found = 1;
- break;
+ for_each_string_list_item(entry, &ctx.repo->readme) {
+ parse_readme(entry->string, &filename, &ref);
+ if ((ref && ref_path_exists(filename, ref)) ||
+ (!ref && !access(filename, R_OK))) {
+ ctx.repo->readme.strdup_strings = 1;
+ string_list_clear(&ctx.repo->readme, 0);
+ ctx.repo->readme.strdup_strings = 0;
+ string_list_append(&ctx.repo->readme, filename)->util = ref;
+ return;
}
free(filename);
free(ref);
}
- repo->readme.strdup_strings = 1;
- string_list_clear(&repo->readme, 0);
- repo->readme.strdup_strings = 0;
- if (found)
- string_list_append(&repo->readme, filename)->util = ref;
-}
-
-static void print_no_repo_clone_urls(const char *url)
-{
- html("<tr><td><a rel='vcs-git' href='");
- html_attr(url);
- html("' title='");
- html_attr(ctx.repo->name);
- html(" Git repository'>");
- html_txt(url);
- html("</a></td></tr>\n");
+ ctx.repo->readme.strdup_strings = 1;
+ string_list_clear(&ctx.repo->readme, 0);
+ ctx.repo->readme.strdup_strings = 0;
}
static void prepare_repo_env(int *nongit)
@@ -341,25 +348,13 @@ static int prepare_repo_cmd(int nongit)
ctx.qry.head = xstrdup("HEAD");
if (repo_get_oid(the_repository, ctx.qry.head, &oid)) {
- if (strcmp(ctx.qry.page, "summary")) {
- cgit_print_error_page(404, "Not found",
- "Invalid revision: %s", ctx.qry.head);
- return 1;
- }
- cgit_print_http_headers();
- cgit_print_docstart();
- cgit_print_pageheader();
- cgit_print_error("Repository seems to be empty");
- html("<table class='list'><tr class='nohover'><td>&nbsp;</td></tr><tr class='nohover'><th class='left'>Clone</th></tr>\n");
- cgit_prepare_repo_env(ctx.repo);
- cgit_add_clone_urls(print_no_repo_clone_urls);
- html("</table>\n");
- cgit_print_docend();
+ cgit_print_error_page(404, "Not found",
+ "Invalid revision: %s", ctx.qry.head);
return 1;
}
string_list_sort(&ctx.repo->submodules);
cgit_prepare_repo_env(ctx.repo);
- choose_readme(ctx.repo);
+ choose_readme();
return 0;
}
@@ -383,12 +378,6 @@ static void process_request(void)
return;
}
- if (!ctx.cfg.enable_http_clone && cmd->is_clone) {
- ctx.page.title = "cgit error";
- cgit_print_error_page(404, "Not found", "Invalid request");
- return;
- }
-
if (cmd->want_repo && !ctx.repo) {
cgit_print_error_page(400, "Bad request",
"No repository selected");
@@ -418,12 +407,9 @@ int cmd_main(int argc UNUSED, const char **argv UNUSED)
set_die_routine(cgit_die_routine);
prepare_context();
- cgit_repolist.length = 0;
- cgit_repolist.count = 0;
- cgit_repolist.repos = NULL;
-
- scan_tree(config_value("CGIT_SCAN_PATH"));
prepare_request();
+ if (ctx.qry.repo)
+ prepare_repository();
process_request();
return 0;
}
diff --git a/cgit/cgit.h b/cgit/cgit.h
index 818970f..fedec59 100644
--- a/cgit/cgit.h
+++ b/cgit/cgit.h
@@ -90,16 +90,13 @@ struct cgit_repo {
char *logo_link;
char *snapshot_prefix;
int snapshots;
- int enable_blame;
int enable_commit_graph;
int enable_follow_links;
int enable_log_filecount;
int enable_log_linecount;
int enable_remote_branches;
int enable_subject_links;
- int enable_html_serving;
int max_stats;
- int branch_sort;
int commit_sort;
time_t mtime;
struct string_list submodules;
@@ -107,12 +104,6 @@ struct cgit_repo {
int ignore;
};
-struct cgit_repolist {
- int length;
- int count;
- struct cgit_repo *repos;
-};
-
struct commitinfo {
struct commit *commit;
char *author;
@@ -178,7 +169,6 @@ struct cgit_query {
};
struct cgit_config {
- char *agefile;
char *clone_prefix;
char *clone_url;
char *favicon;
@@ -197,44 +187,24 @@ struct cgit_config {
char *root_readme;
char *script_name;
char *section;
- char *repository_sort;
char *virtual_root; /* Always ends with '/'. */
- char *strict_export;
- int case_sensitive_sort;
int embedded;
int enable_follow_links;
- int enable_http_clone;
- int enable_index_links;
- int enable_index_owner;
- int enable_blame;
int enable_commit_graph;
int enable_log_filecount;
int enable_log_linecount;
int enable_remote_branches;
int enable_subject_links;
- int enable_html_serving;
- int enable_tree_linenumbers;
int local_time;
int max_atom_items;
- int max_repo_count;
int max_commit_count;
int max_msg_len;
- int max_repodesc_len;
- int max_blob_size;
int max_stats;
int noplainemail;
int noheader;
int renamelimit;
- int remove_suffix;
- int scan_hidden_path;
- int section_from_path;
int snapshots;
- int section_sort;
- int summary_branches;
- int summary_log;
- int summary_tags;
diff_type difftype;
- int branch_sort;
int commit_sort;
struct string_list mimetypes;
struct string_list js;
@@ -280,13 +250,11 @@ struct cgit_snapshot_format {
extern const char *cgit_version;
-extern struct cgit_repolist cgit_repolist;
extern struct cgit_context ctx;
extern const struct cgit_snapshot_format cgit_snapshot_formats[];
extern char *cgit_default_repo_desc;
extern struct cgit_repo *cgit_add_repo(const char *url);
-extern struct cgit_repo *cgit_get_repoinfo(const char *url);
extern int chk_zero(int result, char *msg);
extern int chk_positive(int result, char *msg);
diff --git a/cgit/cmd.c b/cgit/cmd.c
index 43a9036..024b115 100644
--- a/cgit/cmd.c
+++ b/cgit/cmd.c
@@ -15,70 +15,21 @@
#include "cmd.h"
#include "ui-shared.h"
#include "ui-atom.h"
-#include "ui-blame.h"
-#include "ui-blob.h"
-#include "ui-clone.h"
#include "ui-commit.h"
#include "ui-diff.h"
#include "ui-log.h"
#include "ui-patch.h"
-#include "ui-plain.h"
-#include "ui-refs.h"
-#include "ui-repolist.h"
#include "ui-snapshot.h"
#include "ui-stats.h"
-#include "ui-summary.h"
-#include "ui-tag.h"
-#include "ui-tree.h"
-
-static void HEAD_fn(void)
-{
- cgit_clone_head();
-}
static void atom_fn(void)
{
cgit_print_atom(ctx.qry.head, ctx.qry.path, ctx.cfg.max_atom_items);
}
-static void about_fn(void)
-{
- if (!ctx.repo) {
- cgit_print_site_readme();
- return;
- }
- if (ctx.repo->readme.nr)
- cgit_print_repo_readme(NULL);
- else if (ctx.repo->homepage)
- cgit_redirect(ctx.repo->homepage, false);
- else
- cgit_print_error_page(404, "Not found", "No repository documentation");
-}
-
-static void blame_fn(void)
-{
- if (ctx.repo->enable_blame)
- cgit_print_blame();
- else
- cgit_print_error_page(403, "Forbidden", "Blame is disabled");
-}
-
-static void blob_fn(void)
-{
- cgit_print_blob(ctx.qry.oid, ctx.qry.path, ctx.qry.head, 0);
-}
-
-static void commit_fn(void)
-{
- cgit_print_commit(ctx.qry.oid, ctx.qry.path);
-}
-
static void revision_fn(void)
{
- if (ctx.qry.oid && starts_with(ctx.qry.oid, "refs/tags/"))
- cgit_print_tag(ctx.qry.oid);
- else
- cgit_print_commit(ctx.qry.oid, NULL);
+ cgit_print_commit(ctx.qry.oid, NULL);
}
static void diff_fn(void)
@@ -91,11 +42,6 @@ static void rawdiff_fn(void)
cgit_print_diff(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path, 1, 1);
}
-static void info_fn(void)
-{
- cgit_clone_info();
-}
-
static void log_fn(void)
{
cgit_print_log(ctx.qry.oid, ctx.qry.ofs, ctx.cfg.max_commit_count,
@@ -104,31 +50,11 @@ static void log_fn(void)
ctx.repo->commit_sort);
}
-static void objects_fn(void)
-{
- cgit_clone_objects();
-}
-
-static void repolist_fn(void)
-{
- cgit_print_repolist();
-}
-
static void patch_fn(void)
{
cgit_print_patch(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path);
}
-static void plain_fn(void)
-{
- cgit_print_plain();
-}
-
-static void refs_fn(void)
-{
- cgit_print_refs();
-}
-
static void snapshot_fn(void)
{
char *filename;
@@ -148,58 +74,23 @@ static void stats_fn(void)
cgit_show_stats();
}
-static void summary_fn(void)
-{
- cgit_print_summary();
-}
-
-static void tag_fn(void)
-{
- cgit_print_tag(ctx.qry.oid);
-}
-
-static void tree_fn(void)
-{
- cgit_print_tree(ctx.qry.oid, ctx.qry.path);
-}
-
-#define def_cmd(name, want_repo, want_vpath, is_clone) \
- {#name, name##_fn, want_repo, want_vpath, is_clone}
+#define def_cmd(name, want_repo, want_vpath) \
+ {#name, name##_fn, want_repo, want_vpath}
struct cgit_cmd *cgit_get_cmd(void)
{
static struct cgit_cmd cmds[] = {
- def_cmd(HEAD, 1, 0, 1),
- def_cmd(atom, 1, 0, 0),
- def_cmd(about, 0, 0, 0),
- def_cmd(blame, 1, 1, 0),
- def_cmd(blob, 1, 0, 0),
- def_cmd(commit, 1, 1, 0),
- def_cmd(diff, 1, 1, 0),
- def_cmd(info, 1, 0, 1),
- def_cmd(log, 1, 1, 0),
- def_cmd(objects, 1, 0, 1),
- def_cmd(patch, 1, 1, 0),
- def_cmd(plain, 1, 0, 0),
- def_cmd(rawdiff, 1, 1, 0),
- def_cmd(refs, 1, 0, 0),
- def_cmd(revision, 1, 0, 0),
- def_cmd(repolist, 0, 0, 0),
- def_cmd(snapshot, 1, 0, 0),
- def_cmd(stats, 1, 1, 0),
- def_cmd(summary, 1, 0, 0),
- def_cmd(tag, 1, 0, 0),
- def_cmd(tree, 1, 1, 0),
+ def_cmd(atom, 1, 0),
+ def_cmd(diff, 1, 1),
+ def_cmd(log, 1, 1),
+ def_cmd(patch, 1, 1),
+ def_cmd(rawdiff, 1, 1),
+ def_cmd(revision, 1, 0),
+ def_cmd(snapshot, 1, 0),
+ def_cmd(stats, 1, 1),
};
int i;
- if (ctx.qry.page == NULL) {
- if (ctx.repo)
- ctx.qry.page = "summary";
- else
- ctx.qry.page = "repolist";
- }
-
for (i = 0; i < sizeof(cmds)/sizeof(*cmds); i++)
if (!strcmp(ctx.qry.page, cmds[i].name))
return &cmds[i];
diff --git a/cgit/cmd.h b/cgit/cmd.h
index 24377e8..21c1798 100644
--- a/cgit/cmd.h
+++ b/cgit/cmd.h
@@ -11,8 +11,7 @@ struct cgit_cmd {
const char *name;
cgit_cmd_fn fn;
unsigned int want_repo:1,
- want_vpath:1,
- is_clone:1;
+ want_vpath:1;
};
extern struct cgit_cmd *cgit_get_cmd(void);
diff --git a/cgit/json.c b/cgit/json.c
deleted file mode 100644
--- a/cgit/json.c
+++ /dev/null
@@ -1,118 +0,0 @@
-/* SPDX-FileCopyrightText: 2026 Nikolay Govorov */
-/* SPDX-License-Identifier: GPL-2.0-only */
-
-#include "json.h"
-#include "cgit.h"
-
-void json(const char *raw) {
- if (write(STDOUT_FILENO, raw, strlen(raw)) != strlen(raw))
- die_errno("write error on json output");
-}
-
-void jsonf(const char *format, ...) {
- va_list args;
- struct strbuf buf = STRBUF_INIT;
-
- va_start(args, format);
- strbuf_vaddf(&buf, format, args);
- va_end(args);
- json(buf.buf);
- strbuf_release(&buf);
-}
-
-static int utf8_len(const unsigned char *p) {
- int len;
-
- if (p[0] < 0x80) {
- return 1;
- }
-
- if (p[0] >= 0xc2 && p[0] <= 0xdf) {
- len = 2;
- } else if (p[0] >= 0xe0 && p[0] <= 0xef) {
- len = 3;
- } else if (p[0] >= 0xf0 && p[0] <= 0xf4) {
- len = 4;
- } else {
- return 0;
- }
-
- if (!p[1] || (len > 2 && !p[2]) || (len > 3 && !p[3])) {
- return 0;
- }
-
- if ((p[1] & 0xc0) != 0x80 || (len > 2 && (p[2] & 0xc0) != 0x80) ||
- (len > 3 && (p[3] & 0xc0) != 0x80)) {
- return 0;
- }
-
- if ((p[0] == 0xe0 && p[1] < 0xa0) || (p[0] == 0xed && p[1] > 0x9f) ||
- (p[0] == 0xf0 && p[1] < 0x90) || (p[0] == 0xf4 && p[1] > 0x8f)) {
- return 0;
- }
-
- return len;
-}
-
-void json_value(const char *value) {
- const unsigned char *p = (const unsigned char *)(value ? value : "");
-
- json("\"");
- while (*p) {
- int len;
- switch (*p) {
- case '\"':
- json("\\\"");
- p++;
- break;
- case '\\':
- json("\\\\");
- p++;
- break;
- case '\b':
- json("\\b");
- p++;
- break;
- case '\f':
- json("\\f");
- p++;
- break;
- case '\n':
- json("\\n");
- p++;
- break;
- case '\r':
- json("\\r");
- p++;
- break;
- case '\t':
- json("\\t");
- p++;
- break;
- default:
- if (*p < 0x20) {
- jsonf("\\u%04x", *p++);
- } else if (*p < 0x80) {
- char ch[] = {*p++, '\0'};
- json(ch);
- } else if ((len = utf8_len(p))) {
- if (write(STDOUT_FILENO, p, len) != len)
- die_errno("write error on json output");
- p += len;
- } else {
- json("\xef\xbf\xbd");
- p++;
- }
- }
- }
- json("\"");
-}
-
-void json_key(const char *key) {
- json_value(key);
- json(":");
-}
-
-void json_int(intmax_t value) { jsonf("%" PRIdMAX, value); }
-
-void json_bool(int value) { json(value ? "true" : "false"); }
diff --git a/cgit/json.h b/cgit/json.h
deleted file mode 100644
--- a/cgit/json.h
+++ /dev/null
@@ -1,16 +0,0 @@
-/* SPDX-FileCopyrightText: 2026 Nikolay Govorov */
-/* SPDX-License-Identifier: GPL-2.0-only */
-
-#ifndef JSON_H
-#define JSON_H
-
-#include "cgit.h"
-
-extern void json(const char *raw);
-extern void jsonf(const char *format, ...);
-extern void json_key(const char *key);
-extern void json_value(const char *value);
-extern void json_int(intmax_t value);
-extern void json_bool(int value);
-
-#endif
diff --git a/cgit/scan-tree.c b/cgit/scan-tree.c
deleted file mode 100644
--- a/cgit/scan-tree.c
+++ /dev/null
@@ -1,210 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-/* scan-tree.c
- *
- * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
- *
- * Licensed under GNU General Public License v2
- * (see COPYING for full license text)
- */
-
-#include "cgit.h"
-#include "scan-tree.h"
-#include "html.h"
-
-/* return 1 if path contains a objects/ directory and a HEAD file */
-static int is_git_dir(const char *path)
-{
- struct stat st;
- struct strbuf pathbuf = STRBUF_INIT;
- int result = 0;
-
- strbuf_addf(&pathbuf, "%s/objects", path);
- if (stat(pathbuf.buf, &st)) {
- if (errno != ENOENT)
- fprintf(stderr, "Error checking path %s: %s (%d)\n",
- path, strerror(errno), errno);
- goto out;
- }
- if (!S_ISDIR(st.st_mode))
- goto out;
-
- strbuf_reset(&pathbuf);
- strbuf_addf(&pathbuf, "%s/HEAD", path);
- if (stat(pathbuf.buf, &st)) {
- if (errno != ENOENT)
- fprintf(stderr, "Error checking path %s: %s (%d)\n",
- path, strerror(errno), errno);
- goto out;
- }
- if (!S_ISREG(st.st_mode))
- goto out;
-
- result = 1;
-out:
- strbuf_release(&pathbuf);
- return result;
-}
-
-static char *xstrrchr(char *s, char *from, int c)
-{
- while (from >= s && *from != c)
- from--;
- return from < s ? NULL : from;
-}
-
-static void add_repo(const char *base, struct strbuf *path)
-{
- struct stat st;
- struct passwd *pwd;
- struct cgit_repo *repo;
- size_t pathlen;
- struct strbuf rel = STRBUF_INIT;
- char *p, *slash;
- int n;
- size_t size;
-
- if (stat(path->buf, &st)) {
- fprintf(stderr, "Error accessing %s: %s (%d)\n",
- path->buf, strerror(errno), errno);
- return;
- }
-
- strbuf_addch(path, '/');
- pathlen = path->len;
-
- if (ctx.cfg.strict_export) {
- strbuf_addstr(path, ctx.cfg.strict_export);
- if(stat(path->buf, &st))
- return;
- strbuf_setlen(path, pathlen);
- }
-
- strbuf_addstr(path, "noweb");
- if (!stat(path->buf, &st))
- return;
- strbuf_setlen(path, pathlen);
-
- if (!starts_with(path->buf, base))
- strbuf_addbuf(&rel, path);
- else
- strbuf_addstr(&rel, path->buf + strlen(base) + 1);
-
- if (!strcmp(rel.buf + rel.len - 5, "/.git"))
- strbuf_setlen(&rel, rel.len - 5);
- else if (rel.len && rel.buf[rel.len - 1] == '/')
- strbuf_setlen(&rel, rel.len - 1);
-
- repo = cgit_add_repo(rel.buf);
-
- if (ctx.cfg.remove_suffix) {
- size_t urllen;
- strip_suffix(repo->url, ".git", &urllen);
- strip_suffix_mem(repo->url, &urllen, "/");
- repo->url[urllen] = '\0';
- }
- repo->path = strdup_first_line(path->buf);
- while (!repo->owner) {
- if ((pwd = getpwuid(st.st_uid)) == NULL) {
- fprintf(stderr, "Error reading owner-info for %s: %s (%d)\n",
- path->buf, strerror(errno), errno);
- break;
- }
- if (pwd->pw_gecos)
- if ((p = strchr(pwd->pw_gecos, ',')))
- *p = '\0';
- repo->owner = strdup_first_line(pwd->pw_gecos ? pwd->pw_gecos : pwd->pw_name);
- }
-
- if (repo->desc == cgit_default_repo_desc || !repo->desc) {
- strbuf_addstr(path, "description");
- if (!stat(path->buf, &st))
- read_first_line(path->buf, &repo->desc, &size);
- strbuf_setlen(path, pathlen);
- }
-
- if (ctx.cfg.section_from_path) {
- n = ctx.cfg.section_from_path;
- if (n > 0) {
- slash = rel.buf - 1;
- while (slash && n && (slash = strchr(slash + 1, '/')))
- n--;
- } else {
- slash = rel.buf + rel.len;
- while (slash && n && (slash = xstrrchr(rel.buf, slash - 1, '/')))
- n++;
- }
- if (slash && !n) {
- *slash = '\0';
- repo->section = strdup_first_line(rel.buf);
- *slash = '/';
- if (starts_with(repo->name, repo->section)) {
- repo->name += strlen(repo->section);
- if (*repo->name == '/')
- repo->name++;
- }
- }
- }
-
- strbuf_release(&rel);
-}
-
-static void scan_path(const char *base, const char *path)
-{
- DIR *dir = opendir(path);
- struct dirent *ent;
- struct strbuf pathbuf = STRBUF_INIT;
- size_t pathlen = strlen(path);
- struct stat st;
-
- if (!dir) {
- fprintf(stderr, "Error opening directory %s: %s (%d)\n",
- path, strerror(errno), errno);
- return;
- }
-
- strbuf_add(&pathbuf, path, strlen(path));
- if (is_git_dir(pathbuf.buf)) {
- add_repo(base, &pathbuf);
- goto end;
- }
- strbuf_addstr(&pathbuf, "/.git");
- if (is_git_dir(pathbuf.buf)) {
- add_repo(base, &pathbuf);
- goto end;
- }
- /*
- * Add one because we don't want to lose the trailing '/' when we
- * reset the length of pathbuf in the loop below.
- */
- pathlen++;
- while ((ent = readdir(dir)) != NULL) {
- if (ent->d_name[0] == '.') {
- if (ent->d_name[1] == '\0')
- continue;
- if (ent->d_name[1] == '.' && ent->d_name[2] == '\0')
- continue;
- if (!ctx.cfg.scan_hidden_path)
- continue;
- }
- strbuf_setlen(&pathbuf, pathlen);
- strbuf_addstr(&pathbuf, ent->d_name);
- if (stat(pathbuf.buf, &st)) {
- fprintf(stderr, "Error checking path %s: %s (%d)\n",
- pathbuf.buf, strerror(errno), errno);
- continue;
- }
- if (S_ISDIR(st.st_mode))
- scan_path(base, pathbuf.buf);
- }
-end:
- strbuf_release(&pathbuf);
- closedir(dir);
-}
-
-void scan_tree(const char *path)
-{
- scan_path(path, path);
-}
diff --git a/cgit/scan-tree.h b/cgit/scan-tree.h
deleted file mode 100644
--- a/cgit/scan-tree.h
+++ /dev/null
@@ -1,5 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-extern void scan_tree(const char *path);
diff --git a/cgit/shared.c b/cgit/shared.c
index fa6b10f..dd86e24 100644
--- a/cgit/shared.c
+++ b/cgit/shared.c
@@ -14,7 +14,6 @@
#include "cgit.h"
-struct cgit_repolist cgit_repolist;
struct cgit_context ctx;
int chk_zero(int result, char *msg)
@@ -41,20 +40,8 @@ int chk_non_negative(int result, char *msg)
char *cgit_default_repo_desc;
struct cgit_repo *cgit_add_repo(const char *url)
{
- struct cgit_repo *ret;
-
- if (++cgit_repolist.count > cgit_repolist.length) {
- if (cgit_repolist.length == 0)
- cgit_repolist.length = 8;
- else
- cgit_repolist.length *= 2;
- cgit_repolist.repos = xrealloc(cgit_repolist.repos,
- cgit_repolist.length *
- sizeof(struct cgit_repo));
- }
+ struct cgit_repo *ret = xcalloc(1, sizeof(*ret));
- ret = &cgit_repolist.repos[cgit_repolist.count-1];
- memset(ret, 0, sizeof(struct cgit_repo));
ret->url = trim_end(url, '/');
*strchrnul(ret->url, '\n') = '\0';
ret->name = ret->url;
@@ -65,16 +52,13 @@ struct cgit_repo *cgit_add_repo(const char *url)
ret->homepage = NULL;
ret->section = ctx.cfg.section;
ret->snapshots = ctx.cfg.snapshots;
- ret->enable_blame = ctx.cfg.enable_blame;
ret->enable_commit_graph = ctx.cfg.enable_commit_graph;
ret->enable_follow_links = ctx.cfg.enable_follow_links;
ret->enable_log_filecount = ctx.cfg.enable_log_filecount;
ret->enable_log_linecount = ctx.cfg.enable_log_linecount;
ret->enable_remote_branches = ctx.cfg.enable_remote_branches;
ret->enable_subject_links = ctx.cfg.enable_subject_links;
- ret->enable_html_serving = ctx.cfg.enable_html_serving;
ret->max_stats = ctx.cfg.max_stats;
- ret->branch_sort = ctx.cfg.branch_sort;
ret->commit_sort = ctx.cfg.commit_sort;
ret->module_link = ctx.cfg.module_link;
ret->readme = ctx.cfg.readme;
@@ -85,21 +69,6 @@ struct cgit_repo *cgit_add_repo(const char *url)
return ret;
}
-struct cgit_repo *cgit_get_repoinfo(const char *url)
-{
- int i;
- struct cgit_repo *repo;
-
- for (i = 0; i < cgit_repolist.count; i++) {
- repo = &cgit_repolist.repos[i];
- if (repo->ignore)
- continue;
- if (!strcmp(repo->url, url))
- return repo;
- }
- return NULL;
-}
-
void cgit_free_commitinfo(struct commitinfo *info)
{
free(info->author);
diff --git a/cgit/ui-blame.c b/cgit/ui-blame.c
deleted file mode 100644
--- a/cgit/ui-blame.c
+++ /dev/null
@@ -1,312 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-/* ui-blame.c: functions for blame output
- *
- * Copyright (C) 2006-2017 cgit Development Team <cgit@lists.zx2c4.com>
- *
- * Licensed under GNU General Public License v2
- * (see COPYING for full license text)
- */
-
-#define USE_THE_REPOSITORY_VARIABLE
-
-#include "cgit.h"
-#include "ui-blame.h"
-#include "html.h"
-#include "ui-shared.h"
-#include "strvec.h"
-#include "blame.h"
-
-
-static char *emit_suspect_detail(struct blame_origin *suspect)
-{
- struct commitinfo *info;
- struct strbuf detail = STRBUF_INIT;
-
- info = cgit_parse_commit(suspect->commit);
-
- strbuf_addf(&detail, "author %s", info->author);
- if (!ctx.cfg.noplainemail)
- strbuf_addf(&detail, " %s", info->author_email);
- strbuf_addf(&detail, " %s\n",
- show_date(info->author_date, info->author_tz,
- cgit_date_mode(DATE_ISO8601)));
-
- strbuf_addf(&detail, "committer %s", info->committer);
- if (!ctx.cfg.noplainemail)
- strbuf_addf(&detail, " %s", info->committer_email);
- strbuf_addf(&detail, " %s\n\n",
- show_date(info->committer_date, info->committer_tz,
- cgit_date_mode(DATE_ISO8601)));
-
- strbuf_addstr(&detail, info->subject);
-
- cgit_free_commitinfo(info);
- return strbuf_detach(&detail, NULL);
-}
-
-static void emit_blame_entry_hash(struct blame_entry *ent)
-{
- struct blame_origin *suspect = ent->suspect;
- struct object_id *oid = &suspect->commit->object.oid;
- unsigned long line = 0;
-
- char *detail = emit_suspect_detail(suspect);
- html("<span class='oid'>");
- cgit_commit_link(repo_find_unique_abbrev(the_repository, oid, DEFAULT_ABBREV), detail,
- NULL, ctx.qry.head, oid_to_hex(oid), suspect->path);
- html("</span>");
- free(detail);
-
- if (!repo_parse_commit(the_repository, suspect->commit) && suspect->commit->parents) {
- struct commit *parent = suspect->commit->parents->item;
-
- html(" ");
- cgit_blame_link("^", "Blame the previous revision", NULL,
- ctx.qry.head, oid_to_hex(&parent->object.oid),
- suspect->path);
- }
-
- while (line++ < ent->num_lines)
- html("\n");
-}
-
-static void emit_blame_entry_linenumber(struct blame_entry *ent)
-{
- const char *numberfmt = "<a id='n%1$d' href='#n%1$d'>%1$d</a>\n";
-
- unsigned long lineno = ent->lno;
- while (lineno < ent->lno + ent->num_lines)
- htmlf(numberfmt, ++lineno);
-}
-
-static void emit_blame_entry_line_background(struct blame_scoreboard *sb,
- struct blame_entry *ent)
-{
- unsigned long line;
- size_t len, maxlen = 2;
- const char* pos, *endpos;
-
- for (line = ent->lno; line < ent->lno + ent->num_lines; line++) {
- html("\n");
- pos = blame_nth_line(sb, line);
- endpos = blame_nth_line(sb, line + 1);
- len = 0;
- while (pos < endpos) {
- len++;
- if (*pos++ == '\t')
- len = (len + 7) & ~7;
- }
- if (len > maxlen)
- maxlen = len;
- }
-
- for (len = 0; len < maxlen - 1; len++)
- html(" ");
-}
-
-struct walk_tree_context {
- char *curr_rev;
- int match_baselen;
- int state;
-};
-
-static void print_object(const struct object_id *oid, const char *path,
- const char *rev)
-{
- enum object_type type;
- char *buf;
- unsigned long size;
- struct strvec rev_argv = STRVEC_INIT;
- struct rev_info revs;
- struct blame_scoreboard sb;
- struct blame_origin *o;
- struct blame_entry *ent = NULL;
-
- type = odb_read_object_info(the_repository->objects, oid, &size);
- if (type == OBJ_BAD) {
- cgit_print_error_page(404, "Not found", "Bad object name: %s",
- oid_to_hex(oid));
- return;
- }
-
- buf = odb_read_object(the_repository->objects, oid, &type, &size);
- if (!buf) {
- cgit_print_error_page(500, "Internal server error",
- "Error reading object %s", oid_to_hex(oid));
- return;
- }
-
- strvec_push(&rev_argv, "blame");
- strvec_push(&rev_argv, rev);
- repo_init_revisions(the_repository, &revs, NULL);
- revs.diffopt.flags.allow_textconv = 1;
- setup_revisions(rev_argv.nr, rev_argv.v, &revs, NULL);
- init_scoreboard(&sb);
- sb.revs = &revs;
- sb.repo = the_repository;
- sb.path = path;
- setup_scoreboard(&sb, &o);
- o->suspects = blame_entry_prepend(NULL, 0, sb.num_lines, o);
- prio_queue_put(&sb.commits, o->commit);
- blame_origin_decref(o);
- sb.ent = NULL;
- sb.path = path;
- assign_blame(&sb, 0);
- blame_sort_final(&sb);
- blame_coalesce(&sb);
-
- cgit_set_title_from_path(path);
-
- cgit_print_layout_start();
- htmlf("blob: %s (", oid_to_hex(oid));
- cgit_plain_link("plain", NULL, NULL, ctx.qry.head, rev, path);
- html(") (");
- cgit_tree_link("tree", NULL, NULL, ctx.qry.head, rev, path);
- html(")\n");
-
- if (buffer_is_binary(buf, size)) {
- html("<div class='error'>blob is binary.</div>");
- goto cleanup;
- }
- if (ctx.cfg.max_blob_size && size / 1024 > ctx.cfg.max_blob_size) {
- htmlf("<div class='error'>blob size (%ldKB)"
- " exceeds display size limit (%dKB).</div>",
- size / 1024, ctx.cfg.max_blob_size);
- goto cleanup;
- }
-
- html("<table class='blame blob'>\n<tr>\n");
-
- /* Commit hashes */
- html("<td class='hashes'>");
- for (ent = sb.ent; ent; ent = ent->next) {
- html("<div class='alt'><pre>");
- emit_blame_entry_hash(ent);
- html("</pre></div>");
- }
- html("</td>\n");
-
- /* Line numbers */
- if (ctx.cfg.enable_tree_linenumbers) {
- html("<td class='linenumbers'>");
- for (ent = sb.ent; ent; ent = ent->next) {
- html("<div class='alt'><pre>");
- emit_blame_entry_linenumber(ent);
- html("</pre></div>");
- }
- html("</td>\n");
- }
-
- html("<td class='lines'><div>");
-
- /* Colored bars behind lines */
- html("<div>");
- for (ent = sb.ent; ent; ) {
- struct blame_entry *e = ent->next;
- html("<div class='alt'><pre>");
- emit_blame_entry_line_background(&sb, ent);
- html("</pre></div>");
- free(ent);
- ent = e;
- }
- html("</div>");
-
- free((void *)sb.final_buf);
-
- /* Lines */
- html("<pre><code>");
- html_txt(buf);
- html("</code></pre>");
-
- html("</div></td>\n");
-
- html("</tr>\n</table>\n");
-
- cgit_print_layout_end();
-
-cleanup:
- free(buf);
-}
-
-static int walk_tree(const struct object_id *oid, struct strbuf *base,
- const char *pathname, unsigned mode, void *cbdata)
-{
- struct walk_tree_context *walk_tree_ctx = cbdata;
-
- if (base->len == walk_tree_ctx->match_baselen) {
- if (S_ISREG(mode)) {
- struct strbuf buffer = STRBUF_INIT;
- strbuf_addbuf(&buffer, base);
- strbuf_addstr(&buffer, pathname);
- print_object(oid, buffer.buf, walk_tree_ctx->curr_rev);
- strbuf_release(&buffer);
- walk_tree_ctx->state = 1;
- } else if (S_ISDIR(mode)) {
- walk_tree_ctx->state = 2;
- }
- } else if (base->len < INT_MAX
- && (int)base->len > walk_tree_ctx->match_baselen) {
- walk_tree_ctx->state = 2;
- } else if (S_ISDIR(mode)) {
- return READ_TREE_RECURSIVE;
- }
- return 0;
-}
-
-static int basedir_len(const char *path)
-{
- const char *p = strrchr(path, '/');
- if (p)
- return p - path + 1;
- return 0;
-}
-
-void cgit_print_blame(void)
-{
- const char *rev = ctx.qry.oid;
- struct object_id oid;
- struct commit *commit;
- struct pathspec_item path_items = {
- .match = ctx.qry.path,
- .len = ctx.qry.path ? strlen(ctx.qry.path) : 0
- };
- struct pathspec paths = {
- .nr = 1,
- .items = &path_items
- };
- struct walk_tree_context walk_tree_ctx = {
- .state = 0
- };
-
- if (!rev)
- rev = ctx.qry.head;
-
- if (repo_get_oid(the_repository, rev, &oid)) {
- cgit_print_error_page(404, "Not found",
- "Invalid revision name: %s", rev);
- return;
- }
- commit = lookup_commit_reference(the_repository, &oid);
- if (!commit || repo_parse_commit(the_repository, commit)) {
- cgit_print_error_page(404, "Not found",
- "Invalid commit reference: %s", rev);
- return;
- }
-
- walk_tree_ctx.curr_rev = xstrdup(rev);
- walk_tree_ctx.match_baselen = (path_items.match) ?
- basedir_len(path_items.match) : -1;
-
- read_tree(the_repository, repo_get_commit_tree(the_repository, commit),
- &paths, walk_tree, &walk_tree_ctx);
- if (!walk_tree_ctx.state)
- cgit_print_error_page(404, "Not found", "Not found");
- else if (walk_tree_ctx.state == 2)
- cgit_print_error_page(404, "No blame for folders",
- "Blame is not available for folders.");
-
- free(walk_tree_ctx.curr_rev);
-}
diff --git a/cgit/ui-blame.h b/cgit/ui-blame.h
deleted file mode 100644
--- a/cgit/ui-blame.h
+++ /dev/null
@@ -1,10 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-#ifndef UI_BLAME_H
-#define UI_BLAME_H
-
-extern void cgit_print_blame(void);
-
-#endif /* UI_BLAME_H */
diff --git a/cgit/ui-blob.c b/cgit/ui-blob.c
deleted file mode 100644
--- a/cgit/ui-blob.c
+++ /dev/null
@@ -1,188 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-/* ui-blob.c: show blob content
- *
- * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
- *
- * Licensed under GNU General Public License v2
- * (see COPYING for full license text)
- */
-
-#define USE_THE_REPOSITORY_VARIABLE
-
-#include "cgit.h"
-#include "ui-blob.h"
-#include "html.h"
-#include "ui-shared.h"
-
-struct walk_tree_context {
- const char *match_path;
- struct object_id *matched_oid;
- unsigned int found_path:1;
- unsigned int file_only:1;
-};
-
-static int walk_tree(const struct object_id *oid, struct strbuf *base,
- const char *pathname, unsigned mode, void *cbdata)
-{
- struct walk_tree_context *walk_tree_ctx = cbdata;
-
- if (walk_tree_ctx->file_only && !S_ISREG(mode))
- return READ_TREE_RECURSIVE;
- if (strncmp(base->buf, walk_tree_ctx->match_path, base->len)
- || strcmp(walk_tree_ctx->match_path + base->len, pathname))
- return READ_TREE_RECURSIVE;
- oidcpy(walk_tree_ctx->matched_oid, oid);
- walk_tree_ctx->found_path = 1;
- return 0;
-}
-
-int cgit_ref_path_exists(const char *path, const char *ref, int file_only)
-{
- struct object_id oid;
- unsigned long size;
- struct pathspec_item path_items = {
- .match = xstrdup(path),
- .len = strlen(path)
- };
- struct pathspec paths = {
- .nr = 1,
- .items = &path_items
- };
- struct walk_tree_context walk_tree_ctx = {
- .match_path = path,
- .matched_oid = &oid,
- .found_path = 0,
- .file_only = file_only
- };
-
- if (repo_get_oid(the_repository, ref, &oid))
- goto done;
- if (odb_read_object_info(the_repository->objects, &oid, &size) != OBJ_COMMIT)
- goto done;
- read_tree(the_repository,
- repo_get_commit_tree(the_repository, lookup_commit_reference(the_repository, &oid)),
- &paths, walk_tree, &walk_tree_ctx);
-
-done:
- free(path_items.match);
- return walk_tree_ctx.found_path;
-}
-
-int cgit_print_file(char *path, const char *head, int file_only)
-{
- struct object_id oid;
- enum object_type type;
- char *buf;
- unsigned long size;
- struct commit *commit;
- struct pathspec_item path_items = {
- .match = path,
- .len = strlen(path)
- };
- struct pathspec paths = {
- .nr = 1,
- .items = &path_items
- };
- struct walk_tree_context walk_tree_ctx = {
- .match_path = path,
- .matched_oid = &oid,
- .found_path = 0,
- .file_only = file_only
- };
-
- if (repo_get_oid(the_repository, head, &oid))
- return -1;
- type = odb_read_object_info(the_repository->objects, &oid, &size);
- if (type == OBJ_COMMIT) {
- commit = lookup_commit_reference(the_repository, &oid);
- read_tree(the_repository, repo_get_commit_tree(the_repository, commit),
- &paths, walk_tree, &walk_tree_ctx);
- if (!walk_tree_ctx.found_path)
- return -1;
- type = odb_read_object_info(the_repository->objects, &oid, &size);
- }
- if (type == OBJ_BAD)
- return -1;
- buf = odb_read_object(the_repository->objects, &oid, &type, &size);
- if (!buf)
- return -1;
- buf[size] = '\0';
- html_ntxt(buf, size);
- free(buf);
- return 0;
-}
-
-void cgit_print_blob(const char *hex, char *path, const char *head, int file_only)
-{
- struct object_id oid;
- enum object_type type;
- char *buf;
- unsigned long size;
- struct commit *commit;
- struct pathspec_item path_items = {
- .match = path,
- .len = path ? strlen(path) : 0
- };
- struct pathspec paths = {
- .nr = 1,
- .items = &path_items
- };
- struct walk_tree_context walk_tree_ctx = {
- .match_path = path,
- .matched_oid = &oid,
- .found_path = 0,
- .file_only = file_only
- };
-
- if (hex) {
- if (get_oid_hex(hex, &oid)) {
- cgit_print_error_page(400, "Bad request",
- "Bad hex value: %s", hex);
- return;
- }
- } else {
- if (repo_get_oid(the_repository, head, &oid)) {
- cgit_print_error_page(404, "Not found",
- "Bad ref: %s", head);
- return;
- }
- }
-
- type = odb_read_object_info(the_repository->objects, &oid, &size);
-
- if ((!hex) && type == OBJ_COMMIT && path) {
- commit = lookup_commit_reference(the_repository, &oid);
- read_tree(the_repository, repo_get_commit_tree(the_repository, commit),
- &paths, walk_tree, &walk_tree_ctx);
- type = odb_read_object_info(the_repository->objects, &oid, &size);
- }
-
- if (type == OBJ_BAD) {
- cgit_print_error_page(404, "Not found",
- "Bad object name: %s", hex);
- return;
- }
-
- buf = odb_read_object(the_repository->objects, &oid, &type, &size);
- if (!buf) {
- cgit_print_error_page(500, "Internal server error",
- "Error reading object %s", hex);
- return;
- }
-
- buf[size] = '\0';
- if (buffer_is_binary(buf, size))
- ctx.page.mimetype = "application/octet-stream";
- else
- ctx.page.mimetype = "text/plain";
- ctx.page.filename = path;
-
- html("X-Content-Type-Options: nosniff\n");
- html("Content-Security-Policy: default-src 'none'\n");
- cgit_print_http_headers();
- html_raw(buf, size);
- free(buf);
-}
diff --git a/cgit/ui-blob.h b/cgit/ui-blob.h
deleted file mode 100644
--- a/cgit/ui-blob.h
+++ /dev/null
@@ -1,12 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-#ifndef UI_BLOB_H
-#define UI_BLOB_H
-
-extern int cgit_ref_path_exists(const char *path, const char *ref, int file_only);
-extern int cgit_print_file(char *path, const char *head, int file_only);
-extern void cgit_print_blob(const char *hex, char *path, const char *head, int file_only);
-
-#endif /* UI_BLOB_H */
diff --git a/cgit/ui-clone.c b/cgit/ui-clone.c
deleted file mode 100644
--- a/cgit/ui-clone.c
+++ /dev/null
@@ -1,142 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-/* ui-clone.c: functions for http cloning, based on
- * git's http-backend.c by Shawn O. Pearce
- *
- * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
- *
- * Licensed under GNU General Public License v2
- * (see COPYING for full license text)
- */
-
-#define USE_THE_REPOSITORY_VARIABLE
-
-#include "cgit.h"
-#include "ui-clone.h"
-#include "html.h"
-#include "ui-shared.h"
-#include "packfile.h"
-
-static int print_ref_info(const struct reference *ref, void *cb_data)
-{
- struct object *obj;
-
- if (!(obj = parse_object(the_repository, ref->oid)))
- return 0;
-
- htmlf("%s\t%s\n", oid_to_hex(ref->oid), ref->name);
- if (obj->type == OBJ_TAG) {
- if (!(obj = deref_tag(the_repository, obj, ref->name, 0)))
- return 0;
- htmlf("%s\t%s^{}\n", oid_to_hex(&obj->oid), ref->name);
- }
- return 0;
-}
-
-static void print_pack_info(void)
-{
- struct odb_source *source;
- char *offset;
-
- ctx.page.mimetype = "text/plain";
- ctx.page.filename = "objects/info/packs";
- cgit_print_http_headers();
- odb_reprepare(the_repository->objects);
- for (source = the_repository->objects->sources; source; source = source->next) {
- struct odb_source_files *files = odb_source_files_downcast(source);
- struct packfile_list_entry *e;
- for (e = files->packed->packs.head; e; e = e->next) {
- struct packed_git *p = e->pack;
- if (p->pack_local) {
- offset = strrchr(p->pack_name, '/');
- if (offset && offset[1] != '\0')
- ++offset;
- else
- offset = p->pack_name;
- htmlf("P %s\n", offset);
- }
- }
- }
-}
-
-static void send_file(const char *path)
-{
- struct stat st;
-
- if (stat(path, &st)) {
- switch (errno) {
- case ENOENT:
- cgit_print_error_page(404, "Not found", "Not found");
- break;
- case EACCES:
- cgit_print_error_page(403, "Forbidden", "Forbidden");
- break;
- default:
- cgit_print_error_page(400, "Bad request", "Bad request");
- }
- return;
- }
- ctx.page.mimetype = "application/octet-stream";
- ctx.page.filename = path;
- skip_prefix(path, ctx.repo->path, &ctx.page.filename);
- skip_prefix(ctx.page.filename, "/", &ctx.page.filename);
- cgit_print_http_headers();
- html_include(path);
-}
-
-void cgit_clone_info(void)
-{
- if (!ctx.qry.path || strcmp(ctx.qry.path, "refs")) {
- cgit_print_error_page(400, "Bad request", "Bad request");
- return;
- }
-
- ctx.page.mimetype = "text/plain";
- ctx.page.filename = "info/refs";
- cgit_print_http_headers();
- refs_for_each_ref(get_main_ref_store(the_repository),
- print_ref_info, NULL);
-}
-
-void cgit_clone_objects(void)
-{
- char *p, *path;
-
- if (!ctx.qry.path)
- goto err;
-
- if (!strcmp(ctx.qry.path, "info/packs")) {
- print_pack_info();
- return;
- }
-
- /* Avoid directory traversal by forbidding "..", but also work around
- * other funny business by just specifying a fairly strict format. For
- * example, now we don't have to stress out about the Cygwin port.
- */
- for (p = ctx.qry.path; *p; ++p) {
- if (*p == '.' && *(p + 1) == '.')
- goto err;
- if (!isalnum(*p) && *p != '/' && *p != '.' && *p != '-')
- goto err;
- }
-
- path = repo_git_path(the_repository, "objects/%s", ctx.qry.path);
- send_file(path);
- free(path);
- return;
-
-err:
- cgit_print_error_page(400, "Bad request", "Bad request");
-}
-
-void cgit_clone_head(void)
-{
- char *path;
-
- path = repo_git_path(the_repository, "HEAD");
- send_file(path);
- free(path);
-}
diff --git a/cgit/ui-clone.h b/cgit/ui-clone.h
deleted file mode 100644
--- a/cgit/ui-clone.h
+++ /dev/null
@@ -1,12 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-#ifndef UI_CLONE_H
-#define UI_CLONE_H
-
-void cgit_clone_info(void);
-void cgit_clone_objects(void);
-void cgit_clone_head(void);
-
-#endif /* UI_CLONE_H */
diff --git a/cgit/ui-plain.c b/cgit/ui-plain.c
deleted file mode 100644
--- a/cgit/ui-plain.c
+++ /dev/null
@@ -1,213 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-/* ui-plain.c: functions for output of plain blobs by path
- *
- * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
- *
- * Licensed under GNU General Public License v2
- * (see COPYING for full license text)
- */
-
-#define USE_THE_REPOSITORY_VARIABLE
-
-#include "cgit.h"
-#include "ui-plain.h"
-#include "html.h"
-#include "ui-shared.h"
-
-struct walk_tree_context {
- int match_baselen;
- int match;
-};
-
-static int print_object(const struct object_id *oid, const char *path)
-{
- enum object_type type;
- char *buf, *mimetype;
- unsigned long size;
-
- type = odb_read_object_info(the_repository->objects, oid, &size);
- if (type == OBJ_BAD) {
- cgit_print_error_page(404, "Not found", "Not found");
- return 0;
- }
-
- buf = odb_read_object(the_repository->objects, oid, &type, &size);
- if (!buf) {
- cgit_print_error_page(404, "Not found", "Not found");
- return 0;
- }
-
- mimetype = get_mimetype_for_filename(path);
- ctx.page.mimetype = mimetype;
-
- if (!ctx.repo->enable_html_serving) {
- html("X-Content-Type-Options: nosniff\n");
- html("Content-Security-Policy: default-src 'none'\n");
- if (mimetype) {
- /* Built-in white list allows PDF and everything that isn't text/ and application/ */
- if ((!strncmp(mimetype, "text/", 5) || !strncmp(mimetype, "application/", 12)) && strcmp(mimetype, "application/pdf"))
- ctx.page.mimetype = NULL;
- }
- }
-
- if (!ctx.page.mimetype) {
- if (buffer_is_binary(buf, size)) {
- ctx.page.mimetype = "application/octet-stream";
- ctx.page.charset = NULL;
- } else {
- ctx.page.mimetype = "text/plain";
- }
- }
- ctx.page.filename = path;
- ctx.page.size = size;
- ctx.page.etag = oid_to_hex(oid);
- cgit_print_http_headers();
- html_raw(buf, size);
- free(mimetype);
- free(buf);
- return 1;
-}
-
-static char *buildpath(const char *base, int baselen, const char *path)
-{
- if (path[0])
- return fmtalloc("%.*s%s/", baselen, base, path);
- else
- return fmtalloc("%.*s/", baselen, base);
-}
-
-static void print_dir(const struct object_id *oid, const char *base,
- int baselen, const char *path)
-{
- char *fullpath, *slash;
- size_t len;
-
- fullpath = buildpath(base, baselen, path);
- slash = (fullpath[0] == '/' ? "" : "/");
- ctx.page.etag = oid_to_hex(oid);
- cgit_print_http_headers();
- htmlf("<html><head><title>%s", slash);
- html_txt(fullpath);
- htmlf("</title></head>\n<body>\n<h2>%s", slash);
- html_txt(fullpath);
- html("</h2>\n<ul>\n");
- len = strlen(fullpath);
- if (len > 1) {
- fullpath[len - 1] = 0;
- slash = strrchr(fullpath, '/');
- if (slash)
- *(slash + 1) = 0;
- else {
- free(fullpath);
- fullpath = NULL;
- }
- html("<li>");
- cgit_plain_link("../", NULL, NULL, ctx.qry.head, ctx.qry.oid,
- fullpath);
- html("</li>\n");
- }
- free(fullpath);
-}
-
-static void print_dir_entry(const struct object_id *oid, const char *base,
- int baselen, const char *path, unsigned mode)
-{
- char *fullpath;
-
- fullpath = buildpath(base, baselen, path);
- if (!S_ISDIR(mode) && !S_ISGITLINK(mode))
- fullpath[strlen(fullpath) - 1] = 0;
- html(" <li>");
- if (S_ISGITLINK(mode)) {
- cgit_submodule_link(NULL, fullpath, oid_to_hex(oid));
- } else
- cgit_plain_link(path, NULL, NULL, ctx.qry.head, ctx.qry.oid,
- fullpath);
- html("</li>\n");
- free(fullpath);
-}
-
-static void print_dir_tail(void)
-{
- html(" </ul>\n</body></html>\n");
-}
-
-static int walk_tree(const struct object_id *oid, struct strbuf *base,
- const char *pathname, unsigned mode, void *cbdata)
-{
- struct walk_tree_context *walk_tree_ctx = cbdata;
-
- if (base->len == walk_tree_ctx->match_baselen) {
- if (S_ISREG(mode) || S_ISLNK(mode)) {
- if (print_object(oid, pathname))
- walk_tree_ctx->match = 1;
- } else if (S_ISDIR(mode)) {
- print_dir(oid, base->buf, base->len, pathname);
- walk_tree_ctx->match = 2;
- return READ_TREE_RECURSIVE;
- }
- } else if (base->len < INT_MAX && (int)base->len > walk_tree_ctx->match_baselen) {
- print_dir_entry(oid, base->buf, base->len, pathname, mode);
- walk_tree_ctx->match = 2;
- } else if (S_ISDIR(mode)) {
- return READ_TREE_RECURSIVE;
- }
-
- return 0;
-}
-
-static int basedir_len(const char *path)
-{
- const char *p = strrchr(path, '/');
- if (p)
- return p - path + 1;
- return 0;
-}
-
-void cgit_print_plain(void)
-{
- const char *rev = ctx.qry.oid;
- struct object_id oid;
- struct commit *commit;
- struct pathspec_item path_items = {
- .match = ctx.qry.path,
- .len = ctx.qry.path ? strlen(ctx.qry.path) : 0
- };
- struct pathspec paths = {
- .nr = 1,
- .items = &path_items
- };
- struct walk_tree_context walk_tree_ctx = {
- .match = 0
- };
-
- if (!rev)
- rev = ctx.qry.head;
-
- if (repo_get_oid(the_repository, rev, &oid)) {
- cgit_print_error_page(404, "Not found", "Not found");
- return;
- }
- commit = lookup_commit_reference(the_repository, &oid);
- if (!commit || repo_parse_commit(the_repository, commit)) {
- cgit_print_error_page(404, "Not found", "Not found");
- return;
- }
- if (!path_items.match) {
- path_items.match = "";
- walk_tree_ctx.match_baselen = -1;
- print_dir(get_commit_tree_oid(commit), "", 0, "");
- walk_tree_ctx.match = 2;
- }
- else
- walk_tree_ctx.match_baselen = basedir_len(path_items.match);
- read_tree(the_repository, repo_get_commit_tree(the_repository, commit),
- &paths, walk_tree, &walk_tree_ctx);
- if (!walk_tree_ctx.match)
- cgit_print_error_page(404, "Not found", "Not found");
- else if (walk_tree_ctx.match == 2)
- print_dir_tail();
-}
diff --git a/cgit/ui-plain.h b/cgit/ui-plain.h
deleted file mode 100644
--- a/cgit/ui-plain.h
+++ /dev/null
@@ -1,10 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-#ifndef UI_PLAIN_H
-#define UI_PLAIN_H
-
-extern void cgit_print_plain(void);
-
-#endif /* UI_PLAIN_H */
diff --git a/cgit/ui-refs.c b/cgit/ui-refs.c
deleted file mode 100644
--- a/cgit/ui-refs.c
+++ /dev/null
@@ -1,221 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-/* ui-refs.c: browse symbolic refs
- *
- * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
- *
- * Licensed under GNU General Public License v2
- * (see COPYING for full license text)
- */
-
-#define USE_THE_REPOSITORY_VARIABLE
-
-#include "cgit.h"
-#include "ui-refs.h"
-#include "html.h"
-#include "ui-shared.h"
-
-static inline int cmp_age(int age1, int age2)
-{
- /* age1 and age2 are assumed to be non-negative */
- return age2 - age1;
-}
-
-static int cmp_ref_name(const void *a, const void *b)
-{
- struct refinfo *r1 = *(struct refinfo **)a;
- struct refinfo *r2 = *(struct refinfo **)b;
-
- return strcmp(r1->refname, r2->refname);
-}
-
-static int cmp_branch_age(const void *a, const void *b)
-{
- struct refinfo *r1 = *(struct refinfo **)a;
- struct refinfo *r2 = *(struct refinfo **)b;
-
- return cmp_age(r1->commit->committer_date, r2->commit->committer_date);
-}
-
-static int get_ref_age(struct refinfo *ref)
-{
- if (!ref->object)
- return 0;
- switch (ref->object->type) {
- case OBJ_TAG:
- return ref->tag ? ref->tag->tagger_date : 0;
- case OBJ_COMMIT:
- return ref->commit ? ref->commit->committer_date : 0;
- }
- return 0;
-}
-
-static int cmp_tag_age(const void *a, const void *b)
-{
- struct refinfo *r1 = *(struct refinfo **)a;
- struct refinfo *r2 = *(struct refinfo **)b;
-
- return cmp_age(get_ref_age(r1), get_ref_age(r2));
-}
-
-static int print_branch(struct refinfo *ref)
-{
- struct commitinfo *info = ref->commit;
- char *name = (char *)ref->refname;
-
- if (!info)
- return 1;
- html("<tr><td>");
- cgit_log_link(name, NULL, NULL, name, NULL, NULL, 0, NULL, NULL,
- ctx.qry.showmsg, 0);
- html("</td><td>");
-
- if (ref->object->type == OBJ_COMMIT) {
- cgit_commit_link(info->subject, NULL, NULL, name, NULL, NULL);
- html("</td><td>");
- html_txt(info->author);
- html("</td><td colspan='2'>");
- cgit_print_age(info->committer_date, info->committer_tz, -1);
- } else {
- html("</td><td></td><td>");
- cgit_object_link(ref->object);
- }
- html("</td></tr>\n");
- return 0;
-}
-
-static void print_tag_header(void)
-{
- html("<tr class='nohover'><th class='left'>Tag</th>"
- "<th class='left'>Download</th>"
- "<th class='left'>Author</th>"
- "<th class='left' colspan='2'>Age</th></tr>\n");
-}
-
-static int print_tag(struct refinfo *ref)
-{
- struct tag *tag = NULL;
- struct taginfo *info = NULL;
- char *name = (char *)ref->refname;
- struct object *obj = ref->object;
-
- if (obj->type == OBJ_TAG) {
- tag = (struct tag *)obj;
- obj = tag->tagged;
- info = ref->tag;
- if (!info)
- return 1;
- }
-
- html("<tr><td>");
- cgit_tag_link(name, NULL, NULL, name);
- html("</td><td>");
- if (ctx.repo->snapshots && (obj->type == OBJ_COMMIT))
- cgit_print_snapshot_links(ctx.repo, name, "&nbsp;&nbsp;");
- else
- cgit_object_link(obj);
- html("</td><td>");
- if (info) {
- if (info->tagger)
- html_txt(info->tagger);
- } else if (ref->object->type == OBJ_COMMIT) {
- html_txt(ref->commit->author);
- }
- html("</td><td colspan='2'>");
- if (info) {
- if (info->tagger_date > 0)
- cgit_print_age(info->tagger_date, info->tagger_tz, -1);
- } else if (ref->object->type == OBJ_COMMIT) {
- cgit_print_age(ref->commit->commit->date, 0, -1);
- }
- html("</td></tr>\n");
-
- return 0;
-}
-
-static void print_refs_link(const char *path)
-{
- html("<tr class='nohover'><td colspan='5'>");
- cgit_refs_link("[...]", NULL, NULL, ctx.qry.head, NULL, path);
- html("</td></tr>");
-}
-
-void cgit_print_branches(int maxcount)
-{
- struct reflist list;
- int i;
-
- html("<tr class='nohover'><th class='left'>Branch</th>"
- "<th class='left'>Commit message</th>"
- "<th class='left'>Author</th>"
- "<th class='left' colspan='2'>Age</th></tr>\n");
-
- list.refs = NULL;
- list.alloc = list.count = 0;
- refs_for_each_branch_ref(get_main_ref_store(the_repository),
- cgit_refs_cb, &list);
- if (ctx.repo->enable_remote_branches)
- refs_for_each_remote_ref(get_main_ref_store(the_repository),
- cgit_refs_cb, &list);
-
- if (maxcount == 0 || maxcount > list.count)
- maxcount = list.count;
-
- qsort(list.refs, list.count, sizeof(*list.refs), cmp_branch_age);
- if (ctx.repo->branch_sort == 0)
- qsort(list.refs, maxcount, sizeof(*list.refs), cmp_ref_name);
-
- for (i = 0; i < maxcount; i++)
- print_branch(list.refs[i]);
-
- if (maxcount < list.count)
- print_refs_link("heads");
-
- cgit_free_reflist_inner(&list);
-}
-
-void cgit_print_tags(int maxcount)
-{
- struct reflist list;
- int i;
-
- list.refs = NULL;
- list.alloc = list.count = 0;
- refs_for_each_tag_ref(get_main_ref_store(the_repository),
- cgit_refs_cb, &list);
- if (list.count == 0)
- return;
- qsort(list.refs, list.count, sizeof(*list.refs), cmp_tag_age);
- if (!maxcount)
- maxcount = list.count;
- else if (maxcount > list.count)
- maxcount = list.count;
- print_tag_header();
- for (i = 0; i < maxcount; i++)
- print_tag(list.refs[i]);
-
- if (maxcount < list.count)
- print_refs_link("tags");
-
- cgit_free_reflist_inner(&list);
-}
-
-void cgit_print_refs(void)
-{
- cgit_print_layout_start();
- html("<table class='list nowrap'>");
-
- if (ctx.qry.path && starts_with(ctx.qry.path, "heads"))
- cgit_print_branches(0);
- else if (ctx.qry.path && starts_with(ctx.qry.path, "tags"))
- cgit_print_tags(0);
- else {
- cgit_print_branches(0);
- html("<tr class='nohover'><td colspan='5'>&nbsp;</td></tr>");
- cgit_print_tags(0);
- }
- html("</table>");
- cgit_print_layout_end();
-}
diff --git a/cgit/ui-refs.h b/cgit/ui-refs.h
deleted file mode 100644
--- a/cgit/ui-refs.h
+++ /dev/null
@@ -1,12 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-#ifndef UI_REFS_H
-#define UI_REFS_H
-
-extern void cgit_print_branches(int maxcount);
-extern void cgit_print_tags(int maxcount);
-extern void cgit_print_refs(void);
-
-#endif /* UI_REFS_H */
diff --git a/cgit/ui-repolist.c b/cgit/ui-repolist.c
deleted file mode 100644
--- a/cgit/ui-repolist.c
+++ /dev/null
@@ -1,480 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-/* ui-repolist.c: functions for generating the repolist page
- *
- * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
- *
- * Licensed under GNU General Public License v2
- * (see COPYING for full license text)
- */
-
-#include "cgit.h"
-#include "ui-repolist.h"
-#include "html.h"
-#include "json.h"
-#include "ui-shared.h"
-#include "version.h"
-
-static time_t read_agefile(const char *path)
-{
- time_t result;
- size_t size;
- char *buf = NULL;
- struct strbuf date_buf = STRBUF_INIT;
-
- if (read_first_line(path, &buf, &size)) {
- free(buf);
- return 0;
- }
-
- if (parse_date(buf, &date_buf) == 0)
- result = strtoul(date_buf.buf, NULL, 10);
- else
- result = 0;
- free(buf);
- strbuf_release(&date_buf);
- return result;
-}
-
-static int get_repo_modtime(const struct cgit_repo *repo, time_t *mtime)
-{
- struct strbuf path = STRBUF_INIT;
- struct strbuf head = STRBUF_INIT;
- struct stat s;
- struct cgit_repo *r = (struct cgit_repo *)repo;
- const char *ref;
-
- if (repo->mtime != -1) {
- *mtime = repo->mtime;
- return repo->mtime != 0;
- }
- strbuf_addf(&path, "%s/%s", repo->path, ctx.cfg.agefile);
- if (stat(path.buf, &s) == 0) {
- *mtime = read_agefile(path.buf);
- if (*mtime) {
- r->mtime = *mtime;
- goto end;
- }
- }
-
- strbuf_reset(&path);
- strbuf_addf(&path, "%s/HEAD", repo->path);
- if (strbuf_read_file(&head, path.buf, 64) >= 0) {
- strbuf_trim(&head);
- if (skip_prefix(head.buf, "ref: ", &ref)) {
- strbuf_reset(&path);
- strbuf_addf(&path, "%s/%s", repo->path, ref);
- if (stat(path.buf, &s) == 0) {
- *mtime = s.st_mtime;
- r->mtime = *mtime;
- goto end;
- }
- } else if (head.len && stat(path.buf, &s) == 0) {
- *mtime = s.st_mtime;
- r->mtime = *mtime;
- goto end;
- }
- }
-
- strbuf_reset(&path);
- strbuf_addf(&path, "%s/%s", repo->path, "packed-refs");
- if (stat(path.buf, &s) == 0) {
- *mtime = s.st_mtime;
- r->mtime = *mtime;
- goto end;
- }
-
- *mtime = 0;
- r->mtime = *mtime;
-end:
- strbuf_release(&head);
- strbuf_release(&path);
- return (r->mtime != 0);
-}
-
-static int is_match(struct cgit_repo *repo)
-{
- if (!ctx.qry.search)
- return 1;
- if (repo->url && strcasestr(repo->url, ctx.qry.search))
- return 1;
- if (repo->name && strcasestr(repo->name, ctx.qry.search))
- return 1;
- if (repo->desc && strcasestr(repo->desc, ctx.qry.search))
- return 1;
- if (repo->owner && strcasestr(repo->owner, ctx.qry.search))
- return 1;
- return 0;
-}
-
-static int is_visible(struct cgit_repo *repo)
-{
- if (repo->hide || repo->ignore)
- return 0;
- return is_match(repo);
-}
-
-static int any_repos_visible(void)
-{
- int i;
-
- for (i = 0; i < cgit_repolist.count; i++) {
- if (is_visible(&cgit_repolist.repos[i]))
- return 1;
- }
- return 0;
-}
-
-static int cmp(const char *s1, const char *s2)
-{
- if (s1 && s2) {
- if (ctx.cfg.case_sensitive_sort)
- return strcmp(s1, s2);
- else
- return strcasecmp(s1, s2);
- }
- if (s1 && !s2)
- return -1;
- if (s2 && !s1)
- return 1;
- return 0;
-}
-
-static int sort_name(const void *a, const void *b)
-{
- const struct cgit_repo *r1 = a;
- const struct cgit_repo *r2 = b;
-
- return cmp(r1->name, r2->name);
-}
-
-static int sort_desc(const void *a, const void *b)
-{
- const struct cgit_repo *r1 = a;
- const struct cgit_repo *r2 = b;
-
- return cmp(r1->desc, r2->desc);
-}
-
-static int sort_owner(const void *a, const void *b)
-{
- const struct cgit_repo *r1 = a;
- const struct cgit_repo *r2 = b;
-
- return cmp(r1->owner, r2->owner);
-}
-
-static int sort_idle(const void *a, const void *b)
-{
- const struct cgit_repo *r1 = a;
- const struct cgit_repo *r2 = b;
- time_t t1, t2;
-
- t1 = t2 = 0;
- get_repo_modtime(r1, &t1);
- get_repo_modtime(r2, &t2);
- return t2 - t1;
-}
-
-static int sort_section(const void *a, const void *b)
-{
- const struct cgit_repo *r1 = a;
- const struct cgit_repo *r2 = b;
- int result;
-
- result = cmp(r1->section, r2->section);
- if (!result) {
- if (!strcmp(ctx.cfg.repository_sort, "age"))
- result = sort_idle(r1, r2);
- if (!result)
- result = cmp(r1->name, r2->name);
- }
- return result;
-}
-
-struct sortcolumn {
- const char *name;
- int (*fn)(const void *a, const void *b);
-};
-
-static const struct sortcolumn sortcolumn[] = {
- {"section", sort_section},
- {"name", sort_name},
- {"desc", sort_desc},
- {"owner", sort_owner},
- {"idle", sort_idle},
- {NULL, NULL}
-};
-
-static int sort_repolist(char *field)
-{
- const struct sortcolumn *column;
-
- for (column = &sortcolumn[0]; column->name; column++) {
- if (strcmp(field, column->name))
- continue;
- qsort(cgit_repolist.repos, cgit_repolist.count,
- sizeof(struct cgit_repo), column->fn);
- return 1;
- }
- return 0;
-}
-
-
-static void json_include(const char *path)
-{
- struct strbuf content = STRBUF_INIT;
-
- if (!path || strbuf_read_file(&content, path, 0) < 0)
- json("null");
- else
- json_value(content.buf);
- strbuf_release(&content);
-}
-
-static void json_string_list(const struct string_list *list)
-{
- struct string_list_item *item;
- int first = 1;
-
- json("[");
- for_each_string_list_item(item, list) {
- if (!first)
- json(",");
- json_value(item->string);
- first = 0;
- }
- json("]");
-}
-
-static void url_arg(struct strbuf *url, const char *value)
-{
- const unsigned char *p = (const unsigned char *)(value ? value : "");
-
- for (; *p; p++) {
- if (isalnum(*p) || strchr("!$()*,./:;@-[]_~", *p))
- strbuf_addch(url, *p);
- else if (*p == ' ')
- strbuf_addch(url, '+');
- else
- strbuf_addf(url, "%%%02x", *p);
- }
-}
-
-static void url_path(struct strbuf *url, const char *value)
-{
- const unsigned char *p = (const unsigned char *)(value ? value : "");
-
- for (; *p; p++) {
- if (isalnum(*p) || *p == '/' || *p == '_')
- strbuf_addch(url, *p);
- else
- strbuf_addf(url, "%%%02x", *p);
- }
-}
-
-static char *sort_url(const char *sort)
-{
- char *currenturl = cgit_currenturl();
- struct strbuf url = STRBUF_INIT;
-
- strbuf_addf(&url, "%s?s=%s", currenturl, sort);
- free(currenturl);
- if (ctx.qry.search) {
- strbuf_addstr(&url, "&q=");
- url_arg(&url, ctx.qry.search);
- }
- return strbuf_detach(&url, NULL);
-}
-
-static char *pager_url(const char *search, const char *sort, int ofs)
-{
- char *currenturl = cgit_currenturl();
- struct strbuf url = STRBUF_INIT;
- const char *delimiter = "?";
-
- strbuf_addstr(&url, currenturl);
- free(currenturl);
- if (search) {
- strbuf_addf(&url, "?q=%s", search);
- delimiter = "&";
- }
- if (sort) {
- strbuf_addf(&url, "%ss=%s", delimiter, sort);
- delimiter = "&";
- }
- if (ofs)
- strbuf_addf(&url, "%sofs=%d", delimiter, ofs);
- return strbuf_detach(&url, NULL);
-}
-
-static char *repo_url(const struct cgit_repo *repo, const char *page,
- const char *query)
-{
- struct strbuf url = STRBUF_INIT;
-
- strbuf_addch(&url, '/');
- url_path(&url, repo->url);
- if (page) {
- strbuf_addstr(&url, "/+/HEAD/+/");
- url_path(&url, page);
- }
- if (query)
- strbuf_addf(&url, "?%s", query);
- return strbuf_detach(&url, NULL);
-}
-
-static void json_description(const char *description)
-{
- size_t len = description ? strlen(description) : 0;
- size_t shown = len;
-
- if (shown > ctx.cfg.max_repodesc_len)
- shown = ctx.cfg.max_repodesc_len;
- char *text = xmemdupz(description ? description : "", shown);
- json("{");
- json_key("text");
- json_value(text);
- free(text);
- json(",");
- json_key("truncated");
- json_bool(len > shown);
- json("}");
-}
-
-static void json_age(struct cgit_repo *repo)
-{
- time_t t, now, seconds;
- const char *unit;
- double amount;
-
- if (!get_repo_modtime(repo, &t)) {
- json("null");
- return;
- }
- time(&now);
- seconds = now > t ? now - t : 0;
- if (seconds < TM_HOUR * 2) { unit = "minutes"; amount = seconds * 1.0 / TM_MIN; }
- else if (seconds < TM_DAY * 2) { unit = "hours"; amount = seconds * 1.0 / TM_HOUR; }
- else if (seconds < TM_WEEK * 2) { unit = "days"; amount = seconds * 1.0 / TM_DAY; }
- else if (seconds < TM_MONTH * 2) { unit = "weeks"; amount = seconds * 1.0 / TM_WEEK; }
- else if (seconds < TM_YEAR * 2) { unit = "months"; amount = seconds * 1.0 / TM_MONTH; }
- else { unit = "years"; amount = seconds * 1.0 / TM_YEAR; }
- json("{");
- json_key("timestamp"); json_int(t); json(",");
- json_key("title"); json_value(show_date(t, 0, cgit_date_mode(DATE_ISO8601))); json(",");
- json_key("unit"); json_value(unit); json(",");
- json_key("amount"); jsonf("%.0f", amount);
- json("}");
-}
-
-static void json_shell(void)
-{
- json_key("shell"); json("{");
- json_key("embedded"); json_bool(ctx.cfg.embedded); json(",");
- json_key("robots"); json_value(ctx.cfg.robots); json(",");
- json_key("css"); json_string_list(&ctx.cfg.css); json(",");
- json_key("js"); json_string_list(&ctx.cfg.js); json(",");
- json_key("favicon"); json_value(ctx.cfg.favicon); json(",");
- json_key("head_include"); json_include(ctx.cfg.head_include); json(",");
- json_key("header"); json_include(ctx.cfg.header); json(",");
- json_key("footer_configured"); json_bool(ctx.cfg.footer != NULL); json(",");
- json_key("footer"); json_include(ctx.cfg.footer); json(",");
- json_key("logo"); json_value(ctx.cfg.logo); json(",");
- json_key("logo_link"); json_value(ctx.cfg.logo_link); json(",");
- json_key("cgit_version"); json_value(cgit_version); json(",");
- json_key("git_version"); json_value(git_version_string); json(",");
- json_key("generated_at"); json_value(show_date(time(NULL), 0, cgit_date_mode(DATE_ISO8601)));
- json("}");
-}
-
-void cgit_print_repolist(void)
-{
- int i, hits = 0, sorted = 0, first = 1;
- char *last_section = NULL;
-
- if (!any_repos_visible()) {
- cgit_print_error_page(404, "Not found", "No repositories found");
- return;
- }
- ctx.page.title = ctx.cfg.root_title;
- ctx.page.mimetype = "application/vnd.gilti.repolist+json";
- ctx.page.charset = NULL;
- cgit_print_http_headers();
-
- if (ctx.qry.sort)
- sorted = sort_repolist(ctx.qry.sort);
- else if (ctx.cfg.section_sort)
- sort_repolist("section");
-
- json("{");
- json_key("page"); json_value("repolist"); json(",");
- json_key("title"); json_value(ctx.cfg.root_title); json(",");
- json_key("root_desc"); json_value(ctx.cfg.root_desc); json(",");
- json_key("root_url"); json_value(cgit_rooturl()); json(",");
- json_key("about_url"); json_value("/-/about"); json(",");
- json_key("noheader"); json_bool(ctx.cfg.noheader); json(",");
- json_key("search"); json_value(ctx.qry.search); json(",");
- json_key("current_url"); { char *url = cgit_currenturl(); json_value(url); free(url); } json(",");
- json_key("root_readme"); json_bool(ctx.cfg.root_readme != NULL); json(",");
- json_key("owner_enabled"); json_bool(ctx.cfg.enable_index_owner); json(",");
- json_key("links_enabled"); json_bool(ctx.cfg.enable_index_links); json(",");
- json_key("section_grouping"); json_bool(!sorted); json(",");
- json_shell(); json(",");
- json_key("sort_urls"); json("{");
- { const char *names[] = {"name", "desc", "owner", "idle"}; for (i = 0; i < 4; i++) { char *url = sort_url(names[i]); if (i) json(","); json_key(names[i]); json_value(url); free(url); } }
- json("},");
- json_key("rows"); json("[");
- for (i = 0; i < cgit_repolist.count; i++) {
- struct cgit_repo *repo = &cgit_repolist.repos[i];
- time_t mtime;
- int populated;
- char *section, *url;
- if (!is_visible(repo)) continue;
- hits++;
- if (hits <= ctx.qry.ofs || hits > ctx.qry.ofs + ctx.cfg.max_repo_count) continue;
- section = repo->section && *repo->section ? repo->section : NULL;
- if (!sorted &&
- ((last_section == NULL && section != NULL) ||
- (last_section != NULL && section == NULL) ||
- (last_section != NULL && section != NULL &&
- strcmp(section, last_section)))) {
- if (!first) json(",");
- json("{"); json_key("section");
- if (section) json_value(section); else json("null");
- json("}");
- first = 0;
- last_section = section;
- }
- if (!first) json(",");
- json("{");
- json_key("name"); json_value(repo->name); json(",");
- json_key("section"); if (section) json_value(section); else json("null"); json(",");
- json_key("url"); url = repo_url(repo, NULL, NULL); json_value(url); free(url); json(",");
- json_key("description"); json_description(repo->desc); json(",");
- json_key("owner"); json_value(repo->owner); json(",");
- json_key("owner_url"); { char *current = cgit_currenturl(); struct strbuf owner = STRBUF_INIT; strbuf_addf(&owner, "%s?q=", current); url_arg(&owner, repo->owner); json_value(owner.buf); strbuf_release(&owner); free(current); } json(",");
- json_key("idle"); json_age(repo); json(",");
- populated = get_repo_modtime(repo, &mtime);
- json_key("log_url"); if (populated) { url = repo_url(repo, "log", ctx.qry.showmsg ? "showmsg=1" : NULL); json_value(url); free(url); } else json("null"); json(",");
- json_key("tree_url"); if (populated) { url = repo_url(repo, "tree", NULL); json_value(url); free(url); } else json("null");
- json("}"); first = 0;
- }
- json("],");
- json_key("pager"); json("[");
- if (hits > ctx.cfg.max_repo_count)
- for (i = 0; i * ctx.cfg.max_repo_count < hits; i++) { char *url = pager_url(ctx.qry.search, ctx.qry.sort, i * ctx.cfg.max_repo_count); if (i) json(","); json("{"); json_key("url"); json_value(url); free(url); json(","); json_key("current"); json_bool(ctx.qry.ofs == i * ctx.cfg.max_repo_count); json("}"); }
- json("]}");
-}
-
-void cgit_print_site_readme(void)
-{
- cgit_print_layout_start();
- if (!ctx.cfg.root_readme)
- goto done;
- html_include(ctx.cfg.root_readme);
-done:
- cgit_print_layout_end();
-}
diff --git a/cgit/ui-repolist.h b/cgit/ui-repolist.h
deleted file mode 100644
--- a/cgit/ui-repolist.h
+++ /dev/null
@@ -1,11 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-#ifndef UI_REPOLIST_H
-#define UI_REPOLIST_H
-
-extern void cgit_print_repolist(void);
-extern void cgit_print_site_readme(void);
-
-#endif /* UI_REPOLIST_H */
diff --git a/cgit/ui-shared.h b/cgit/ui-shared.h
index c1d055b..152cb0a 100644
--- a/cgit/ui-shared.h
+++ b/cgit/ui-shared.h
@@ -19,7 +19,6 @@ extern char *cgit_revurl(const char *reponame, const char *revision);
extern char *cgit_treeurl(const char *reponame, const char *revision,
const char *path, const char *fragment);
-extern void cgit_add_clone_urls(void (*fn)(const char *));
extern void cgit_index_link(const char *name, const char *title,
const char *class, const char *pattern, const char *sort, int ofs, int always_root);
@@ -87,6 +86,7 @@ extern void cgit_compose_snapshot_prefix(struct strbuf *filename,
extern void cgit_print_snapshot_links(const struct cgit_repo *repo,
const char *ref, const char *separator);
extern const char *cgit_snapshot_prefix(const struct cgit_repo *repo);
+extern void cgit_add_clone_urls(void (*fn)(const char *));
extern void cgit_add_hidden_formfields(int incl_head, int incl_search,
const char *page);
diff --git a/cgit/ui-summary.c b/cgit/ui-summary.c
deleted file mode 100644
--- a/cgit/ui-summary.c
+++ /dev/null
@@ -1,148 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-/* ui-summary.c: functions for generating repo summary page
- *
- * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
- *
- * Licensed under GNU General Public License v2
- * (see COPYING for full license text)
- */
-
-#include "cgit.h"
-#include "ui-summary.h"
-#include "html.h"
-#include "ui-blob.h"
-#include "ui-log.h"
-#include "ui-plain.h"
-#include "ui-refs.h"
-#include "ui-shared.h"
-
-static int urls;
-
-static void print_url(const char *url)
-{
- int columns = 3;
-
- if (ctx.repo->enable_log_filecount)
- columns++;
- if (ctx.repo->enable_log_linecount)
- columns++;
-
- if (urls++ == 0) {
- htmlf("<tr class='nohover'><td colspan='%d'>&nbsp;</td></tr>", columns);
- htmlf("<tr class='nohover'><th class='left' colspan='%d'>Clone</th></tr>\n", columns);
- }
-
- htmlf("<tr><td colspan='%d'><a rel='vcs-git' href='", columns);
- html_attr(url);
- html("' title='");
- html_attr(ctx.repo->name);
- html(" Git repository'>");
- html_txt(url);
- html("</a></td></tr>\n");
-}
-
-void cgit_print_summary(void)
-{
- int columns = 3;
-
- if (ctx.repo->enable_log_filecount)
- columns++;
- if (ctx.repo->enable_log_linecount)
- columns++;
-
- cgit_print_layout_start();
- html("<table summary='repository info' class='list nowrap'>");
- cgit_print_branches(ctx.cfg.summary_branches);
- htmlf("<tr class='nohover'><td colspan='%d'>&nbsp;</td></tr>", columns);
- cgit_print_tags(ctx.cfg.summary_tags);
- if (ctx.cfg.summary_log > 0) {
- htmlf("<tr class='nohover'><td colspan='%d'>&nbsp;</td></tr>", columns);
- cgit_print_log(ctx.qry.head, 0, ctx.cfg.summary_log, NULL,
- NULL, NULL, 0, 0, 0);
- }
- urls = 0;
- cgit_add_clone_urls(print_url);
- html("</table>");
- cgit_print_layout_end();
-}
-
-/* The caller must free the return value. */
-static char* append_readme_path(const char *filename, const char *ref, const char *path)
-{
- char *file, *base_dir, *full_path, *resolved_base = NULL, *resolved_full = NULL;
- /* If a subpath is specified for the about page, make it relative
- * to the directory containing the configured readme. */
-
- file = xstrdup(filename);
- base_dir = dirname(file);
- if (!strcmp(base_dir, ".") || !strcmp(base_dir, "..")) {
- if (!ref) {
- free(file);
- return NULL;
- }
- full_path = xstrdup(path);
- } else
- full_path = fmtalloc("%s/%s", base_dir, path);
-
- if (!ref) {
- resolved_base = realpath(base_dir, NULL);
- resolved_full = realpath(full_path, NULL);
- if (!resolved_base || !resolved_full || !starts_with(resolved_full, resolved_base)) {
- free(full_path);
- full_path = NULL;
- }
- }
-
- free(file);
- free(resolved_base);
- free(resolved_full);
-
- return full_path;
-}
-
-void cgit_print_repo_readme(const char *path)
-{
- char *filename, *ref, *mimetype;
- int free_filename = 0;
-
- mimetype = get_mimetype_for_filename(path);
- if (mimetype && (!strncmp(mimetype, "image/", 6) || !strncmp(mimetype, "video/", 6))) {
- ctx.page.mimetype = mimetype;
- ctx.page.charset = NULL;
- cgit_print_plain();
- free(mimetype);
- return;
- }
- free(mimetype);
-
- cgit_print_layout_start();
- if (ctx.repo->readme.nr == 0)
- goto done;
-
- filename = ctx.repo->readme.items[0].string;
- ref = ctx.repo->readme.items[0].util;
-
- if (path) {
- free_filename = 1;
- filename = append_readme_path(filename, ref, path);
- if (!filename)
- goto done;
- }
-
- /* Print the calculated readme from the git repo or filesystem as text. */
- html("<div id='summary'><pre>");
- if (ref)
- cgit_print_file(filename, ref, 1);
- else
- html_include_text(filename);
-
- html("</pre></div>");
- if (free_filename)
- free(filename);
-
-done:
- cgit_print_layout_end();
-}
diff --git a/cgit/ui-summary.h b/cgit/ui-summary.h
deleted file mode 100644
--- a/cgit/ui-summary.h
+++ /dev/null
@@ -1,11 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-#ifndef UI_SUMMARY_H
-#define UI_SUMMARY_H
-
-extern void cgit_print_summary(void);
-extern void cgit_print_repo_readme(const char *path);
-
-#endif /* UI_SUMMARY_H */
diff --git a/cgit/ui-tag.c b/cgit/ui-tag.c
deleted file mode 100644
--- a/cgit/ui-tag.c
+++ /dev/null
@@ -1,130 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-/* ui-tag.c: display a tag
- *
- * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
- *
- * Licensed under GNU General Public License v2
- * (see COPYING for full license text)
- */
-
-#define USE_THE_REPOSITORY_VARIABLE
-
-#include "cgit.h"
-#include "ui-tag.h"
-#include "html.h"
-#include "ui-shared.h"
-
-static void print_tag_content(char *buf)
-{
- char *p;
-
- if (!buf)
- return;
-
- html("<div class='commit-subject'>");
- p = strchr(buf, '\n');
- if (p)
- *p = '\0';
- html_txt(buf);
- html("</div>");
- if (p) {
- html("<div class='commit-msg'>");
- html_txt(++p);
- html("</div>");
- }
-}
-
-static void print_download_links(char *revname)
-{
- html("<tr><th>download</th><td class='oid'>");
- cgit_print_snapshot_links(ctx.repo, revname, "<br/>");
- html("</td></tr>");
-}
-
-void cgit_print_tag(char *revname)
-{
- struct strbuf fullref = STRBUF_INIT;
- struct object_id oid;
- struct object *obj;
- const char *displayname;
-
- if (!revname)
- revname = ctx.qry.head;
-
- if (skip_prefix(revname, "refs/tags/", &displayname))
- strbuf_addstr(&fullref, revname);
- else {
- displayname = revname;
- strbuf_addf(&fullref, "refs/tags/%s", revname);
- }
- if (repo_get_oid(the_repository, fullref.buf, &oid)) {
- cgit_print_error_page(404, "Not found",
- "Bad tag reference: %s", displayname);
- goto cleanup;
- }
- obj = parse_object(the_repository, &oid);
- if (!obj) {
- cgit_print_error_page(500, "Internal server error",
- "Bad object id: %s", oid_to_hex(&oid));
- goto cleanup;
- }
- if (obj->type == OBJ_TAG) {
- struct tag *tag;
- struct taginfo *info;
-
- tag = lookup_tag(the_repository, &oid);
- if (!tag || parse_tag(the_repository, tag) || !(info = cgit_parse_tag(tag))) {
- cgit_print_error_page(500, "Internal server error",
- "Bad tag object: %s", displayname);
- goto cleanup;
- }
- cgit_print_layout_start();
- html("<table class='commit-info'>\n");
- html("<tr><td>tag name</td><td>");
- html_txt(displayname);
- htmlf(" (%s)</td></tr>\n", oid_to_hex(&oid));
- if (info->tagger_date > 0) {
- html("<tr><td>tag date</td><td>");
- html_txt(show_date(info->tagger_date, info->tagger_tz,
- cgit_date_mode(DATE_ISO8601)));
- html("</td></tr>\n");
- }
- if (info->tagger) {
- html("<tr><td>tagged by</td><td>");
- html_txt(info->tagger);
- if (info->tagger_email && !ctx.cfg.noplainemail) {
- html(" ");
- html_txt(info->tagger_email);
- }
- html("</td></tr>\n");
- }
- html("<tr><td>tagged object</td><td class='oid'>");
- cgit_object_link(tag->tagged);
- html("</td></tr>\n");
- if (ctx.repo->snapshots)
- print_download_links(revname);
- html("</table>\n");
- print_tag_content(info->msg);
- cgit_print_layout_end();
- cgit_free_taginfo(info);
- } else {
- cgit_print_layout_start();
- html("<table class='commit-info'>\n");
- html("<tr><td>tag name</td><td>");
- html_txt(displayname);
- html("</td></tr>\n");
- html("<tr><td>tagged object</td><td class='oid'>");
- cgit_object_link(obj);
- html("</td></tr>\n");
- if (ctx.repo->snapshots)
- print_download_links(revname);
- html("</table>\n");
- cgit_print_layout_end();
- }
-
-cleanup:
- strbuf_release(&fullref);
-}
diff --git a/cgit/ui-tag.h b/cgit/ui-tag.h
deleted file mode 100644
--- a/cgit/ui-tag.h
+++ /dev/null
@@ -1,10 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-#ifndef UI_TAG_H
-#define UI_TAG_H
-
-extern void cgit_print_tag(char *revname);
-
-#endif /* UI_TAG_H */
diff --git a/cgit/ui-tree.c b/cgit/ui-tree.c
deleted file mode 100644
--- a/cgit/ui-tree.c
+++ /dev/null
@@ -1,400 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-/* ui-tree.c: functions for tree output
- *
- * Copyright (C) 2006-2017 cgit Development Team <cgit@lists.zx2c4.com>
- *
- * Licensed under GNU General Public License v2
- * (see COPYING for full license text)
- */
-
- #define USE_THE_REPOSITORY_VARIABLE
-
-#include "cgit.h"
-#include "ui-tree.h"
-#include "html.h"
-#include "ui-shared.h"
-
-struct walk_tree_context {
- char *curr_rev;
- char *match_path;
- int state;
-};
-
-static void print_text_buffer(char *buf, unsigned long size)
-{
- unsigned long lineno, idx;
- const char *numberfmt = "<a id='n%1$d' href='#n%1$d'>%1$d</a>\n";
-
- html("<table summary='blob content' class='blob'>\n");
-
- if (ctx.cfg.enable_tree_linenumbers) {
- html("<tr><td class='linenumbers'><pre>");
- idx = 0;
- lineno = 0;
-
- if (size) {
- htmlf(numberfmt, ++lineno);
- while (idx < size - 1) { // skip absolute last newline
- if (buf[idx] == '\n')
- htmlf(numberfmt, ++lineno);
- idx++;
- }
- }
- html("</pre></td>\n");
- }
- else {
- html("<tr>\n");
- }
-
- html("<td class='lines'><pre><code>");
- html_txt(buf);
- html("</code></pre></td></tr></table>\n");
-}
-
-#define ROWLEN 32
-
-static void print_binary_buffer(char *buf, unsigned long size)
-{
- unsigned long ofs, idx;
- static char ascii[ROWLEN + 1];
-
- html("<table summary='blob content' class='bin-blob'>\n");
- html("<tr><th>ofs</th><th>hex dump</th><th>ascii</th></tr>");
- for (ofs = 0; ofs < size; ofs += ROWLEN, buf += ROWLEN) {
- htmlf("<tr><td class='right'>%04lx</td><td class='hex'>", ofs);
- for (idx = 0; idx < ROWLEN && ofs + idx < size; idx++)
- htmlf("%*s%02x",
- idx == 16 ? 4 : 1, "",
- buf[idx] & 0xff);
- html(" </td><td class='hex'>");
- for (idx = 0; idx < ROWLEN && ofs + idx < size; idx++)
- ascii[idx] = isgraph(buf[idx]) ? buf[idx] : '.';
- ascii[idx] = '\0';
- html_txt(ascii);
- html("</td></tr>\n");
- }
- html("</table>\n");
-}
-
-static void print_object(const struct object_id *oid, const char *path, const char *rev)
-{
- enum object_type type;
- char *buf;
- unsigned long size;
- bool is_binary;
-
- type = odb_read_object_info(the_repository->objects, oid, &size);
- if (type == OBJ_BAD) {
- cgit_print_error_page(404, "Not found",
- "Bad object name: %s", oid_to_hex(oid));
- return;
- }
-
- buf = odb_read_object(the_repository->objects, oid, &type, &size);
- if (!buf) {
- cgit_print_error_page(500, "Internal server error",
- "Error reading object %s", oid_to_hex(oid));
- return;
- }
- is_binary = buffer_is_binary(buf, size);
-
- cgit_set_title_from_path(path);
-
- cgit_print_layout_start();
- htmlf("blob: %s (", oid_to_hex(oid));
- cgit_plain_link("plain", NULL, NULL, ctx.qry.head,
- rev, path);
- if (ctx.repo->enable_blame && !is_binary) {
- html(") (");
- cgit_blame_link("blame", NULL, NULL, ctx.qry.head,
- rev, path);
- }
- html(")\n");
-
- if (ctx.cfg.max_blob_size && size / 1024 > ctx.cfg.max_blob_size) {
- htmlf("<div class='error'>blob size (%ldKB) exceeds display size limit (%dKB).</div>",
- size / 1024, ctx.cfg.max_blob_size);
- return;
- }
-
- if (is_binary)
- print_binary_buffer(buf, size);
- else
- print_text_buffer(buf, size);
-
- free(buf);
-}
-
-struct single_tree_ctx {
- struct strbuf *path;
- struct object_id oid;
- char *name;
- size_t count;
-};
-
-static int single_tree_cb(const struct object_id *oid, struct strbuf *base,
- const char *pathname, unsigned mode, void *cbdata)
-{
- struct single_tree_ctx *ctx = cbdata;
-
- if (++ctx->count > 1)
- return -1;
-
- if (!S_ISDIR(mode)) {
- ctx->count = 2;
- return -1;
- }
-
- ctx->name = xstrdup(pathname);
- oidcpy(&ctx->oid, oid);
- strbuf_addf(ctx->path, "/%s", pathname);
- return 0;
-}
-
-static void write_tree_link(const struct object_id *oid, char *name,
- char *rev, struct strbuf *fullpath)
-{
- size_t initial_length = fullpath->len;
- struct tree *tree;
- struct single_tree_ctx tree_ctx = {
- .path = fullpath,
- .count = 1,
- };
- struct pathspec paths = {
- .nr = 0
- };
-
- oidcpy(&tree_ctx.oid, oid);
-
- while (tree_ctx.count == 1) {
- cgit_tree_link(name, NULL, "ls-dir", ctx.qry.head, rev,
- fullpath->buf);
-
- tree = lookup_tree(the_repository, &tree_ctx.oid);
- if (!tree)
- return;
-
- free(tree_ctx.name);
- tree_ctx.name = NULL;
- tree_ctx.count = 0;
-
- read_tree(the_repository, tree, &paths, single_tree_cb, &tree_ctx);
-
- if (tree_ctx.count != 1)
- break;
-
- html(" / ");
- name = tree_ctx.name;
- }
-
- strbuf_setlen(fullpath, initial_length);
-}
-
-static int ls_item(const struct object_id *oid, struct strbuf *base,
- const char *pathname, unsigned mode, void *cbdata)
-{
- struct walk_tree_context *walk_tree_ctx = cbdata;
- char *name;
- struct strbuf fullpath = STRBUF_INIT;
- struct strbuf linkpath = STRBUF_INIT;
- struct strbuf class = STRBUF_INIT;
- enum object_type type;
- unsigned long size = 0;
- char *buf;
-
- name = xstrdup(pathname);
- strbuf_addf(&fullpath, "%s%s%s", ctx.qry.path ? ctx.qry.path : "",
- ctx.qry.path ? "/" : "", name);
-
- if (!S_ISGITLINK(mode)) {
- type = odb_read_object_info(the_repository->objects, oid, &size);
- if (type == OBJ_BAD) {
- htmlf("<tr><td colspan='3'>Bad object: %s %s</td></tr>",
- name,
- oid_to_hex(oid));
- goto cleanup;
- }
- }
-
- html("<tr><td class='ls-mode'>");
- cgit_print_filemode(mode);
- html("</td><td>");
- if (S_ISGITLINK(mode)) {
- cgit_submodule_link("ls-mod", fullpath.buf, oid_to_hex(oid));
- } else if (S_ISDIR(mode)) {
- write_tree_link(oid, name, walk_tree_ctx->curr_rev,
- &fullpath);
- } else {
- char *ext = strrchr(name, '.');
- strbuf_addstr(&class, "ls-blob");
- if (ext)
- strbuf_addf(&class, " %s", ext + 1);
- cgit_tree_link(name, NULL, class.buf, ctx.qry.head,
- walk_tree_ctx->curr_rev, fullpath.buf);
- }
- if (S_ISLNK(mode)) {
- html(" -> ");
- buf = odb_read_object(the_repository->objects, oid, &type, &size);
- if (!buf) {
- htmlf("Error reading object: %s", oid_to_hex(oid));
- goto cleanup;
- }
- strbuf_addbuf(&linkpath, &fullpath);
- strbuf_addf(&linkpath, "/../%s", buf);
- strbuf_normalize_path(&linkpath);
- cgit_tree_link(buf, NULL, class.buf, ctx.qry.head,
- walk_tree_ctx->curr_rev, linkpath.buf);
- free(buf);
- strbuf_release(&linkpath);
- }
- htmlf("</td><td class='ls-size'>%li</td>", size);
-
- html("<td>");
- cgit_log_link("log", NULL, "button", ctx.qry.head,
- walk_tree_ctx->curr_rev, fullpath.buf, 0, NULL, NULL,
- ctx.qry.showmsg, 0);
- if (ctx.repo->max_stats)
- cgit_stats_link("stats", NULL, "button", ctx.qry.head,
- fullpath.buf);
- if (!S_ISGITLINK(mode))
- cgit_plain_link("plain", NULL, "button", ctx.qry.head,
- walk_tree_ctx->curr_rev, fullpath.buf);
- if (!S_ISDIR(mode) && ctx.repo->enable_blame)
- cgit_blame_link("blame", NULL, "button", ctx.qry.head,
- walk_tree_ctx->curr_rev, fullpath.buf);
- html("</td></tr>\n");
-
-cleanup:
- free(name);
- strbuf_release(&fullpath);
- strbuf_release(&class);
- return 0;
-}
-
-static void ls_head(void)
-{
- cgit_print_layout_start();
- html("<table summary='tree listing' class='list'>\n");
- html("<tr class='nohover'>");
- html("<th class='left'>Mode</th>");
- html("<th class='left'>Name</th>");
- html("<th class='right'>Size</th>");
- html("<th/>");
- html("</tr>\n");
-}
-
-static void ls_tail(void)
-{
- html("</table>\n");
- cgit_print_layout_end();
-}
-
-static void ls_tree(const struct object_id *oid, const char *path, struct walk_tree_context *walk_tree_ctx)
-{
- struct tree *tree;
- struct pathspec paths = {
- .nr = 0
- };
-
- tree = parse_tree_indirect(oid);
- if (!tree) {
- cgit_print_error_page(404, "Not found",
- "Not a tree object: %s", oid_to_hex(oid));
- return;
- }
-
- ls_head();
- read_tree(the_repository, tree, &paths, ls_item, walk_tree_ctx);
- ls_tail();
-}
-
-
-static int walk_tree(const struct object_id *oid, struct strbuf *base,
- const char *pathname, unsigned mode, void *cbdata)
-{
- struct walk_tree_context *walk_tree_ctx = cbdata;
-
- if (walk_tree_ctx->state == 0) {
- struct strbuf buffer = STRBUF_INIT;
-
- strbuf_addbuf(&buffer, base);
- strbuf_addstr(&buffer, pathname);
- if (strcmp(walk_tree_ctx->match_path, buffer.buf))
- return READ_TREE_RECURSIVE;
-
- if (S_ISDIR(mode)) {
- walk_tree_ctx->state = 1;
- cgit_set_title_from_path(buffer.buf);
- strbuf_release(&buffer);
- ls_head();
- return READ_TREE_RECURSIVE;
- } else {
- walk_tree_ctx->state = 2;
- print_object(oid, buffer.buf, walk_tree_ctx->curr_rev);
- strbuf_release(&buffer);
- return 0;
- }
- }
- ls_item(oid, base, pathname, mode, walk_tree_ctx);
- return 0;
-}
-
-/*
- * Show a tree or a blob
- * rev: the commit pointing at the root tree object
- * path: path to tree or blob
- */
-void cgit_print_tree(const char *rev, char *path)
-{
- struct object_id oid;
- struct commit *commit;
- struct pathspec_item path_items = {
- .match = path,
- .len = path ? strlen(path) : 0
- };
- struct pathspec paths = {
- .nr = path ? 1 : 0,
- .items = &path_items
- };
- struct walk_tree_context walk_tree_ctx = {
- .match_path = path,
- .state = 0
- };
-
- if (!rev)
- rev = ctx.qry.head;
-
- if (repo_get_oid(the_repository, rev, &oid)) {
- cgit_print_error_page(404, "Not found",
- "Invalid revision name: %s", rev);
- return;
- }
- commit = lookup_commit_reference(the_repository, &oid);
- if (!commit || repo_parse_commit(the_repository, commit)) {
- cgit_print_error_page(404, "Not found",
- "Invalid commit reference: %s", rev);
- return;
- }
-
- walk_tree_ctx.curr_rev = xstrdup(rev);
-
- if (path == NULL) {
- ls_tree(get_commit_tree_oid(commit), NULL, &walk_tree_ctx);
- goto cleanup;
- }
-
- read_tree(the_repository, repo_get_commit_tree(the_repository, commit),
- &paths, walk_tree, &walk_tree_ctx);
- if (walk_tree_ctx.state == 1)
- ls_tail();
- else if (walk_tree_ctx.state == 2)
- cgit_print_layout_end();
- else
- cgit_print_error_page(404, "Not found", "Path not found");
-
-cleanup:
- free(walk_tree_ctx.curr_rev);
-}
diff --git a/cgit/ui-tree.h b/cgit/ui-tree.h
deleted file mode 100644
--- a/cgit/ui-tree.h
+++ /dev/null
@@ -1,10 +0,0 @@
-/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
- * SPDX-License-Identifier: GPL-2.0-only
- */
-
-#ifndef UI_TREE_H
-#define UI_TREE_H
-
-extern void cgit_print_tree(const char *rev, char *path);
-
-#endif /* UI_TREE_H */
diff --git a/crates/gilti-ssh/Cargo.toml b/crates/gilti-ssh/Cargo.toml
index bbebe68..1fc17cd 100644
--- a/crates/gilti-ssh/Cargo.toml
+++ b/crates/gilti-ssh/Cargo.toml
@@ -12,6 +12,7 @@ authors.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
+rust-version.workspace = true
[dependencies]
libc.workspace = true
diff --git a/crates/gilti/Cargo.toml b/crates/gilti/Cargo.toml
index fee4d7c..a20c3dd 100644
--- a/crates/gilti/Cargo.toml
+++ b/crates/gilti/Cargo.toml
@@ -12,9 +12,11 @@ license.workspace = true
publish.workspace = true
homepage.workspace = true
repository.workspace = true
+rust-version.workspace = true
[dependencies]
axum.workspace = true
+git2.workspace = true
percent-encoding.workspace = true
tokio.workspace = true
tower.workspace = true
diff --git a/crates/gilti/src/lfs.rs b/crates/gilti/src/lfs.rs
index 7f883fb..01371ee 100644
--- a/crates/gilti/src/lfs.rs
+++ b/crates/gilti/src/lfs.rs
@@ -74,12 +74,13 @@ pub async fn serve(
if path == "objects/batch" && method == axum::http::Method::POST {
return batch(objects, write_enabled, request).await;
}
- if let Some(oid) = path
- .strip_prefix("objects/")
- .and_then(|value| value.strip_suffix("/verify"))
- && method == axum::http::Method::POST
- {
- return verify(&objects, oid, request).await;
+ if method == axum::http::Method::POST {
+ if let Some(oid) = path
+ .strip_prefix("objects/")
+ .and_then(|value| value.strip_suffix("/verify"))
+ {
+ return verify(&objects, oid, request).await;
+ }
}
if let Some(oid) = path.strip_prefix("objects/") {
if !valid_oid(oid) {
diff --git a/crates/gilti/src/main.rs b/crates/gilti/src/main.rs
index f7e4cd0..2fbc1f6 100644
--- a/crates/gilti/src/main.rs
+++ b/crates/gilti/src/main.rs
@@ -3,8 +3,9 @@
mod cgi;
mod lfs;
+mod models;
pub mod router;
-mod ui;
+mod views;
const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8080";
const DEFAULT_CACHE_SECONDS: &str = "5";
@@ -26,26 +27,17 @@ const CGIT_LOGO: &str = "/usr/share/webapps/cgit/cgit.png";
const CGIT_FAVICON: &str = "/usr/share/webapps/cgit/favicon.ico";
const CGIT_ENVIRONMENT: &[(&str, &str)] = &[
- ("CGIT_AGEFILE", "info/web/last-modified"),
- ("CGIT_BRANCH_SORT", "0"),
- ("CGIT_CASE_SENSITIVE_SORT", "1"),
("CGIT_CLONE_URL", ""),
("CGIT_COMMIT_SORT", "0"),
("CGIT_CSS", "/-/assets/cgit.css"),
("CGIT_DIFFTYPE", "0"),
("CGIT_EMBEDDED", "0"),
- ("CGIT_ENABLE_BLAME", "1"),
("CGIT_ENABLE_COMMIT_GRAPH", "1"),
("CGIT_ENABLE_FOLLOW_LINKS", "0"),
- ("CGIT_ENABLE_HTML_SERVING", "1"),
- ("CGIT_ENABLE_HTTP_CLONE", "1"),
- ("CGIT_ENABLE_INDEX_LINKS", "1"),
- ("CGIT_ENABLE_INDEX_OWNER", "1"),
("CGIT_ENABLE_LOG_FILECOUNT", "1"),
("CGIT_ENABLE_LOG_LINECOUNT", "1"),
("CGIT_ENABLE_REMOTE_BRANCHES", "0"),
("CGIT_ENABLE_SUBJECT_LINKS", "0"),
- ("CGIT_ENABLE_TREE_LINENUMBERS", "1"),
("CGIT_FAVICON", "/-/assets/favicon.ico"),
("CGIT_FOOTER", ""),
("CGIT_HEADER", ""),
@@ -55,11 +47,8 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[
("CGIT_LOGO", "/-/assets/cgit.png"),
("CGIT_LOGO_LINK", ""),
("CGIT_MAX_ATOM_ITEMS", "10"),
- ("CGIT_MAX_BLOB_SIZE", "0"),
("CGIT_MAX_COMMIT_COUNT", "50"),
("CGIT_MAX_MESSAGE_LENGTH", "80"),
- ("CGIT_MAX_REPO_COUNT", "50"),
- ("CGIT_MAX_REPODESC_LENGTH", "80"),
("CGIT_MAX_STATS", "4"),
("CGIT_MIMETYPE_FILE", ""),
("CGIT_MODULE_LINK", ""),
@@ -67,22 +56,11 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[
("CGIT_NOPLAINEMAIL", "0"),
("CGIT_README_0", ":README.md"),
("CGIT_README_1", ":README"),
- ("CGIT_REMOVE_SUFFIX", "1"),
("CGIT_REPO_DEFAULT_DESC", "[no description]"),
("CGIT_RENAMELIMIT", "-1"),
- ("CGIT_REPOSITORY_SORT", "name"),
("CGIT_ROBOTS", "index, nofollow"),
- ("CGIT_ROOT_README", ""),
- ("CGIT_SCAN_HIDDEN_PATH", "0"),
- ("CGIT_SCAN_PATH", "/var/lib/gilti/git/repositories"),
("CGIT_SECTION", ""),
- ("CGIT_SECTION_FROM_PATH", "0"),
- ("CGIT_SECTION_SORT", "1"),
("CGIT_SNAPSHOTS", "2147483647"),
- ("CGIT_STRICT_EXPORT", ""),
- ("CGIT_SUMMARY_BRANCHES", "10"),
- ("CGIT_SUMMARY_LOG", "10"),
- ("CGIT_SUMMARY_TAGS", "10"),
("CGIT_VIRTUAL_ROOT", "/"),
("GIT_ATTR_NOSYSTEM", "1"),
("GIT_CONFIG_NOSYSTEM", "1"),
@@ -92,6 +70,7 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[
struct RepositoryService {
cgit: cgi::Cgi,
git: cgi::Cgi,
+ views: views::shared::Context,
write_enabled: bool,
}
@@ -166,6 +145,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
+ let views = views::shared::Context {
+ repositories: REPOSITORIES,
+ root_title: std::sync::Arc::from(config.root_title.clone()),
+ root_description: std::sync::Arc::from(config.root_description.clone()),
+ clone_prefix: std::sync::Arc::from(config.clone_prefix.clone()),
+ };
let mut cgit = cgi::Cgi::new(CGIT, GIT_HOME, config.listen_addr)
.cache(config.cache)
.env("CGIT_ROOT_TITLE", config.root_title)
@@ -187,6 +172,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let repositories = RepositoryService {
cgit,
git,
+ views,
write_enabled: config.http_write,
};
let app = axum::Router::new()
@@ -329,10 +315,91 @@ impl RepositoryService {
{
request.extensions_mut().insert(cgi::RemoteAddr(remote));
}
+ let migrated = matches!(
+ &route,
+ router::Route::Repositories
+ | router::Route::Overview(_)
+ | router::Route::About(_)
+ | router::Route::Object(_)
+ | router::Route::Refs(_)
+ | router::Route::Tree(_)
+ | router::Route::Blame(_)
+ | router::Route::ArchiveSignature(_)
+ ) || matches!(
+ &route,
+ router::Route::Revision(router::RepoRoute {
+ params: router::Revision::Ref(reference),
+ ..
+ }) if reference.starts_with("refs/tags/")
+ );
+ let query = if migrated {
+ let query = match request_query(request.uri().query()) {
+ Ok(query) => query,
+ Err(()) => {
+ return plain_response(axum::http::StatusCode::BAD_REQUEST, "bad query\n");
+ }
+ };
+ if !valid_format(&route, query.format.as_deref()) {
+ return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n");
+ }
+ Some(query)
+ } else {
+ None
+ };
+ let format = query.as_ref().and_then(|query| query.format.as_deref());
match route {
+ router::Route::Repositories => {
+ views::repositories::serve(
+ &self.views,
+ views::repositories::Query::from_request(query.as_ref().expect("query parsed")),
+ request.method().clone(),
+ )
+ .await
+ }
router::Route::Summary(route) => redirect(&route.repo),
router::Route::GitClone(route) => redirect(&route.repo),
+ router::Route::Overview(route) => {
+ views::overview::serve(
+ &self.views,
+ route,
+ request.headers(),
+ request.method().clone(),
+ )
+ .await
+ }
+ router::Route::About(route) => {
+ views::about::serve(&self.views, route, request.method().clone()).await
+ }
+ router::Route::Object(route) => {
+ views::object::serve(REPOSITORIES, route, request.method().clone()).await
+ }
+ router::Route::Refs(route) => {
+ views::refs::serve(&self.views, route, request.method().clone()).await
+ }
+ router::Route::Tree(route) => {
+ views::tree::serve(&self.views, route, format, request.method().clone()).await
+ }
+ router::Route::Blame(route) => {
+ views::blame::serve(&self.views, route, request.method().clone()).await
+ }
+ router::Route::ArchiveSignature(route) => {
+ views::archive_signature::serve(
+ &self.views,
+ route,
+ format,
+ request.method().clone(),
+ )
+ .await
+ }
+ router::Route::Revision(route)
+ if matches!(
+ &route.params,
+ router::Revision::Ref(reference) if reference.starts_with("refs/tags/")
+ ) =>
+ {
+ views::tag::serve(&self.views, route, request.method().clone()).await
+ }
router::Route::GitLfs(route) => {
lfs::serve(
std::path::Path::new(REPOSITORIES),
@@ -419,16 +486,43 @@ impl RepositoryService {
if !valid_format(&route, query.format.as_deref()) {
return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n");
}
- let environment = match cgit_environment(route, query, request.uri().path()) {
+ let mut environment = match cgit_environment(route, query, request.uri().path()) {
Ok(environment) => environment,
Err(()) => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"),
};
+ let repository = environment
+ .iter()
+ .find(|(name, _)| name == std::ffi::OsStr::new("GILTI_REPOSITORY"))
+ .and_then(|(_, value)| value.to_str());
+ let repository_path = match repository {
+ Some(repository) => crate::models::repository::path(
+ std::path::Path::new(self.views.repositories),
+ repository,
+ ),
+ None => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"),
+ };
+ let repository_path = match repository_path {
+ Ok(path) => path,
+ Err(crate::models::Error::NotFound) => {
+ return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n");
+ }
+ Err(crate::models::Error::Internal(error)) => {
+ eprintln!("gilti: repository lookup failed: {error}");
+ return plain_response(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "internal server error\n",
+ );
+ }
+ };
+ environment.push((
+ "GILTI_REPOSITORY_PATH".into(),
+ repository_path.into_os_string(),
+ ));
request
.extensions_mut()
.insert(cgi::Environment(environment));
- let method = request.method().clone();
match tower::ServiceExt::oneshot(self.cgit.clone(), request).await {
- Ok(response) => render_private_page(method, response).await,
+ Ok(response) => response,
Err(error) => internal_error("cgit", error),
}
}
@@ -440,6 +534,15 @@ struct RequestQuery {
environment: Vec<(std::ffi::OsString, std::ffi::OsString)>,
}
+impl RequestQuery {
+ fn value(&self, name: &str) -> Option<&str> {
+ self.environment
+ .iter()
+ .find(|(key, _)| key == std::ffi::OsStr::new(name))
+ .and_then(|(_, value)| value.to_str())
+ }
+}
+
fn request_query(query: Option<&str>) -> Result<RequestQuery, ()> {
let mut result = RequestQuery::default();
for pair in query
@@ -519,28 +622,10 @@ fn cgit_environment(
set("GILTI_CURRENT_URL", current_url.to_owned());
match route {
- router::Route::Repositories => set("GILTI_PAGE", "repolist".to_owned()),
- router::Route::Overview(route) => {
- set("GILTI_REPOSITORY", route.repo);
- set("GILTI_PAGE", "summary".to_owned());
- }
- router::Route::About(route) => {
- set("GILTI_REPOSITORY", route.repo);
- set("GILTI_PAGE", "about".to_owned());
- }
router::Route::Stats(route) => {
set("GILTI_REPOSITORY", route.repo);
set("GILTI_PAGE", "stats".to_owned());
}
- router::Route::Object(route) => {
- set("GILTI_REPOSITORY", route.repo);
- set("GILTI_PAGE", "blob".to_owned());
- set("GILTI_REVISION", route.params);
- }
- router::Route::Refs(route) => {
- set("GILTI_REPOSITORY", route.repo);
- set("GILTI_PAGE", "refs".to_owned());
- }
router::Route::Revision(route) => {
set("GILTI_REPOSITORY", route.repo);
set("GILTI_PAGE", "revision".to_owned());
@@ -554,28 +639,6 @@ fn cgit_environment(
set("GILTI_PATH", path);
}
}
- router::Route::Tree(route) => {
- set("GILTI_REPOSITORY", route.repo);
- set(
- "GILTI_PAGE",
- if format == Some("raw") {
- "plain"
- } else {
- "tree"
- }
- .to_owned(),
- );
- set("GILTI_REVISION", revision(route.params.rev));
- if let Some(path) = route.params.path {
- set("GILTI_PATH", path);
- }
- }
- router::Route::Blame(route) => {
- set("GILTI_REPOSITORY", route.repo);
- set("GILTI_PAGE", "blame".to_owned());
- set("GILTI_REVISION", revision(route.params.rev));
- set("GILTI_PATH", route.params.path);
- }
router::Route::Archive(route) => {
set("GILTI_REPOSITORY", route.repo);
set("GILTI_PAGE", "snapshot".to_owned());
@@ -585,13 +648,6 @@ fn cgit_environment(
set("GILTI_PATH", path);
}
}
- router::Route::ArchiveSignature(route) => {
- set("GILTI_REPOSITORY", route.repo);
- set("GILTI_PAGE", "snapshot".to_owned());
- set("GILTI_REVISION", revision(route.params));
- set("GILTI_FORMAT", format.unwrap_or("tar.gz").to_owned());
- set("GILTI_SIGNATURE", "1".to_owned());
- }
router::Route::AtomFeed(route) => {
set("GILTI_REPOSITORY", route.repo);
set("GILTI_PAGE", "atom".to_owned());
@@ -692,52 +748,6 @@ fn internal_error(context: &str, error: std::io::Error) -> axum::response::Respo
)
}
-async fn render_private_page(
- method: axum::http::Method,
- response: axum::response::Response,
-) -> axum::response::Response {
- let private_page = response
- .headers()
- .get(axum::http::header::CONTENT_TYPE)
- .is_some_and(|value| value.as_bytes() == ui::PRIVATE_CONTENT_TYPE.as_bytes());
- if !private_page {
- return response;
- }
-
- let (mut parts, body) = response.into_parts();
- parts.headers.insert(
- axum::http::header::CONTENT_TYPE,
- axum::http::HeaderValue::from_static("text/html; charset=UTF-8"),
- );
- parts.headers.remove(axum::http::header::CONTENT_LENGTH);
- if method == axum::http::Method::HEAD {
- return axum::http::response::Response::from_parts(parts, axum::body::Body::empty());
- }
- let body = match axum::body::to_bytes(body, usize::MAX).await {
- Ok(body) => body,
- Err(error) => {
- eprintln!("gilti: cannot read private page response: {error}");
- return plain_response(
- axum::http::StatusCode::INTERNAL_SERVER_ERROR,
- "internal server error\n",
- );
- }
- };
- match ui::render(&body) {
- Ok(markup) => axum::http::response::Response::from_parts(
- parts,
- axum::body::Body::from(markup.into_string()),
- ),
- Err(error) => {
- eprintln!("gilti: invalid private page response: {error}");
- plain_response(
- axum::http::StatusCode::INTERNAL_SERVER_ERROR,
- "internal server error\n",
- )
- }
- }
-}
-
fn response(
status: axum::http::StatusCode,
content_type: &'static str,
@@ -809,78 +819,4 @@ mod tests {
assert!(super::parse_cache("3601").is_err());
assert!(super::parse_cache("forever").is_err());
}
-
- #[tokio::test]
- async fn private_pages_become_html_and_head_keeps_headers() {
- let model = br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/-/about","noheader":true,"search":"","current_url":"/","root_readme":false,"owner_enabled":false,"links_enabled":false,"section_grouping":false,"shell":{"embedded":false,"robots":"","css":[],"js":[],"favicon":"","head_include":null,"header":null,"footer_configured":false,"footer":null,"logo":"","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[],"pager":[]}"#;
- let response = axum::http::Response::builder()
- .status(axum::http::StatusCode::OK)
- .header(
- axum::http::header::CONTENT_TYPE,
- super::ui::PRIVATE_CONTENT_TYPE,
- )
- .header(axum::http::header::CACHE_CONTROL, "max-age=60")
- .header(axum::http::header::CONTENT_LENGTH, model.len())
- .body(axum::body::Body::from(model.as_slice()))
- .unwrap();
- let response = super::render_private_page(axum::http::Method::GET, response).await;
- assert_eq!(
- response.headers()[axum::http::header::CONTENT_TYPE],
- "text/html; charset=UTF-8"
- );
- assert_eq!(
- response.headers()[axum::http::header::CACHE_CONTROL],
- "max-age=60"
- );
- assert!(
- !response
- .headers()
- .contains_key(axum::http::header::CONTENT_LENGTH)
- );
- let body = axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .unwrap();
- assert!(
- std::str::from_utf8(&body)
- .unwrap()
- .contains("repository list")
- );
-
- let response = axum::http::Response::builder()
- .header(
- axum::http::header::CONTENT_TYPE,
- super::ui::PRIVATE_CONTENT_TYPE,
- )
- .header(axum::http::header::CACHE_CONTROL, "max-age=60")
- .body(axum::body::Body::empty())
- .unwrap();
- let response = super::render_private_page(axum::http::Method::HEAD, response).await;
- assert_eq!(
- response.headers()[axum::http::header::CONTENT_TYPE],
- "text/html; charset=UTF-8"
- );
- assert!(
- axum::body::to_bytes(response.into_body(), usize::MAX)
- .await
- .unwrap()
- .is_empty()
- );
- }
-
- #[tokio::test]
- async fn malformed_private_page_fails_closed() {
- let response = axum::http::Response::builder()
- .header(
- axum::http::header::CONTENT_TYPE,
- super::ui::PRIVATE_CONTENT_TYPE,
- )
- .body(axum::body::Body::from("not json"))
- .unwrap();
- assert_eq!(
- super::render_private_page(axum::http::Method::GET, response)
- .await
- .status(),
- axum::http::StatusCode::INTERNAL_SERVER_ERROR
- );
- }
}
diff --git a/crates/gilti/src/models/about.rs b/crates/gilti/src/models/about.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/models/about.rs
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::path::Path;
+
+pub struct About {
+ pub repository: super::repository::Info,
+ pub bytes: Vec<u8>,
+}
+
+impl About {
+ pub fn load(root: &Path, name: &str) -> Result<Self, super::Error> {
+ let repository = super::repository::open(root, name)?;
+ let info = super::repository::info(&repository, name);
+ let commit = super::revision::commit(&repository, &crate::router::Revision::Head)?;
+ let tree = commit.tree().map_err(super::Error::from_git)?;
+ let blob = ["README.md", "README"]
+ .iter()
+ .find_map(|path| {
+ tree.get_path(Path::new(path))
+ .ok()
+ .filter(|entry| entry.kind() == Some(git2::ObjectType::Blob))
+ .and_then(|entry| repository.find_blob(entry.id()).ok())
+ })
+ .ok_or(super::Error::NotFound)?;
+ Ok(Self {
+ repository: info,
+ bytes: blob.content().to_vec(),
+ })
+ }
+}
diff --git a/crates/gilti/src/models/archive_signature.rs b/crates/gilti/src/models/archive_signature.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/models/archive_signature.rs
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::path::Path;
+
+pub struct ArchiveSignature {
+ pub bytes: Vec<u8>,
+ pub oid: String,
+ pub filename: String,
+}
+
+impl ArchiveSignature {
+ pub fn load(
+ root: &Path,
+ name: &str,
+ revision: crate::router::Revision,
+ format: &str,
+ ) -> Result<Self, super::Error> {
+ let repository = super::repository::open(root, name)?;
+ let commit = super::revision::commit(&repository, &revision)?;
+ let notes_ref = format!("refs/notes/signatures/{format}");
+ let note = repository
+ .find_note(Some(&notes_ref), commit.id())
+ .map_err(super::Error::from_git)?;
+ Ok(Self {
+ bytes: note.message_bytes().to_vec(),
+ oid: note.id().to_string(),
+ filename: format!("{}.{}.asc", name.rsplit('/').next().unwrap_or(name), format),
+ })
+ }
+}
diff --git a/crates/gilti/src/models/blame.rs b/crates/gilti/src/models/blame.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/models/blame.rs
@@ -0,0 +1,114 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::path::Path;
+
+pub struct Blame {
+ pub repository: super::repository::Info,
+ pub revision: String,
+ pub path: String,
+ pub oid: String,
+ pub bytes: Vec<u8>,
+ pub binary: bool,
+ pub hunks: Vec<Hunk>,
+}
+
+pub struct Hunk {
+ pub oid: String,
+ pub short_oid: String,
+ pub start: usize,
+ pub lines: usize,
+ pub original_path: String,
+ pub author: String,
+ pub author_email: String,
+ pub committer: String,
+ pub committer_email: String,
+ pub timestamp: i64,
+ pub summary: String,
+ pub parent: Option<String>,
+}
+
+impl Blame {
+ pub fn load(
+ root: &Path,
+ name: &str,
+ revision: crate::router::Revision,
+ path: String,
+ ) -> Result<Self, super::Error> {
+ let repository = super::repository::open(root, name)?;
+ let info = super::repository::info(&repository, name);
+ let selector = super::revision::selector(&revision);
+ let commit = super::revision::commit(&repository, &revision)?;
+ let tree = commit.tree().map_err(super::Error::from_git)?;
+ let entry = tree
+ .get_path(Path::new(&path))
+ .map_err(super::Error::from_git)?;
+ if !matches!(entry.filemode_raw(), 0o100644 | 0o100755) {
+ return Err(super::Error::NotFound);
+ }
+ let blob = repository
+ .find_blob(entry.id())
+ .map_err(super::Error::from_git)?;
+ let bytes = blob.content().to_vec();
+ let binary = bytes.iter().take(8000).any(|byte| *byte == 0);
+ let mut hunks = Vec::new();
+ if !binary {
+ let mut options = git2::BlameOptions::new();
+ options.newest_commit(commit.id());
+ let blame = repository
+ .blame_file(Path::new(&path), Some(&mut options))
+ .map_err(super::Error::from_git)?;
+ for hunk in blame.iter() {
+ let oid = hunk.final_commit_id();
+ let commit = repository
+ .find_commit(oid)
+ .map_err(super::Error::from_git)?;
+ let author = hunk.final_signature().unwrap_or_else(|| commit.author());
+ let committer = hunk.final_committer().unwrap_or_else(|| commit.committer());
+ hunks.push(Hunk {
+ oid: oid.to_string(),
+ short_oid: oid.to_string().chars().take(7).collect(),
+ start: hunk.final_start_line(),
+ lines: hunk.lines_in_hunk(),
+ original_path: hunk
+ .path()
+ .map_or_else(|| path.clone(), |path| path.to_string_lossy().into_owned()),
+ author: signature_name(&author),
+ author_email: signature_email(&author),
+ committer: signature_name(&committer),
+ committer_email: signature_email(&committer),
+ timestamp: author.when().seconds(),
+ summary: String::from_utf8_lossy(
+ hunk.summary_bytes()
+ .unwrap_or(commit.summary_bytes().unwrap_or_default()),
+ )
+ .into_owned(),
+ parent: commit.parent_id(0).ok().map(|oid| oid.to_string()),
+ });
+ }
+ }
+ Ok(Self {
+ repository: info,
+ revision: selector,
+ path,
+ oid: entry.id().to_string(),
+ bytes,
+ binary,
+ hunks,
+ })
+ }
+}
+
+fn signature_name(signature: &git2::Signature<'_>) -> String {
+ signature
+ .name()
+ .map(str::to_owned)
+ .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned())
+}
+
+fn signature_email(signature: &git2::Signature<'_>) -> String {
+ signature
+ .email()
+ .map(str::to_owned)
+ .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned())
+}
diff --git a/crates/gilti/src/models/mod.rs b/crates/gilti/src/models/mod.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/models/mod.rs
@@ -0,0 +1,33 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+pub mod about;
+pub mod archive_signature;
+pub mod blame;
+pub mod object;
+pub mod overview;
+pub mod refs;
+pub mod repositories;
+pub mod repository;
+pub mod revision;
+pub mod tag;
+pub mod tree;
+
+#[derive(Debug)]
+pub enum Error {
+ NotFound,
+ Internal(String),
+}
+
+impl Error {
+ pub fn from_git(error: git2::Error) -> Self {
+ if matches!(
+ error.code(),
+ git2::ErrorCode::NotFound | git2::ErrorCode::UnbornBranch
+ ) {
+ Self::NotFound
+ } else {
+ Self::Internal(error.message().to_owned())
+ }
+ }
+}
diff --git a/crates/gilti/src/models/object.rs b/crates/gilti/src/models/object.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/models/object.rs
@@ -0,0 +1,87 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::path::Path;
+
+pub struct RawObject {
+ pub bytes: Vec<u8>,
+ pub binary: bool,
+}
+
+impl RawObject {
+ pub fn load(root: &Path, repository: &str, oid: &str) -> Result<Self, super::Error> {
+ let repository = super::repository::open(root, repository)?;
+ let oid = git2::Oid::from_str_ext(oid, repository.object_format())
+ .map_err(|_| super::Error::NotFound)?;
+ let odb = repository.odb().map_err(super::Error::from_git)?;
+ let object = odb.read(oid).map_err(super::Error::from_git)?;
+ let bytes = object.data().to_vec();
+ let binary = bytes.iter().take(8000).any(|byte| *byte == 0);
+ Ok(Self { bytes, binary })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ fn fixture() -> (std::path::PathBuf, git2::Repository) {
+ let root = std::env::temp_dir().join(format!(
+ "gilti-object-test-{}-{}",
+ std::process::id(),
+ std::thread::current().name().unwrap_or("thread")
+ ));
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&root).unwrap();
+ let repository = git2::Repository::init_bare(root.join("example.git")).unwrap();
+ (root, repository)
+ }
+
+ #[test]
+ fn reads_raw_text_and_binary_objects_like_cgit_blob() {
+ let (root, repository) = fixture();
+ let odb = repository.odb().unwrap();
+ let text_oid = odb.write(git2::ObjectType::Blob, b"hello\n").unwrap();
+ let binary_oid = odb
+ .write(git2::ObjectType::Blob, b"binary\0payload")
+ .unwrap();
+
+ let text = super::RawObject::load(&root, "example", &text_oid.to_string()).unwrap();
+ assert_eq!(text.bytes, b"hello\n");
+ assert!(!text.binary);
+ let binary = super::RawObject::load(&root, "example", &binary_oid.to_string()).unwrap();
+ assert_eq!(binary.bytes, b"binary\0payload");
+ assert!(binary.binary);
+
+ std::fs::remove_dir_all(root).unwrap();
+ }
+
+ #[test]
+ fn reads_sha256_objects_with_the_repository_format() {
+ let root =
+ std::env::temp_dir().join(format!("gilti-object-sha256-test-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&root).unwrap();
+ let mut options = git2::RepositoryInitOptions::new();
+ options.bare(true).object_format(git2::ObjectFormat::Sha256);
+ let repository = git2::Repository::init_opts(root.join("example.git"), &options).unwrap();
+ let oid = repository
+ .odb()
+ .unwrap()
+ .write(git2::ObjectType::Blob, b"sha256\n")
+ .unwrap();
+ assert_eq!(oid.to_string().len(), 64);
+
+ let object = super::RawObject::load(&root, "example", &oid.to_string()).unwrap();
+ assert_eq!(object.bytes, b"sha256\n");
+ std::fs::remove_dir_all(root).unwrap();
+ }
+
+ #[test]
+ fn missing_object_is_not_found() {
+ let (root, _repository) = fixture();
+ assert!(matches!(
+ super::RawObject::load(&root, "example", "0000000000000000000000000000000000000000"),
+ Err(crate::models::Error::NotFound)
+ ));
+ std::fs::remove_dir_all(root).unwrap();
+ }
+}
diff --git a/crates/gilti/src/models/overview.rs b/crates/gilti/src/models/overview.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/models/overview.rs
@@ -0,0 +1,122 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::collections::HashMap;
+use std::path::Path;
+
+pub struct Overview {
+ pub repository: super::repository::Info,
+ pub empty: bool,
+ pub branches: Vec<super::refs::Branch>,
+ pub tags: Vec<super::refs::Tag>,
+ pub commits: Vec<Commit>,
+}
+
+pub struct Commit {
+ pub oid: String,
+ pub subject: String,
+ pub author: String,
+ pub timestamp: i64,
+ pub files: usize,
+ pub insertions: usize,
+ pub deletions: usize,
+ pub decorations: Vec<Decoration>,
+}
+
+pub struct Decoration {
+ pub label: String,
+ pub reference: Option<String>,
+ pub tag: bool,
+}
+
+impl Overview {
+ pub fn load(root: &Path, name: &str) -> Result<Self, super::Error> {
+ let repository = super::repository::open(root, name)?;
+ let info = super::repository::info(&repository, name);
+ let head = match repository.head().and_then(|head| head.peel_to_commit()) {
+ Ok(head) => head,
+ Err(error) if error.code() == git2::ErrorCode::UnbornBranch => {
+ return Ok(Self {
+ repository: info,
+ empty: true,
+ branches: Vec::new(),
+ tags: Vec::new(),
+ commits: Vec::new(),
+ });
+ }
+ Err(error) => return Err(super::Error::from_git(error)),
+ };
+ let refs = super::refs::Refs::load(root, name)?;
+ let mut decorations = HashMap::<git2::Oid, Vec<Decoration>>::new();
+ decorations.entry(head.id()).or_default().push(Decoration {
+ label: "HEAD".to_owned(),
+ reference: None,
+ tag: false,
+ });
+ for branch in &refs.branches {
+ if let Ok(oid) = repository.refname_to_id(&branch.reference) {
+ decorations.entry(oid).or_default().push(Decoration {
+ label: branch.name.clone(),
+ reference: Some(branch.reference.clone()),
+ tag: false,
+ });
+ }
+ }
+ for tag in &refs.tags {
+ if let Ok(reference) = repository.find_reference(&tag.reference) {
+ if let Ok(commit) = reference.peel_to_commit() {
+ decorations
+ .entry(commit.id())
+ .or_default()
+ .push(Decoration {
+ label: tag.name.clone(),
+ reference: Some(tag.reference.clone()),
+ tag: true,
+ });
+ }
+ }
+ }
+ let mut walk = repository.revwalk().map_err(super::Error::from_git)?;
+ walk.push(head.id()).map_err(super::Error::from_git)?;
+ walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)
+ .map_err(super::Error::from_git)?;
+ let mut commits = Vec::new();
+ for oid in walk.take(10) {
+ let oid = oid.map_err(super::Error::from_git)?;
+ let commit = repository
+ .find_commit(oid)
+ .map_err(super::Error::from_git)?;
+ let tree = commit.tree().map_err(super::Error::from_git)?;
+ let parent_tree = commit.parent(0).ok().and_then(|parent| parent.tree().ok());
+ let diff = repository
+ .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)
+ .map_err(super::Error::from_git)?;
+ let stats = diff.stats().map_err(super::Error::from_git)?;
+ commits.push(Commit {
+ oid: oid.to_string(),
+ subject: String::from_utf8_lossy(commit.summary_bytes().unwrap_or_default())
+ .into_owned(),
+ author: signature_name(&commit.author()),
+ timestamp: commit.time().seconds(),
+ files: stats.files_changed(),
+ insertions: stats.insertions(),
+ deletions: stats.deletions(),
+ decorations: decorations.remove(&oid).unwrap_or_default(),
+ });
+ }
+ Ok(Self {
+ repository: info,
+ empty: false,
+ branches: refs.branches.into_iter().take(10).collect(),
+ tags: refs.tags.into_iter().take(10).collect(),
+ commits,
+ })
+ }
+}
+
+fn signature_name(signature: &git2::Signature<'_>) -> String {
+ signature
+ .name()
+ .map(str::to_owned)
+ .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned())
+}
diff --git a/crates/gilti/src/models/refs.rs b/crates/gilti/src/models/refs.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/models/refs.rs
@@ -0,0 +1,196 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::path::Path;
+
+pub struct Refs {
+ pub repository: super::repository::Info,
+ pub branches: Vec<Branch>,
+ pub tags: Vec<Tag>,
+}
+
+pub struct Branch {
+ pub name: String,
+ pub reference: String,
+ pub subject: String,
+ pub author: String,
+ pub timestamp: i64,
+}
+
+pub struct Tag {
+ pub name: String,
+ pub reference: String,
+ pub target: String,
+ pub downloadable: bool,
+ pub author: String,
+ pub timestamp: i64,
+}
+
+impl Refs {
+ pub fn load(root: &Path, name: &str) -> Result<Self, super::Error> {
+ let repository = super::repository::open(root, name)?;
+ repository
+ .head()
+ .and_then(|head| head.peel_to_commit())
+ .map_err(super::Error::from_git)?;
+ let info = super::repository::info(&repository, name);
+ let mut branches = Vec::new();
+ let references = repository.references().map_err(super::Error::from_git)?;
+ for reference in references {
+ let reference = reference.map_err(super::Error::from_git)?;
+ let Ok(reference_name) = reference.name() else {
+ continue;
+ };
+ if !reference_name.starts_with("refs/heads/") {
+ continue;
+ }
+ let commit = match reference.peel_to_commit() {
+ Ok(commit) => commit,
+ Err(_) => continue,
+ };
+ branches.push(Branch {
+ name: reference_name["refs/heads/".len()..].to_owned(),
+ reference: reference_name.to_owned(),
+ subject: first_line(commit.message_bytes()),
+ author: signature_name(&commit.author()),
+ timestamp: commit.time().seconds(),
+ });
+ }
+ branches.sort_by(|left, right| left.reference.cmp(&right.reference));
+
+ let mut tags = Vec::new();
+ let references = repository.references().map_err(super::Error::from_git)?;
+ for reference in references {
+ let reference = reference.map_err(super::Error::from_git)?;
+ let Ok(reference_name) = reference.name() else {
+ continue;
+ };
+ if !reference_name.starts_with("refs/tags/") {
+ continue;
+ }
+ let object = match reference
+ .resolve()
+ .and_then(|reference| {
+ reference
+ .target()
+ .ok_or_else(|| git2::Error::from_str("reference has no target"))
+ })
+ .and_then(|oid| repository.find_object(oid, None))
+ {
+ Ok(object) => object,
+ Err(_) => continue,
+ };
+ let (author, timestamp) = if let Some(tag) = object.as_tag() {
+ let tagger = tag.tagger();
+ (
+ tagger.as_ref().map_or_else(String::new, signature_name),
+ tagger.as_ref().map_or(0, |tagger| tagger.when().seconds()),
+ )
+ } else if let Some(commit) = object.as_commit() {
+ (signature_name(&commit.author()), commit.time().seconds())
+ } else {
+ (String::new(), 0)
+ };
+ let target = object
+ .as_tag()
+ .map_or_else(|| object.id(), git2::Tag::target_id);
+ let downloadable = object.peel_to_commit().is_ok();
+ tags.push(Tag {
+ name: reference_name["refs/tags/".len()..].to_owned(),
+ reference: reference_name.to_owned(),
+ target: target.to_string(),
+ downloadable,
+ author,
+ timestamp,
+ });
+ }
+ tags.sort_by(|left, right| {
+ right
+ .timestamp
+ .cmp(&left.timestamp)
+ .then_with(|| left.reference.cmp(&right.reference))
+ });
+ Ok(Self {
+ repository: info,
+ branches,
+ tags,
+ })
+ }
+}
+
+fn signature_name(signature: &git2::Signature<'_>) -> String {
+ signature
+ .name()
+ .map(str::to_owned)
+ .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned())
+}
+
+fn first_line(message: &[u8]) -> String {
+ String::from_utf8_lossy(
+ message
+ .split(|byte| *byte == b'\n')
+ .next()
+ .unwrap_or_default(),
+ )
+ .into_owned()
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn loads_local_branches_and_annotated_tags() {
+ let root = std::env::temp_dir().join(format!("gilti-refs-test-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&root).unwrap();
+ let repository = git2::Repository::init_bare(root.join("example.git")).unwrap();
+ let signature = git2::Signature::new(
+ "Example Author",
+ "author@example.test",
+ &git2::Time::new(1_700_000_000, 0),
+ )
+ .unwrap();
+ let tree_oid = repository.treebuilder(None).unwrap().write().unwrap();
+ let tree = repository.find_tree(tree_oid).unwrap();
+ let commit_oid = repository
+ .commit(
+ Some("refs/heads/main"),
+ &signature,
+ &signature,
+ "Subject line\n\nBody",
+ &tree,
+ &[],
+ )
+ .unwrap();
+ repository.set_head("refs/heads/main").unwrap();
+ repository
+ .reference("refs/heads/feature/x", commit_oid, false, "test")
+ .unwrap();
+ let commit = repository.find_object(commit_oid, None).unwrap();
+ repository
+ .tag("v1.0", &commit, &signature, "Release", false)
+ .unwrap();
+ drop(commit);
+ drop(tree);
+ drop(repository);
+
+ let refs = super::Refs::load(&root, "example").unwrap();
+ assert_eq!(
+ refs.branches
+ .iter()
+ .map(|branch| branch.name.as_str())
+ .collect::<Vec<_>>(),
+ ["feature/x", "main"]
+ );
+ assert!(
+ refs.branches
+ .iter()
+ .all(|branch| branch.subject == "Subject line")
+ );
+ assert_eq!(refs.tags.len(), 1);
+ assert_eq!(refs.tags[0].name, "v1.0");
+ assert_eq!(refs.tags[0].author, "Example Author");
+ assert!(refs.tags[0].downloadable);
+
+ std::fs::remove_dir_all(root).unwrap();
+ }
+}
diff --git a/crates/gilti/src/models/repositories.rs b/crates/gilti/src/models/repositories.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/models/repositories.rs
@@ -0,0 +1,181 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::path::Path;
+
+pub struct Repositories {
+ pub rows: Vec<Repository>,
+ pub offset: usize,
+ pub has_next: bool,
+}
+
+pub struct Repository {
+ pub name: String,
+ pub description: String,
+ pub timestamp: Option<i64>,
+ pub populated: bool,
+}
+
+#[derive(Clone, Copy)]
+pub enum Sort {
+ Name,
+ Description,
+ Owner,
+ Idle,
+}
+
+pub struct Filter {
+ pub search: Option<String>,
+ pub sort: Sort,
+ pub offset: usize,
+ pub limit: usize,
+}
+
+impl Repositories {
+ pub fn load(root: &Path, filter: Filter) -> Result<Self, super::Error> {
+ let mut rows = Vec::new();
+ scan(root, root, &mut rows)?;
+ if let Some(search) = filter.search.as_deref() {
+ let search = search.to_lowercase();
+ rows.retain(|repository| {
+ repository.name.to_lowercase().contains(&search)
+ || repository.description.to_lowercase().contains(&search)
+ });
+ }
+ if rows.is_empty() {
+ return Err(super::Error::NotFound);
+ }
+ match filter.sort {
+ Sort::Name | Sort::Owner => rows.sort_by(|left, right| left.name.cmp(&right.name)),
+ Sort::Description => rows.sort_by(|left, right| {
+ left.description
+ .cmp(&right.description)
+ .then_with(|| left.name.cmp(&right.name))
+ }),
+ Sort::Idle => rows.sort_by(|left, right| {
+ right
+ .timestamp
+ .cmp(&left.timestamp)
+ .then_with(|| left.name.cmp(&right.name))
+ }),
+ }
+ let has_next = rows.len() > filter.offset.saturating_add(filter.limit);
+ let rows = rows
+ .into_iter()
+ .skip(filter.offset)
+ .take(filter.limit)
+ .collect();
+ Ok(Self {
+ rows,
+ offset: filter.offset,
+ has_next,
+ })
+ }
+}
+
+fn scan(root: &Path, directory: &Path, rows: &mut Vec<Repository>) -> Result<(), super::Error> {
+ let entries =
+ std::fs::read_dir(directory).map_err(|error| super::Error::Internal(error.to_string()))?;
+ for entry in entries {
+ let entry = entry.map_err(|error| super::Error::Internal(error.to_string()))?;
+ let file_type = entry
+ .file_type()
+ .map_err(|error| super::Error::Internal(error.to_string()))?;
+ if !file_type.is_dir() {
+ continue;
+ }
+ let file_name = entry.file_name();
+ let Some(file_name) = file_name.to_str() else {
+ continue;
+ };
+ if file_name.starts_with('.') {
+ continue;
+ }
+ let path = entry.path();
+ if let Some(name) = repository_name(root, &path) {
+ if let Some(repository) = load(&path, name) {
+ rows.push(repository);
+ }
+ } else {
+ scan(root, &path, rows)?;
+ }
+ }
+ Ok(())
+}
+
+fn repository_name<'a>(root: &Path, path: &'a Path) -> Option<&'a str> {
+ let relative = path.strip_prefix(root).ok()?.to_str()?;
+ relative.strip_suffix(".git")
+}
+
+fn load(path: &Path, name: &str) -> Option<Repository> {
+ let repository = git2::Repository::open_bare(path).ok()?;
+ let description = std::fs::read_to_string(path.join("description"))
+ .ok()
+ .map(|description| description.trim().to_owned())
+ .filter(|description| !description.is_empty())
+ .unwrap_or_else(|| "[no description]".to_owned());
+ let commit = repository
+ .head()
+ .and_then(|head| head.peel_to_commit())
+ .ok();
+ Some(Repository {
+ name: name.to_owned(),
+ description,
+ timestamp: commit.as_ref().map(|commit| commit.time().seconds()),
+ populated: commit.is_some(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ fn filter(search: Option<&str>) -> super::Filter {
+ super::Filter {
+ search: search.map(str::to_owned),
+ sort: super::Sort::Name,
+ offset: 0,
+ limit: 50,
+ }
+ }
+
+ #[test]
+ fn scans_nested_and_terminal_dot_git_names_and_filters() {
+ let root =
+ std::env::temp_dir().join(format!("gilti-repositories-test-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(root.join("group")).unwrap();
+ git2::Repository::init_bare(root.join("group/project.git")).unwrap();
+ git2::Repository::init_bare(root.join("group/dotted.git.git")).unwrap();
+ git2::Repository::init_bare(root.join("empty.git")).unwrap();
+ std::fs::write(
+ root.join("group/project.git/description"),
+ "Project description\n",
+ )
+ .unwrap();
+
+ let repositories = super::Repositories::load(&root, filter(None)).unwrap();
+ assert_eq!(
+ repositories
+ .rows
+ .iter()
+ .map(|repository| repository.name.as_str())
+ .collect::<Vec<_>>(),
+ ["empty", "group/dotted.git", "group/project"]
+ );
+ assert!(
+ repositories
+ .rows
+ .iter()
+ .all(|repository| !repository.populated)
+ );
+ let repositories =
+ super::Repositories::load(&root, filter(Some("Project description"))).unwrap();
+ assert_eq!(repositories.rows.len(), 1);
+ assert_eq!(repositories.rows[0].name, "group/project");
+ assert!(matches!(
+ super::Repositories::load(&root, filter(Some("missing"))),
+ Err(crate::models::Error::NotFound)
+ ));
+ std::fs::remove_dir_all(root).unwrap();
+ }
+}
diff --git a/crates/gilti/src/models/repository.rs b/crates/gilti/src/models/repository.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/models/repository.rs
@@ -0,0 +1,81 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::path::{Path, PathBuf};
+
+pub struct Info {
+ pub name: String,
+ pub description: String,
+}
+
+pub fn info(repository: &git2::Repository, name: &str) -> Info {
+ let description = std::fs::read_to_string(repository.path().join("description"))
+ .ok()
+ .map(|description| description.trim().to_owned())
+ .filter(|description| !description.is_empty())
+ .unwrap_or_else(|| "[no description]".to_owned());
+ Info {
+ name: name.to_owned(),
+ description,
+ }
+}
+
+pub fn open(root: &Path, name: &str) -> Result<git2::Repository, super::Error> {
+ git2::Repository::open_bare(path(root, name)?).map_err(super::Error::from_git)
+}
+
+pub fn path(root: &Path, name: &str) -> Result<PathBuf, super::Error> {
+ let root = root
+ .canonicalize()
+ .map_err(|error| super::Error::Internal(error.to_string()))?;
+ let path = repository_path(&root, name)
+ .canonicalize()
+ .map_err(|error| {
+ if error.kind() == std::io::ErrorKind::NotFound {
+ super::Error::NotFound
+ } else {
+ super::Error::Internal(error.to_string())
+ }
+ })?;
+ if !path.starts_with(&root) || !path.is_dir() {
+ return Err(super::Error::NotFound);
+ }
+ Ok(path)
+}
+
+fn repository_path(root: &Path, name: &str) -> PathBuf {
+ let (parent, leaf) = name.rsplit_once('/').unwrap_or(("", name));
+ root.join(parent).join(format!("{leaf}.git"))
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn appends_exactly_one_storage_suffix() {
+ let root = std::path::Path::new("/repositories");
+ assert_eq!(
+ super::repository_path(root, "group/project"),
+ root.join("group/project.git")
+ );
+ assert_eq!(
+ super::repository_path(root, "group/project.git"),
+ root.join("group/project.git.git")
+ );
+ }
+
+ #[test]
+ fn resolved_path_cannot_escape_the_repository_root() {
+ let parent =
+ std::env::temp_dir().join(format!("gilti-repository-path-test-{}", std::process::id()));
+ let root = parent.join("repositories");
+ let outside = parent.join("outside.git");
+ let _ = std::fs::remove_dir_all(&parent);
+ std::fs::create_dir_all(&root).unwrap();
+ git2::Repository::init_bare(&outside).unwrap();
+ assert!(matches!(
+ super::path(&root, "../outside"),
+ Err(crate::models::Error::NotFound)
+ ));
+ std::fs::remove_dir_all(parent).unwrap();
+ }
+}
diff --git a/crates/gilti/src/models/revision.rs b/crates/gilti/src/models/revision.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/models/revision.rs
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+pub fn selector(revision: &crate::router::Revision) -> String {
+ match revision {
+ crate::router::Revision::Head => "HEAD".to_owned(),
+ crate::router::Revision::Ref(reference) | crate::router::Revision::Commit(reference) => {
+ reference.clone()
+ }
+ }
+}
+
+pub fn commit<'repo>(
+ repository: &'repo git2::Repository,
+ revision: &crate::router::Revision,
+) -> Result<git2::Commit<'repo>, super::Error> {
+ match revision {
+ crate::router::Revision::Head => repository
+ .head()
+ .and_then(|head| head.peel_to_commit())
+ .map_err(|_| super::Error::NotFound),
+ crate::router::Revision::Ref(reference) => repository
+ .find_reference(reference)
+ .and_then(|reference| reference.peel_to_commit())
+ .map_err(|_| super::Error::NotFound),
+ crate::router::Revision::Commit(oid) => {
+ let oid = git2::Oid::from_str_ext(oid, repository.object_format())
+ .map_err(|_| super::Error::NotFound)?;
+ repository
+ .find_commit(oid)
+ .map_err(|_| super::Error::NotFound)
+ }
+ }
+}
diff --git a/crates/gilti/src/models/tag.rs b/crates/gilti/src/models/tag.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/models/tag.rs
@@ -0,0 +1,112 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::path::Path;
+
+pub struct Tag {
+ pub repository: super::repository::Info,
+ pub reference: String,
+ pub name: String,
+ pub oid: String,
+ pub targets: Vec<Target>,
+ pub annotated: bool,
+ pub tagger: String,
+ pub tagger_email: String,
+ pub timestamp: Option<i64>,
+ pub message: String,
+ pub downloadable: bool,
+}
+
+pub struct Target {
+ pub oid: String,
+ pub commit: bool,
+}
+
+impl Tag {
+ pub fn load(root: &Path, name: &str, reference: String) -> Result<Self, super::Error> {
+ if !reference.starts_with("refs/tags/") {
+ return Err(super::Error::NotFound);
+ }
+ let repository = super::repository::open(root, name)?;
+ let info = super::repository::info(&repository, name);
+ let git_reference = repository
+ .find_reference(&reference)
+ .map_err(super::Error::from_git)?;
+ let oid = git_reference
+ .resolve()
+ .map_err(super::Error::from_git)?
+ .target()
+ .ok_or(super::Error::NotFound)?;
+ let object = repository
+ .find_object(oid, None)
+ .map_err(super::Error::from_git)?;
+ let downloadable = object.peel_to_commit().is_ok();
+ let (targets, annotated, tagger, tagger_email, timestamp, message) =
+ if let Some(tag) = object.as_tag() {
+ let tagger = tag.tagger();
+ (
+ targets(tag)?,
+ true,
+ tagger.as_ref().map_or_else(String::new, signature_name),
+ tagger.as_ref().map_or_else(String::new, signature_email),
+ tagger.as_ref().map(|tagger| tagger.when().seconds()),
+ String::from_utf8_lossy(tag.message_bytes().unwrap_or_default()).into_owned(),
+ )
+ } else {
+ (
+ vec![Target {
+ oid: object.id().to_string(),
+ commit: object.kind() == Some(git2::ObjectType::Commit),
+ }],
+ false,
+ String::new(),
+ String::new(),
+ None,
+ String::new(),
+ )
+ };
+ Ok(Self {
+ repository: info,
+ reference: reference.clone(),
+ name: reference["refs/tags/".len()..].to_owned(),
+ oid: oid.to_string(),
+ targets,
+ annotated,
+ tagger,
+ tagger_email,
+ timestamp,
+ message,
+ downloadable,
+ })
+ }
+}
+
+fn targets(tag: &git2::Tag<'_>) -> Result<Vec<Target>, super::Error> {
+ let mut targets = Vec::new();
+ let mut object = tag.target().map_err(super::Error::from_git)?;
+ loop {
+ targets.push(Target {
+ oid: object.id().to_string(),
+ commit: object.kind() == Some(git2::ObjectType::Commit),
+ });
+ let Some(tag) = object.as_tag() else {
+ break;
+ };
+ object = tag.target().map_err(super::Error::from_git)?;
+ }
+ Ok(targets)
+}
+
+fn signature_name(signature: &git2::Signature<'_>) -> String {
+ signature
+ .name()
+ .map(str::to_owned)
+ .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned())
+}
+
+fn signature_email(signature: &git2::Signature<'_>) -> String {
+ signature
+ .email()
+ .map(str::to_owned)
+ .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned())
+}
diff --git a/crates/gilti/src/models/tree.rs b/crates/gilti/src/models/tree.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/models/tree.rs
@@ -0,0 +1,250 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::path::Path;
+
+pub struct Tree {
+ pub repository: super::repository::Info,
+ pub revision: String,
+ pub path: Option<String>,
+ pub content: Content,
+}
+
+pub enum Content {
+ Directory {
+ oid: String,
+ entries: Vec<Entry>,
+ },
+ Blob {
+ oid: String,
+ bytes: Vec<u8>,
+ binary: bool,
+ },
+}
+
+pub struct Entry {
+ pub mode: u32,
+ pub name: String,
+ pub path: String,
+ pub oid: String,
+ pub kind: Kind,
+ pub size: usize,
+ pub symlink_target: Option<String>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum Kind {
+ Tree,
+ Blob,
+ Submodule,
+}
+
+impl Tree {
+ pub fn load(
+ root: &Path,
+ name: &str,
+ revision: crate::router::Revision,
+ path: Option<String>,
+ ) -> Result<Self, super::Error> {
+ let repository = super::repository::open(root, name)?;
+ let info = super::repository::info(&repository, name);
+ let selector = super::revision::selector(&revision);
+ let commit = super::revision::commit(&repository, &revision)?;
+ let root_tree = commit.tree().map_err(super::Error::from_git)?;
+ let content = match path.as_deref() {
+ None => directory(&repository, &root_tree, "")?,
+ Some(path) => {
+ let entry = root_tree
+ .get_path(Path::new(path))
+ .map_err(super::Error::from_git)?;
+ match entry.kind() {
+ Some(git2::ObjectType::Tree) => {
+ let tree = repository
+ .find_tree(entry.id())
+ .map_err(super::Error::from_git)?;
+ directory(&repository, &tree, path)?
+ }
+ Some(git2::ObjectType::Blob) => {
+ let blob = repository
+ .find_blob(entry.id())
+ .map_err(super::Error::from_git)?;
+ let bytes = blob.content().to_vec();
+ let binary = bytes.iter().take(8000).any(|byte| *byte == 0);
+ Content::Blob {
+ oid: entry.id().to_string(),
+ bytes,
+ binary,
+ }
+ }
+ _ => return Err(super::Error::NotFound),
+ }
+ }
+ };
+ Ok(Self {
+ repository: info,
+ revision: selector,
+ path,
+ content,
+ })
+ }
+}
+
+fn directory(
+ repository: &git2::Repository,
+ tree: &git2::Tree<'_>,
+ base: &str,
+) -> Result<Content, super::Error> {
+ let odb = repository.odb().map_err(super::Error::from_git)?;
+ let mut entries = Vec::with_capacity(tree.len());
+ for entry in tree {
+ let name = String::from_utf8_lossy(entry.name_bytes()).into_owned();
+ let path = if base.is_empty() {
+ name.clone()
+ } else {
+ format!("{base}/{name}")
+ };
+ let kind = match entry.kind() {
+ Some(git2::ObjectType::Tree) => Kind::Tree,
+ Some(git2::ObjectType::Commit) => Kind::Submodule,
+ Some(git2::ObjectType::Blob) => Kind::Blob,
+ _ => continue,
+ };
+ let size = if kind == Kind::Submodule {
+ 0
+ } else {
+ odb.read_header(entry.id())
+ .map_err(super::Error::from_git)?
+ .0
+ };
+ let symlink_target = if entry.filemode_raw() == 0o120000 {
+ repository
+ .find_blob(entry.id())
+ .ok()
+ .map(|blob| String::from_utf8_lossy(blob.content()).into_owned())
+ } else {
+ None
+ };
+ entries.push(Entry {
+ mode: entry.filemode_raw() as u32,
+ name,
+ path,
+ oid: entry.id().to_string(),
+ kind,
+ size,
+ symlink_target,
+ });
+ }
+ Ok(Content::Directory {
+ oid: tree.id().to_string(),
+ entries,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn loads_directories_blobs_modes_and_symlinks() {
+ let root = std::env::temp_dir().join(format!("gilti-tree-test-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&root).unwrap();
+ let repository = git2::Repository::init_bare(root.join("example.git")).unwrap();
+ let odb = repository.odb().unwrap();
+ let readme = odb.write(git2::ObjectType::Blob, b"hello\n").unwrap();
+ let executable = odb.write(git2::ObjectType::Blob, b"#!/bin/sh\n").unwrap();
+ let symlink = odb.write(git2::ObjectType::Blob, b"README.md").unwrap();
+ let nested = odb.write(git2::ObjectType::Blob, b"nested\n").unwrap();
+ let subtree = {
+ let mut tree = repository.treebuilder(None).unwrap();
+ tree.insert("file.txt", nested, 0o100644).unwrap();
+ tree.write().unwrap()
+ };
+ let tree_oid = {
+ let mut tree = repository.treebuilder(None).unwrap();
+ tree.insert("README.md", readme, 0o100644).unwrap();
+ tree.insert("run", executable, 0o100755).unwrap();
+ tree.insert("link", symlink, 0o120000).unwrap();
+ tree.insert("dir", subtree, 0o040000).unwrap();
+ tree.write().unwrap()
+ };
+ let tree = repository.find_tree(tree_oid).unwrap();
+ let signature = git2::Signature::now("Author", "author@example.test").unwrap();
+ repository
+ .commit(
+ Some("refs/heads/main"),
+ &signature,
+ &signature,
+ "Tree fixture",
+ &tree,
+ &[],
+ )
+ .unwrap();
+ repository.set_head("refs/heads/main").unwrap();
+ drop(tree);
+ drop(odb);
+ drop(repository);
+
+ let root_tree =
+ super::Tree::load(&root, "example", crate::router::Revision::Head, None).unwrap();
+ let super::Content::Directory { entries, .. } = root_tree.content else {
+ panic!("expected directory")
+ };
+ assert_eq!(entries.len(), 4);
+ assert_eq!(
+ entries
+ .iter()
+ .find(|entry| entry.name == "run")
+ .unwrap()
+ .mode,
+ 0o100755
+ );
+ assert_eq!(
+ entries
+ .iter()
+ .find(|entry| entry.name == "link")
+ .unwrap()
+ .symlink_target
+ .as_deref(),
+ Some("README.md")
+ );
+ assert_eq!(
+ entries
+ .iter()
+ .find(|entry| entry.name == "dir")
+ .unwrap()
+ .kind,
+ super::Kind::Tree
+ );
+
+ let blob = super::Tree::load(
+ &root,
+ "example",
+ crate::router::Revision::Head,
+ Some("dir/file.txt".to_owned()),
+ )
+ .unwrap();
+ let super::Content::Blob { bytes, binary, .. } = blob.content else {
+ panic!("expected blob")
+ };
+ assert_eq!(bytes, b"nested\n");
+ assert!(!binary);
+ assert!(matches!(
+ super::Tree::load(
+ &root,
+ "example",
+ crate::router::Revision::Head,
+ Some("missing".to_owned())
+ ),
+ Err(crate::models::Error::NotFound)
+ ));
+ assert!(matches!(
+ super::Tree::load(
+ &root,
+ "example",
+ crate::router::Revision::Commit(readme.to_string()),
+ None
+ ),
+ Err(crate::models::Error::NotFound)
+ ));
+ std::fs::remove_dir_all(root).unwrap();
+ }
+}
diff --git a/crates/gilti/src/ui.rs b/crates/gilti/src/ui.rs
deleted file mode 100644
--- a/crates/gilti/src/ui.rs
+++ /dev/null
@@ -1,296 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::{Markup, PreEscaped, html};
-use serde::Deserialize;
-
-pub const PRIVATE_CONTENT_TYPE: &str = "application/vnd.gilti.repolist+json";
-
-#[derive(Deserialize)]
-struct RepoList {
- page: String,
- title: String,
- root_desc: String,
- root_url: String,
- about_url: String,
- noheader: bool,
- search: String,
- current_url: String,
- root_readme: bool,
- owner_enabled: bool,
- links_enabled: bool,
- section_grouping: bool,
- shell: Shell,
- sort_urls: SortUrls,
- rows: Vec<Row>,
- pager: Vec<Pager>,
-}
-
-#[derive(Deserialize)]
-struct Shell {
- embedded: bool,
- robots: String,
- css: Vec<String>,
- js: Vec<String>,
- favicon: String,
- head_include: Option<String>,
- header: Option<String>,
- footer_configured: bool,
- footer: Option<String>,
- logo: String,
- logo_link: String,
- cgit_version: String,
- git_version: String,
- generated_at: String,
-}
-
-#[derive(Deserialize)]
-struct SortUrls {
- name: String,
- desc: String,
- owner: String,
- idle: String,
-}
-
-#[derive(Deserialize)]
-#[serde(untagged)]
-enum Row {
- Section(Section),
- Repo(Box<Repo>),
-}
-
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct Section {
- section: Option<String>,
-}
-
-#[derive(Deserialize)]
-struct Repo {
- name: String,
- section: Option<String>,
- url: String,
- description: Description,
- owner: String,
- owner_url: String,
- idle: Option<Age>,
- log_url: Option<String>,
- tree_url: Option<String>,
-}
-
-#[derive(Deserialize)]
-struct Description {
- text: String,
- truncated: bool,
-}
-
-#[derive(Deserialize)]
-struct Age {
- timestamp: i64,
- title: String,
- unit: String,
- amount: f64,
-}
-
-#[derive(Deserialize)]
-struct Pager {
- url: String,
- current: bool,
-}
-
-pub fn render(body: &[u8]) -> Result<Markup, serde_json::Error> {
- let page: RepoList = serde_json::from_slice(body)?;
- if page.page != "repolist" {
- return Err(serde_json::Error::io(std::io::Error::new(
- std::io::ErrorKind::InvalidData,
- "unknown private page",
- )));
- }
- Ok(render_repolist(page))
-}
-
-fn include(content: &Option<String>) -> Markup {
- PreEscaped(content.clone().unwrap_or_default())
-}
-
-fn render_repolist(page: RepoList) -> Markup {
- let shell = &page.shell;
- html! {
- @if shell.embedded {
- (include(&shell.header))
- } @else {
- (maud::DOCTYPE)
- html lang="en" {
- head {
- title { (&page.title) }
- meta name="generator" content=(format!("cgit {}", shell.cgit_version));
-
- @if !shell.robots.is_empty() {
- meta name="robots" content=(&shell.robots);
- }
-
- @if shell.css.is_empty() {
- link rel="stylesheet" type="text/css" href="/-/assets/cgit.css";
- }
-
- @for css in &shell.css {
- @if !css.is_empty() {
- link rel="stylesheet" type="text/css" href=(css);
- }
- }
-
- @if shell.js.is_empty() {
- script type="text/javascript" src="/-/assets/cgit.js" {}
- }
-
- @for js in &shell.js {
- @if !js.is_empty() {
- script type="text/javascript" src=(js) {}
- }
- }
-
- @if !shell.favicon.is_empty() {
- link rel="shortcut icon" href=(&shell.favicon);
- }
-
- (include(&shell.head_include))
- }
- body {
- (include(&shell.header))
- (page_content(&page, true))
- }
- }
- }
- @if shell.embedded {
- (page_content(&page, false))
- (footer(shell, true))
- }
- }
-}
-
-fn page_content(page: &RepoList, footer_inside: bool) -> Markup {
- let shell = &page.shell;
- let has_repos = page.rows.iter().any(|row| matches!(row, Row::Repo(_)));
- html! {
- div id="cgit" {
- @if !page.noheader {
- table id="header" {
- tr {
- @if !shell.logo.is_empty() {
- td class="logo" rowspan="2" {
- a href=(if shell.logo_link.is_empty() { &page.root_url } else { &shell.logo_link }) {
- img src=(&shell.logo) alt="cgit logo";
- }
- }
- }
- td class="main" { (&page.title) }
- }
- tr { td class="sub" { (&page.root_desc) } }
- }
- }
- table class="tabs" { tr {
- td {
- a href=(&page.root_url) class="active" { "index" }
- @if page.root_readme { a href=(&page.about_url) { "about" } }
- }
- td class="form" {
- form method="get" action=(&page.current_url) {
- input type="search" name="q" size="10" value=(&page.search);
- input type="submit" value="search";
- }
- }
- }}
- div class="content" {
- table summary="repository list" class="list nowrap" {
- @if has_repos { tr class="nohover" {
- th class="left" { a href=(&page.sort_urls.name) { "Name" } }
- th class="left" { a href=(&page.sort_urls.desc) { "Description" } }
- @if page.owner_enabled { th class="left" { a href=(&page.sort_urls.owner) { "Owner" } } }
- th class="left" { a href=(&page.sort_urls.idle) { "Idle" } }
- @if page.links_enabled { th class="left" { "Links" } }
- }}
- @for row in &page.rows {
- @match row {
- Row::Section(section) => tr class="nohover-highlight" {
- td colspan=(3 + usize::from(page.owner_enabled) + usize::from(page.links_enabled)) class="reposection" { (section.section.as_deref().unwrap_or("")) }
- },
- Row::Repo(repo) => tr {
- td class=(if page.section_grouping && repo.section.is_some() { "sublevel-repo" } else { "toplevel-repo" }) { a href=(&repo.url) { (&repo.name) } }
- td { a href=(&repo.url) { (&repo.description.text) @if repo.description.truncated { "..." } } }
- @if page.owner_enabled { td { a href=(&repo.owner_url) { (&repo.owner) } } }
- td { @if let Some(age) = &repo.idle { span class=(if age.unit == "minutes" { "age-mins".to_owned() } else { format!("age-{}", age.unit) }) data-ut=(age.timestamp) title=(&age.title) { (format!("{:.0}", age.amount)) " " (if age.unit == "minutes" { "min." } else { &age.unit }) } } }
- @if page.links_enabled { td { a class="button" href=(&repo.url) { "summary" } @if let Some(url) = &repo.log_url { a class="button" href=(url) { "log" } } @if let Some(url) = &repo.tree_url { a class="button" href=(url) { "tree" } } } }
- },
- }
- }
- }
- @if !page.pager.is_empty() { ul class="pager" { @for (index, item) in page.pager.iter().enumerate() { li { a href=(&item.url) class=[item.current.then_some("current")] title=(format!("Page {}", index + 1)) { "[" (index + 1) "]" } } } } }
- }
- @if footer_inside { (footer(&page.shell, false)) }
- }
- }
-}
-
-fn footer(shell: &Shell, embedded: bool) -> Markup {
- if let Some(footer) = &shell.footer {
- return PreEscaped(footer.clone());
- }
- if embedded || shell.footer_configured {
- return Markup::default();
- }
- html! { div class="footer" { "generated by " a href="https://git.zx2c4.com/cgit/about/" { "cgit " (&shell.cgit_version) } " (" a href="https://git-scm.com/" { "git " (&shell.git_version) } ") at " (&shell.generated_at) } }
-}
-
-#[cfg(test)]
-mod tests {
- #[test]
- fn renders_shell_and_escapes_data() {
- let markup = super::render(br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/?p=about","noheader":false,"search":"<x>","current_url":"/","root_readme":true,"owner_enabled":true,"links_enabled":true,"section_grouping":true,"shell":{"embedded":false,"robots":"noindex","css":["/custom.css"],"js":["/custom.js"],"favicon":"/favicon.ico","head_include":"<meta name='x'>","header":"ignored","footer_configured":false,"footer":null,"logo":"/cgit.png","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[{"name":"<repo>","section":"group","url":"/?url=repo","description":{"text":"x&y","truncated":true},"owner":"o","owner_url":"/?q=o","idle":{"timestamp":1,"title":"date","unit":"years","amount":1},"log_url":"/?url=repo/log/&showmsg=1","tree_url":"/?url=repo/tree/"}],"pager":[]}"#).unwrap().into_string();
- assert!(markup.contains("&lt;repo&gt;"));
- assert!(markup.contains("x&amp;y..."));
- assert!(markup.contains("href=\"/?url=repo/log/&amp;showmsg=1\""));
- assert!(markup.contains("/custom.css"));
- assert!(markup.contains("cgit.png"));
- assert!(markup.contains("name='x'"));
- }
-
- #[test]
- fn empty_rows_have_no_column_header() {
- let markup = super::render(br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/?p=about","noheader":true,"search":"","current_url":"/","root_readme":false,"owner_enabled":false,"links_enabled":false,"section_grouping":false,"shell":{"embedded":false,"robots":"","css":[],"js":[],"favicon":"","head_include":null,"header":null,"footer_configured":false,"footer":null,"logo":"","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[],"pager":[{"url":"/?ofs=0","current":false}]}"#).unwrap().into_string();
- assert!(!markup.contains("<tr class=\"nohover\">"));
- }
-
- #[test]
- fn footer_respects_embedded_and_unreadable_include_modes() {
- let mut shell = super::Shell {
- embedded: false,
- robots: String::new(),
- css: Vec::new(),
- js: Vec::new(),
- favicon: String::new(),
- head_include: None,
- header: None,
- footer_configured: false,
- footer: None,
- logo: String::new(),
- logo_link: String::new(),
- cgit_version: "v1".into(),
- git_version: "2".into(),
- generated_at: "now".into(),
- };
- assert!(super::footer(&shell, true).into_string().is_empty());
- shell.footer_configured = true;
- assert!(super::footer(&shell, false).into_string().is_empty());
- shell.footer = Some("<footer>configured</footer>".into());
- assert!(
- super::footer(&shell, true)
- .into_string()
- .contains("<footer>configured</footer>")
- );
- }
-
- #[test]
- fn rejects_invalid_model() {
- assert!(super::render(br#"{"page":"repolist"}"#).is_err());
- }
-}
diff --git a/crates/gilti/src/views/about.rs b/crates/gilti/src/views/about.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/views/about.rs
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use maud::html;
+
+pub async fn serve(
+ context: &super::shared::Context,
+ route: crate::router::RepoRoute<()>,
+ method: axum::http::Method,
+) -> axum::response::Response {
+ if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
+ return super::method_not_allowed();
+ }
+ let repositories = context.repositories;
+ let model = tokio::task::spawn_blocking(move || {
+ crate::models::about::About::load(std::path::Path::new(repositories), &route.repo)
+ })
+ .await;
+ let model = match model {
+ Ok(Ok(model)) => model,
+ Ok(Err(error)) => return super::error(error),
+ Err(error) => {
+ return super::error(crate::models::Error::Internal(error.to_string()));
+ }
+ };
+ let readme = String::from_utf8_lossy(&model.bytes);
+ let content = html! { div id="summary" { pre { (readme) } } };
+ super::shared::render(
+ context,
+ &model.repository,
+ "HEAD",
+ super::shared::Page::About,
+ content,
+ &method,
+ )
+}
diff --git a/crates/gilti/src/views/archive_signature.rs b/crates/gilti/src/views/archive_signature.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/views/archive_signature.rs
@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+pub async fn serve(
+ context: &super::shared::Context,
+ route: crate::router::RepoRoute<crate::router::Revision>,
+ format: Option<&str>,
+ method: axum::http::Method,
+) -> axum::response::Response {
+ if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
+ return super::method_not_allowed();
+ }
+ let repositories = context.repositories;
+ let format = format.unwrap_or("tar.gz").to_owned();
+ let model = tokio::task::spawn_blocking(move || {
+ crate::models::archive_signature::ArchiveSignature::load(
+ std::path::Path::new(repositories),
+ &route.repo,
+ route.params,
+ &format,
+ )
+ })
+ .await;
+ let model = match model {
+ Ok(Ok(model)) => model,
+ Ok(Err(error)) => return super::error(error),
+ Err(error) => {
+ return super::error(crate::models::Error::Internal(error.to_string()));
+ }
+ };
+ let length = model.bytes.len();
+ let body = if method == axum::http::Method::HEAD {
+ axum::body::Body::empty()
+ } else {
+ axum::body::Body::from(model.bytes)
+ };
+ axum::response::Response::builder()
+ .status(axum::http::StatusCode::OK)
+ .header(
+ axum::http::header::CONTENT_TYPE,
+ "application/pgp-signature; charset=UTF-8",
+ )
+ .header(axum::http::header::CONTENT_LENGTH, length)
+ .header(
+ axum::http::header::CONTENT_DISPOSITION,
+ format!("inline; filename=\"{}\"", model.filename),
+ )
+ .header(axum::http::header::ETAG, format!("\"{}\"", model.oid))
+ .header("x-content-type-options", "nosniff")
+ .header("content-security-policy", "default-src 'none'")
+ .body(body)
+ .expect("archive signature response is valid")
+}
diff --git a/crates/gilti/src/views/blame.rs b/crates/gilti/src/views/blame.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/views/blame.rs
@@ -0,0 +1,112 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use maud::{Markup, html};
+
+pub async fn serve(
+ context: &super::shared::Context,
+ route: crate::router::RepoRoute<crate::router::RevisionFile>,
+ method: axum::http::Method,
+) -> axum::response::Response {
+ if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
+ return super::method_not_allowed();
+ }
+ let repositories = context.repositories;
+ let model = tokio::task::spawn_blocking(move || {
+ crate::models::blame::Blame::load(
+ std::path::Path::new(repositories),
+ &route.repo,
+ route.params.rev,
+ route.params.path,
+ )
+ })
+ .await;
+ let model = match model {
+ Ok(Ok(model)) => model,
+ Ok(Err(error)) => return super::error(error),
+ Err(error) => {
+ return super::error(crate::models::Error::Internal(error.to_string()));
+ }
+ };
+ let content = content(&model);
+ super::shared::render(
+ context,
+ &model.repository,
+ &model.revision,
+ super::shared::Page::Tree,
+ content,
+ &method,
+ )
+}
+
+fn content(model: &crate::models::blame::Blame) -> Markup {
+ let repo = super::shared::repository_url(&model.repository.name);
+ let revision = super::shared::encode_path(&model.revision);
+ let path = super::shared::encode_path(&model.path);
+ html! {
+ div class="path" { "path: " (breadcrumbs(&repo, &revision, &model.path)) }
+ "blob: " (&model.oid) " ("
+ a href=(format!("{repo}/+/{revision}/+/tree/{path}?format=raw")) { "plain" }
+ ") ("
+ a href=(format!("{repo}/+/{revision}/+/tree/{path}")) { "tree" }
+ ")"
+ @if model.binary {
+ div class="error" { "blob is binary." }
+ } @else {
+ (blame_table(model, &repo))
+ }
+ }
+}
+
+fn blame_table(model: &crate::models::blame::Blame, repo: &str) -> Markup {
+ let lines = String::from_utf8_lossy(&model.bytes)
+ .split_inclusive('\n')
+ .map(str::to_owned)
+ .collect::<Vec<_>>();
+ html! { table class="blame blob" {
+ @for hunk in &model.hunks {
+ @for offset in 0..hunk.lines {
+ @let line = hunk.start + offset;
+ tr class=[(offset % 2 == 0).then_some("alt")] {
+ @if offset == 0 {
+ td class="hashes" rowspan=(hunk.lines) {
+ span class="oid" title=(format!(
+ "author {} <{}> {}\ncommitter {} <{}>\n\n{}",
+ hunk.author, hunk.author_email,
+ super::shared::age(hunk.timestamp),
+ hunk.committer, hunk.committer_email,
+ hunk.summary
+ )) {
+ a href=(format!("{repo}/+/{}", hunk.oid)) { (&hunk.short_oid) }
+ }
+ @if let Some(parent) = &hunk.parent {
+ " " a href=(format!(
+ "{repo}/+/{parent}/+/blame/{}",
+ super::shared::encode_path(&hunk.original_path)
+ )) title="Blame the previous revision" { "^" }
+ }
+ }
+ }
+ td class="linenumbers" { a id=(format!("n{line}")) href=(format!("#n{line}")) { (line) } }
+ td class="lines" { pre { code { (lines.get(line - 1).map_or("", String::as_str)) } } }
+ }
+ }
+ }
+ } }
+}
+
+fn breadcrumbs(repository: &str, revision: &str, path: &str) -> Markup {
+ let parts = path.split('/').collect::<Vec<_>>();
+ html! {
+ a href=(format!("{repository}/+/{revision}/+/tree")) { "root" }
+ @for (index, part) in parts.iter().enumerate() {
+ "/"
+ @let current = parts[..=index].join("/");
+ @if index + 1 == parts.len() {
+ a href=(format!("{repository}/+/{revision}/+/blame/{}", super::shared::encode_path(&current))) { (part) }
+ } @else {
+ a href=(format!("{repository}/+/{revision}/+/tree/{}", super::shared::encode_path(&current))) { (part) }
+ }
+ }
+ }
+}
diff --git a/crates/gilti/src/views/mod.rs b/crates/gilti/src/views/mod.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/views/mod.rs
@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+pub mod about;
+pub mod archive_signature;
+pub mod blame;
+pub mod object;
+pub mod overview;
+pub mod refs;
+pub mod repositories;
+pub mod shared;
+pub mod tag;
+pub mod tree;
+
+fn method_not_allowed() -> axum::response::Response {
+ axum::response::Response::builder()
+ .status(axum::http::StatusCode::METHOD_NOT_ALLOWED)
+ .header(axum::http::header::CONTENT_TYPE, "text/plain")
+ .body(axum::body::Body::from("method not allowed\n"))
+ .expect("static response is valid")
+}
+
+fn error(error: crate::models::Error) -> axum::response::Response {
+ let (status, message) = match error {
+ crate::models::Error::NotFound => (axum::http::StatusCode::NOT_FOUND, "not found\n"),
+ crate::models::Error::Internal(message) => {
+ eprintln!("gilti: repository view failed: {message}");
+ (
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "internal server error\n",
+ )
+ }
+ };
+ axum::response::Response::builder()
+ .status(status)
+ .header(axum::http::header::CONTENT_TYPE, "text/plain")
+ .body(axum::body::Body::from(message))
+ .expect("static response is valid")
+}
diff --git a/crates/gilti/src/views/object.rs b/crates/gilti/src/views/object.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/views/object.rs
@@ -0,0 +1,52 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+pub async fn serve(
+ repositories: &'static str,
+ route: crate::router::RepoRoute<String>,
+ method: axum::http::Method,
+) -> axum::response::Response {
+ if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
+ return axum::response::Response::builder()
+ .status(axum::http::StatusCode::METHOD_NOT_ALLOWED)
+ .header(axum::http::header::CONTENT_TYPE, "text/plain")
+ .body(axum::body::Body::from("method not allowed\n"))
+ .expect("static response is valid");
+ }
+ let model = tokio::task::spawn_blocking(move || {
+ crate::models::object::RawObject::load(
+ std::path::Path::new(repositories),
+ &route.repo,
+ &route.params,
+ )
+ })
+ .await;
+ let model = match model {
+ Ok(Ok(model)) => model,
+ Ok(Err(error)) => return super::error(error),
+ Err(error) => {
+ return super::error(crate::models::Error::Internal(error.to_string()));
+ }
+ };
+ let length = model.bytes.len();
+ let body = if method == axum::http::Method::HEAD {
+ axum::body::Body::empty()
+ } else {
+ axum::body::Body::from(model.bytes)
+ };
+ axum::response::Response::builder()
+ .status(axum::http::StatusCode::OK)
+ .header(
+ axum::http::header::CONTENT_TYPE,
+ if model.binary {
+ "application/octet-stream; charset=UTF-8"
+ } else {
+ "text/plain; charset=UTF-8"
+ },
+ )
+ .header(axum::http::header::CONTENT_LENGTH, length)
+ .header("x-content-type-options", "nosniff")
+ .header("content-security-policy", "default-src 'none'")
+ .body(body)
+ .expect("object response is valid")
+}
diff --git a/crates/gilti/src/views/overview.rs b/crates/gilti/src/views/overview.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/views/overview.rs
@@ -0,0 +1,155 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use maud::{Markup, html};
+
+pub async fn serve(
+ context: &super::shared::Context,
+ route: crate::router::RepoRoute<()>,
+ headers: &axum::http::HeaderMap,
+ method: axum::http::Method,
+) -> axum::response::Response {
+ if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
+ return super::method_not_allowed();
+ }
+ let clone_url = clone_url(context, headers, &route.repo);
+ let repositories = context.repositories;
+ let model = tokio::task::spawn_blocking(move || {
+ crate::models::overview::Overview::load(std::path::Path::new(repositories), &route.repo)
+ })
+ .await;
+ let model = match model {
+ Ok(Ok(model)) => model,
+ Ok(Err(error)) => return super::error(error),
+ Err(error) => {
+ return super::error(crate::models::Error::Internal(error.to_string()));
+ }
+ };
+ let content = content(&model, &clone_url);
+ super::shared::render(
+ context,
+ &model.repository,
+ "HEAD",
+ super::shared::Page::Summary,
+ content,
+ &method,
+ )
+}
+
+fn content(model: &crate::models::overview::Overview, clone_url: &str) -> Markup {
+ let repo = super::shared::repository_url(&model.repository.name);
+ html! {
+ @if model.empty {
+ div class="error" { "Repository seems to be empty" }
+ }
+ table summary="repository info" class="list nowrap" {
+ @if !model.empty {
+ tr class="nohover" {
+ th class="left" { "Branch" }
+ th class="left" { "Commit message" }
+ th class="left" { "Author" }
+ th class="left" colspan="2" { "Age" }
+ }
+ @for branch in &model.branches {
+ @let revision = super::shared::encode_path(&branch.reference);
+ tr {
+ td { a href=(format!("{repo}/+/{revision}/+/log")) { (&branch.name) } }
+ td { a href=(format!("{repo}/+/{revision}")) { (&branch.subject) } }
+ td { (&branch.author) }
+ td colspan="2" { (super::shared::age(branch.timestamp)) }
+ }
+ }
+ @if !model.tags.is_empty() {
+ tr class="nohover" { td colspan="5" { " " } }
+ tr class="nohover" {
+ th class="left" { "Tag" }
+ th class="left" { "Download" }
+ th class="left" { "Author" }
+ th class="left" colspan="2" { "Age" }
+ }
+ @for tag in &model.tags {
+ @let revision = super::shared::encode_path(&tag.reference);
+ tr {
+ td { a href=(format!("{repo}/+/{revision}")) { (&tag.name) } }
+ td {
+ @if tag.downloadable {
+ @for (index, format) in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"].iter().enumerate() {
+ @if index > 0 { "  " }
+ (archive_link(&repo, &revision, format))
+ }
+ } @else {
+ a href=(format!("{repo}/+/object/{}", tag.target)) { (&tag.target) }
+ }
+ }
+ td { (&tag.author) }
+ td colspan="2" { (super::shared::age(tag.timestamp)) }
+ }
+ }
+ }
+ @if !model.commits.is_empty() {
+ tr class="nohover" { td colspan="5" { " " } }
+ tr class="nohover" {
+ th class="left" { "Age" }
+ th class="left" { "Commit message" }
+ th class="left" { "Author" }
+ th class="left" { "Files" }
+ th class="left" { "Lines" }
+ }
+ @for commit in &model.commits {
+ tr {
+ td { (super::shared::age(commit.timestamp)) }
+ td {
+ a href=(format!("{repo}/+/{}", commit.oid)) { (&commit.subject) }
+ @if !commit.decorations.is_empty() { span class="decoration" {
+ @for decoration in &commit.decorations {
+ @if let Some(reference) = &decoration.reference {
+ @let revision = super::shared::encode_path(reference);
+ a class=(if decoration.tag { "tag-annotated-deco" } else { "branch-deco" }) href=(if decoration.tag { format!("{repo}/+/{revision}") } else { format!("{repo}/+/{revision}/+/log") }) { (&decoration.label) }
+ } @else {
+ a class="deco" href=(format!("{repo}/+/{}", commit.oid)) { (&decoration.label) }
+ }
+ }
+ } }
+ }
+ td { (&commit.author) }
+ td { (commit.files) }
+ td { span class="deletions" { "-" (commit.deletions) } "/" span class="insertions" { "+" (commit.insertions) } }
+ }
+ }
+ }
+ }
+ tr class="nohover" { td colspan="5" { " " } }
+ tr class="nohover" { th class="left" colspan="5" { "Clone" } }
+ tr { td colspan="5" { a rel="vcs-git" href=(clone_url) { (clone_url) } } }
+ }
+ }
+}
+
+fn archive_link(repository: &str, revision: &str, format: &str) -> Markup {
+ html! { a href=(format!("{repository}/+/{revision}/+/archive?format={format}")) { (format) } }
+}
+
+fn clone_url(
+ context: &super::shared::Context,
+ headers: &axum::http::HeaderMap,
+ repository: &str,
+) -> String {
+ let repository = super::shared::encode_path(repository);
+ if !context.clone_prefix.is_empty() {
+ return format!(
+ "{}/{}.git",
+ context.clone_prefix.trim_end_matches('/'),
+ repository
+ );
+ }
+ let scheme = headers
+ .get("x-forwarded-proto")
+ .and_then(|value| value.to_str().ok())
+ .filter(|value| matches!(*value, "http" | "https"))
+ .unwrap_or("http");
+ let host = headers
+ .get(axum::http::header::HOST)
+ .and_then(|value| value.to_str().ok())
+ .unwrap_or("localhost");
+ format!("{scheme}://{host}/{repository}.git")
+}
diff --git a/crates/gilti/src/views/refs.rs b/crates/gilti/src/views/refs.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/views/refs.rs
@@ -0,0 +1,87 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use maud::{Markup, html};
+
+pub async fn serve(
+ context: &super::shared::Context,
+ route: crate::router::RepoRoute<()>,
+ method: axum::http::Method,
+) -> axum::response::Response {
+ if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
+ return super::method_not_allowed();
+ }
+ let repositories = context.repositories;
+ let model = tokio::task::spawn_blocking(move || {
+ crate::models::refs::Refs::load(std::path::Path::new(repositories), &route.repo)
+ })
+ .await;
+ let model = match model {
+ Ok(Ok(model)) => model,
+ Ok(Err(error)) => return super::error(error),
+ Err(error) => {
+ return super::error(crate::models::Error::Internal(error.to_string()));
+ }
+ };
+ let content = content(&model);
+ super::shared::render(
+ context,
+ &model.repository,
+ "HEAD",
+ super::shared::Page::Refs,
+ content,
+ &method,
+ )
+}
+
+fn content(model: &crate::models::refs::Refs) -> Markup {
+ let repo = super::shared::repository_url(&model.repository.name);
+ html! { table class="list nowrap" {
+ tr class="nohover" {
+ th class="left" { "Branch" }
+ th class="left" { "Commit message" }
+ th class="left" { "Author" }
+ th class="left" colspan="2" { "Age" }
+ }
+ @for branch in &model.branches {
+ @let revision = super::shared::encode_path(&branch.reference);
+ tr {
+ td { a href=(format!("{repo}/+/{revision}/+/log")) { (&branch.name) } }
+ td { a href=(format!("{repo}/+/{revision}")) { (&branch.subject) } }
+ td { (&branch.author) }
+ td colspan="2" { (super::shared::age(branch.timestamp)) }
+ }
+ }
+ @if !model.tags.is_empty() {
+ tr class="nohover" { td colspan="5" { " " } }
+ tr class="nohover" {
+ th class="left" { "Tag" }
+ th class="left" { "Download" }
+ th class="left" { "Author" }
+ th class="left" colspan="2" { "Age" }
+ }
+ }
+ @for tag in &model.tags {
+ @let revision = super::shared::encode_path(&tag.reference);
+ tr {
+ td { a href=(format!("{repo}/+/{revision}")) { (&tag.name) } }
+ td {
+ @if tag.downloadable {
+ @for (index, format) in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"].iter().enumerate() {
+ @if index > 0 { "  " }
+ (archive_link(&repo, &revision, format))
+ }
+ } @else {
+ a href=(format!("{repo}/+/object/{}", tag.target)) { (&tag.target) }
+ }
+ }
+ td { (&tag.author) }
+ td colspan="2" { (super::shared::age(tag.timestamp)) }
+ }
+ }
+ } }
+}
+
+fn archive_link(repository: &str, revision: &str, format: &str) -> Markup {
+ html! { a href=(format!("{repository}/+/{revision}/+/archive?format={format}")) { (format) } }
+}
diff --git a/crates/gilti/src/views/repositories.rs b/crates/gilti/src/views/repositories.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/views/repositories.rs
@@ -0,0 +1,170 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use maud::{DOCTYPE, html};
+
+pub struct Query {
+ search: Option<String>,
+ sort: crate::models::repositories::Sort,
+ sort_name: &'static str,
+ offset: usize,
+}
+
+impl Query {
+ pub fn from_request(query: &crate::RequestQuery) -> Self {
+ let (sort, sort_name) = match query.value("GILTI_QUERY_SORT") {
+ Some("desc") => (crate::models::repositories::Sort::Description, "desc"),
+ Some("owner") => (crate::models::repositories::Sort::Owner, "owner"),
+ Some("idle") => (crate::models::repositories::Sort::Idle, "idle"),
+ _ => (crate::models::repositories::Sort::Name, "name"),
+ };
+ Self {
+ search: query.value("GILTI_QUERY_SEARCH").map(str::to_owned),
+ sort,
+ sort_name,
+ offset: query
+ .value("GILTI_QUERY_OFFSET")
+ .and_then(|offset| offset.parse().ok())
+ .unwrap_or(0),
+ }
+ }
+}
+
+pub async fn serve(
+ context: &super::shared::Context,
+ query: Query,
+ method: axum::http::Method,
+) -> axum::response::Response {
+ if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
+ return super::method_not_allowed();
+ }
+ let repositories = context.repositories;
+ let filter = crate::models::repositories::Filter {
+ search: query.search.clone(),
+ sort: query.sort,
+ offset: query.offset,
+ limit: 50,
+ };
+ let model = tokio::task::spawn_blocking(move || {
+ crate::models::repositories::Repositories::load(std::path::Path::new(repositories), filter)
+ })
+ .await;
+ let model = match model {
+ Ok(Ok(model)) => model,
+ Ok(Err(error)) => return super::error(error),
+ Err(error) => {
+ return super::error(crate::models::Error::Internal(error.to_string()));
+ }
+ };
+ let document = html! {
+ (DOCTYPE)
+ html lang="en" {
+ head {
+ title { (&*context.root_title) }
+ meta name="generator" content="Gilti";
+ meta name="robots" content="index, nofollow";
+ link rel="stylesheet" type="text/css" href="/-/assets/cgit.css";
+ script type="text/javascript" src="/-/assets/cgit.js" {}
+ link rel="shortcut icon" href="/-/assets/favicon.ico";
+ }
+ body { div id="cgit" {
+ table id="header" {
+ tr {
+ td class="logo" rowspan="2" { a href="/" { img src="/-/assets/cgit.png" alt="cgit logo"; } }
+ td class="main" { (&*context.root_title) }
+ }
+ tr { td class="sub" { (&*context.root_description) } }
+ }
+ table class="tabs" { tr {
+ td { a href="/" class="active" { "index" } }
+ td class="form" { form method="get" action="/" {
+ input type="search" name="q" size="10" value=(query.search.as_deref().unwrap_or_default());
+ input type="submit" value="search";
+ } }
+ } }
+ div class="content" { table summary="repository list" class="list nowrap" {
+ @if !model.rows.is_empty() {
+ tr class="nohover" {
+ th class="left" { a href=(sort_url("name", query.search.as_deref())) { "Name" } }
+ th class="left" { a href=(sort_url("desc", query.search.as_deref())) { "Description" } }
+ th class="left" { a href=(sort_url("owner", query.search.as_deref())) { "Owner" } }
+ th class="left" { a href=(sort_url("idle", query.search.as_deref())) { "Idle" } }
+ th class="left" { "Links" }
+ }
+ }
+ @for repository in &model.rows {
+ @let url = super::shared::repository_url(&repository.name);
+ tr {
+ td class="toplevel-repo" { a href=(&url) { (&repository.name) } }
+ td { a href=(&url) { (description(&repository.description)) } }
+ td { a href="/?q=" {} }
+ td { @if let Some(timestamp) = repository.timestamp { (super::shared::age(timestamp)) } }
+ td {
+ a class="button" href=(&url) { "summary" }
+ @if repository.populated {
+ a class="button" href=(format!("{url}/+/HEAD/+/log")) { "log" }
+ a class="button" href=(format!("{url}/+/HEAD/+/tree")) { "tree" }
+ }
+ }
+ }
+ }
+ }
+ @if model.offset > 0 || model.has_next {
+ ul class="pager" {
+ @if model.offset > 0 {
+ li { a href=(page_url(model.offset.saturating_sub(50), query.sort_name, query.search.as_deref())) { "[previous]" } }
+ }
+ @if model.has_next {
+ li { a href=(page_url(model.offset + 50, query.sort_name, query.search.as_deref())) { "[next]" } }
+ }
+ }
+ }
+ }
+ div class="footer" { "generated by Gilti" }
+ } }
+ }
+ }
+ .into_string();
+ let length = document.len();
+ let body = if method == axum::http::Method::HEAD {
+ axum::body::Body::empty()
+ } else {
+ axum::body::Body::from(document)
+ };
+ axum::response::Response::builder()
+ .status(axum::http::StatusCode::OK)
+ .header(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8")
+ .header(axum::http::header::CONTENT_LENGTH, length)
+ .body(body)
+ .expect("repository list response is valid")
+}
+
+fn description(description: &str) -> String {
+ let mut characters = description.chars();
+ let shown = characters.by_ref().take(80).collect::<String>();
+ if characters.next().is_some() {
+ format!("{shown}...")
+ } else {
+ shown
+ }
+}
+
+fn sort_url(sort: &str, search: Option<&str>) -> String {
+ search.map_or_else(
+ || format!("/?s={sort}"),
+ |search| format!("/?s={sort}&q={}", query_value(search)),
+ )
+}
+
+fn page_url(offset: usize, sort: &str, search: Option<&str>) -> String {
+ let mut url = format!("/?ofs={offset}&s={sort}");
+ if let Some(search) = search {
+ url.push_str("&q=");
+ url.push_str(&query_value(search));
+ }
+ url
+}
+
+fn query_value(value: &str) -> String {
+ percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string()
+}
diff --git a/crates/gilti/src/views/shared.rs b/crates/gilti/src/views/shared.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/views/shared.rs
@@ -0,0 +1,126 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use maud::{DOCTYPE, Markup, html};
+
+#[derive(Clone)]
+pub struct Context {
+ pub repositories: &'static str,
+ pub root_title: std::sync::Arc<str>,
+ pub root_description: std::sync::Arc<str>,
+ pub clone_prefix: std::sync::Arc<str>,
+}
+
+#[derive(Clone, Copy, Eq, PartialEq)]
+pub enum Page {
+ About,
+ Summary,
+ Refs,
+ Log,
+ Tree,
+ Revision,
+ Diff,
+ Stats,
+}
+
+pub fn render(
+ context: &Context,
+ repository: &crate::models::repository::Info,
+ revision: &str,
+ active: Page,
+ content: Markup,
+ method: &axum::http::Method,
+) -> axum::response::Response {
+ let repo = repository_url(&repository.name);
+ let rev = encode_path(revision);
+ let title = format!("{} - {}", repository.name, repository.description);
+ let document = html! {
+ (DOCTYPE)
+ html lang="en" {
+ head {
+ title { (title) }
+ meta name="generator" content="Gilti";
+ meta name="robots" content="index, nofollow";
+ link rel="stylesheet" type="text/css" href="/-/assets/cgit.css";
+ script type="text/javascript" src="/-/assets/cgit.js" {}
+ link rel="shortcut icon" href="/-/assets/favicon.ico";
+ }
+ body { div id="cgit" {
+ table id="header" {
+ tr {
+ td class="logo" rowspan="2" { a href="/" { img src="/-/assets/cgit.png" alt="cgit logo"; } }
+ td class="main" { (&*context.root_title) " : " a href=(&repo) { (&repository.name) } }
+ }
+ tr { td class="sub" { (&repository.description) } }
+ }
+ table class="tabs" { tr { td {
+ (tab(&format!("{repo}/+/about"), "about", active == Page::About))
+ (tab(&repo, "summary", active == Page::Summary))
+ (tab(&format!("{repo}/+/refs"), "refs", active == Page::Refs))
+ (tab(&format!("{repo}/+/{rev}/+/log"), "log", active == Page::Log))
+ (tab(&format!("{repo}/+/{rev}/+/tree"), "tree", active == Page::Tree))
+ (tab(&format!("{repo}/+/{rev}"), "commit", active == Page::Revision))
+ (tab(&format!("{repo}/+/diff/HEAD..{rev}"), "diff", active == Page::Diff))
+ (tab(&format!("{repo}/+/stats"), "stats", active == Page::Stats))
+ } } }
+ div class="content" { (content) }
+ div class="footer" { "generated by Gilti" }
+ } }
+ }
+ }
+ .into_string();
+ let length = document.len();
+ let body = if method == axum::http::Method::HEAD {
+ axum::body::Body::empty()
+ } else {
+ axum::body::Body::from(document)
+ };
+ axum::response::Response::builder()
+ .status(axum::http::StatusCode::OK)
+ .header(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8")
+ .header(axum::http::header::CONTENT_LENGTH, length)
+ .body(body)
+ .expect("HTML response is valid")
+}
+
+fn tab(url: &str, label: &str, active: bool) -> Markup {
+ html! { a href=(url) class=[active.then_some("active")] { (label) } }
+}
+
+pub fn repository_url(repository: &str) -> String {
+ format!("/{}", encode_path(repository))
+}
+
+pub fn encode_path(value: &str) -> String {
+ let mut encoded = String::new();
+ for byte in value.bytes() {
+ if byte.is_ascii_alphanumeric() || byte == b'/' || byte == b'_' {
+ encoded.push(char::from(byte));
+ } else {
+ use std::fmt::Write;
+ write!(encoded, "%{byte:02x}").expect("writing to String cannot fail");
+ }
+ }
+ encoded
+}
+
+pub fn age(timestamp: i64) -> String {
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_or(0, |duration| duration.as_secs() as i64);
+ let seconds = now.saturating_sub(timestamp).max(0);
+ let (amount, unit) = if seconds < 120 * 60 {
+ (seconds / 60, "min.")
+ } else if seconds < 48 * 60 * 60 {
+ (seconds / (60 * 60), "hours")
+ } else if seconds < 14 * 24 * 60 * 60 {
+ (seconds / (24 * 60 * 60), "days")
+ } else if seconds < 60 * 24 * 60 * 60 {
+ (seconds / (7 * 24 * 60 * 60), "weeks")
+ } else if seconds < 730 * 24 * 60 * 60 {
+ (seconds / (30 * 24 * 60 * 60), "months")
+ } else {
+ (seconds / (365 * 24 * 60 * 60), "years")
+ };
+ format!("{amount} {unit} ago")
+}
diff --git a/crates/gilti/src/views/tag.rs b/crates/gilti/src/views/tag.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/views/tag.rs
@@ -0,0 +1,76 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use maud::{Markup, html};
+
+pub async fn serve(
+ context: &super::shared::Context,
+ route: crate::router::RepoRoute<crate::router::Revision>,
+ method: axum::http::Method,
+) -> axum::response::Response {
+ if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
+ return super::method_not_allowed();
+ }
+ let crate::router::Revision::Ref(reference) = route.params else {
+ return super::error(crate::models::Error::NotFound);
+ };
+ let repositories = context.repositories;
+ let model = tokio::task::spawn_blocking(move || {
+ crate::models::tag::Tag::load(std::path::Path::new(repositories), &route.repo, reference)
+ })
+ .await;
+ let model = match model {
+ Ok(Ok(model)) => model,
+ Ok(Err(error)) => return super::error(error),
+ Err(error) => {
+ return super::error(crate::models::Error::Internal(error.to_string()));
+ }
+ };
+ let content = content(&model);
+ super::shared::render(
+ context,
+ &model.repository,
+ &model.reference,
+ super::shared::Page::Revision,
+ content,
+ &method,
+ )
+}
+
+fn content(model: &crate::models::tag::Tag) -> Markup {
+ let repo = super::shared::repository_url(&model.repository.name);
+ let revision = super::shared::encode_path(&model.reference);
+ html! {
+ table class="commit-info" {
+ tr { td { "tag name" } td { (&model.name) @if model.annotated { " (" (&model.oid) ")" } } }
+ @if let Some(timestamp) = model.timestamp {
+ tr { td { "tag date" } td { (super::shared::age(timestamp)) } }
+ }
+ @if model.annotated {
+ tr { td { "tagged by" } td { (&model.tagger) " <" (&model.tagger_email) ">" } }
+ }
+ tr { td { "tagged object" } td class="oid" {
+ @for (index, target) in model.targets.iter().enumerate() {
+ @if index > 0 { " → " }
+ @if target.commit {
+ a href=(format!("{repo}/+/{}", target.oid)) { (&target.oid) }
+ } @else {
+ a href=(format!("{repo}/+/object/{}", target.oid)) { (&target.oid) }
+ }
+ }
+ } }
+ @if model.downloadable {
+ tr { td { "download" } td class="oid" {
+ @for format in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"] {
+ a href=(format!("{repo}/+/{revision}/+/archive?format={format}")) { (format) } br;
+ }
+ } }
+ }
+ }
+ @if model.annotated {
+ @let mut lines = model.message.splitn(2, '\n');
+ div class="commit-subject" { (lines.next().unwrap_or_default()) }
+ div class="commit-msg" { (lines.next().unwrap_or_default()) }
+ }
+ }
+}
diff --git a/crates/gilti/src/views/tree.rs b/crates/gilti/src/views/tree.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/views/tree.rs
@@ -0,0 +1,293 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use maud::{DOCTYPE, Markup, html};
+
+pub async fn serve(
+ context: &super::shared::Context,
+ route: crate::router::RepoRoute<crate::router::RevisionPath>,
+ format: Option<&str>,
+ method: axum::http::Method,
+) -> axum::response::Response {
+ if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
+ return super::method_not_allowed();
+ }
+ let repositories = context.repositories;
+ let model = tokio::task::spawn_blocking(move || {
+ crate::models::tree::Tree::load(
+ std::path::Path::new(repositories),
+ &route.repo,
+ route.params.rev,
+ route.params.path,
+ )
+ })
+ .await;
+ let model = match model {
+ Ok(Ok(model)) => model,
+ Ok(Err(error)) => return super::error(error),
+ Err(error) => {
+ return super::error(crate::models::Error::Internal(error.to_string()));
+ }
+ };
+ if format == Some("raw") {
+ return raw(model, &method);
+ }
+ let content = html_content(&model);
+ super::shared::render(
+ context,
+ &model.repository,
+ &model.revision,
+ super::shared::Page::Tree,
+ content,
+ &method,
+ )
+}
+
+fn html_content(model: &crate::models::tree::Tree) -> Markup {
+ let repo = super::shared::repository_url(&model.repository.name);
+ let revision = super::shared::encode_path(&model.revision);
+ let prefix = format!("{repo}/+/{revision}/+/tree");
+ html! {
+ @if let Some(path) = &model.path {
+ div class="path" { "path: " (breadcrumbs(&prefix, path)) }
+ }
+ @match &model.content {
+ crate::models::tree::Content::Directory { entries, .. } => {
+ table summary="tree listing" class="list" {
+ tr class="nohover" {
+ th class="left" { "Mode" }
+ th class="left" { "Name" }
+ th class="right" { "Size" }
+ th {}
+ }
+ @for entry in entries {
+ @let path = super::shared::encode_path(&entry.path);
+ tr {
+ td class="ls-mode" { (filemode(entry.mode)) }
+ td {
+ @match entry.kind {
+ crate::models::tree::Kind::Submodule => {
+ span class="ls-mod" { (&entry.name) " @ " (&entry.oid) }
+ }
+ _ => {
+ a href=(format!("{prefix}/{path}")) class=(entry_class(entry)) { (&entry.name) }
+ @if let Some(target) = &entry.symlink_target {
+ " -> " a href=(format!("{prefix}/{}", super::shared::encode_path(&symlink_path(&entry.path, target)))) class="ls-blob" { (target) }
+ }
+ }
+ }
+ }
+ td class="ls-size" { (entry.size) }
+ td {
+ a class="button" href=(format!("{repo}/+/{revision}/+/log/{path}")) { "log" }
+ a class="button" href=(format!("{repo}/+/stats")) { "stats" }
+ @if entry.kind != crate::models::tree::Kind::Submodule {
+ a class="button" href=(format!("{prefix}/{path}?format=raw")) { "plain" }
+ }
+ @if entry.kind == crate::models::tree::Kind::Blob && entry.symlink_target.is_none() {
+ a class="button" href=(format!("{repo}/+/{revision}/+/blame/{path}")) { "blame" }
+ }
+ }
+ }
+ }
+ }
+ }
+ crate::models::tree::Content::Blob { oid, bytes, binary } => {
+ "blob: " (oid) " ("
+ a href=(format!("{prefix}/{}?format=raw", super::shared::encode_path(model.path.as_deref().unwrap_or_default()))) { "plain" }
+ @if !binary { ") (" a href=(format!("{repo}/+/{revision}/+/blame/{}", super::shared::encode_path(model.path.as_deref().unwrap_or_default()))) { "blame" } }
+ ")"
+ @if *binary { (binary_blob(bytes)) } @else { (text_blob(bytes)) }
+ }
+ }
+ }
+}
+
+fn filemode(mode: u32) -> String {
+ let mut value = String::with_capacity(10);
+ value.push(match mode {
+ 0o040000 => 'd',
+ 0o120000 => 'l',
+ 0o160000 => 'm',
+ _ => '-',
+ });
+ for bit in [
+ 0o400, 0o200, 0o100, 0o040, 0o020, 0o010, 0o004, 0o002, 0o001,
+ ] {
+ value.push(if mode & bit == 0 {
+ '-'
+ } else {
+ match bit {
+ 0o400 | 0o040 | 0o004 => 'r',
+ 0o200 | 0o020 | 0o002 => 'w',
+ _ => 'x',
+ }
+ });
+ }
+ value
+}
+
+fn entry_class(entry: &crate::models::tree::Entry) -> String {
+ if entry.kind == crate::models::tree::Kind::Tree {
+ return "ls-dir".to_owned();
+ }
+ let extension = entry.name.rsplit_once('.').map(|(_, extension)| extension);
+ extension.map_or_else(
+ || "ls-blob".to_owned(),
+ |extension| format!("ls-blob {extension}"),
+ )
+}
+
+fn symlink_path(path: &str, target: &str) -> String {
+ let mut parts = path.rsplit_once('/').map_or_else(Vec::new, |(parent, _)| {
+ parent.split('/').collect::<Vec<_>>()
+ });
+ for part in target.split('/') {
+ match part {
+ "" | "." => {}
+ ".." => {
+ parts.pop();
+ }
+ part => parts.push(part),
+ }
+ }
+ parts.join("/")
+}
+
+fn breadcrumbs(prefix: &str, path: &str) -> Markup {
+ let parts = path.split('/').collect::<Vec<_>>();
+ html! {
+ a href=(prefix) { "root" }
+ @for (index, part) in parts.iter().enumerate() {
+ "/"
+ @let path = parts[..=index].join("/");
+ a href=(format!("{prefix}/{}", super::shared::encode_path(&path))) { (part) }
+ }
+ }
+}
+
+fn text_blob(bytes: &[u8]) -> Markup {
+ let text = String::from_utf8_lossy(bytes);
+ let lines = if bytes.is_empty() {
+ 0
+ } else {
+ bytes.iter().filter(|byte| **byte == b'\n').count()
+ + usize::from(bytes.last() != Some(&b'\n'))
+ };
+ html! { table summary="blob content" class="blob" { tr {
+ td class="linenumbers" { pre {
+ @for line in 1..=lines { a id=(format!("n{line}")) href=(format!("#n{line}")) { (line) } "\n" }
+ } }
+ td class="lines" { pre { code { (text) } } }
+ } } }
+}
+
+fn binary_blob(bytes: &[u8]) -> Markup {
+ html! { table summary="blob content" class="bin-blob" {
+ tr { th { "ofs" } th { "hex dump" } th { "ascii" } }
+ @for (row, chunk) in bytes.chunks(32).enumerate() {
+ tr {
+ td class="right" { (format!("{:04x}", row * 32)) }
+ td class="hex" { (chunk.iter().map(|byte| format!("{byte:02x}")).collect::<Vec<_>>().join(" ")) }
+ td class="hex" { (chunk.iter().map(|byte| if byte.is_ascii_graphic() { char::from(*byte) } else { '.' }).collect::<String>()) }
+ }
+ }
+ } }
+}
+
+fn raw(model: crate::models::tree::Tree, method: &axum::http::Method) -> axum::response::Response {
+ let filename = model.path.as_deref().map(content_disposition);
+ match model.content {
+ crate::models::tree::Content::Blob { oid, bytes, binary } => {
+ let length = bytes.len();
+ let body = if method == axum::http::Method::HEAD {
+ axum::body::Body::empty()
+ } else {
+ axum::body::Body::from(bytes)
+ };
+ axum::response::Response::builder()
+ .status(axum::http::StatusCode::OK)
+ .header(
+ axum::http::header::CONTENT_TYPE,
+ if binary {
+ "application/octet-stream"
+ } else {
+ "text/plain; charset=UTF-8"
+ },
+ )
+ .header(axum::http::header::CONTENT_LENGTH, length)
+ .header(axum::http::header::ETAG, format!("\"{oid}\""))
+ .header(
+ axum::http::header::CONTENT_DISPOSITION,
+ filename.as_deref().unwrap_or("inline"),
+ )
+ .body(body)
+ .expect("raw blob response is valid")
+ }
+ crate::models::tree::Content::Directory { oid, entries } => {
+ let repo = super::shared::repository_url(&model.repository.name);
+ let revision = super::shared::encode_path(&model.revision);
+ let current = model.path.as_deref().unwrap_or_default();
+ let title = if current.is_empty() {
+ "/".to_owned()
+ } else {
+ format!("/{current}/")
+ };
+ let document = html! {
+ (DOCTYPE)
+ html { head { title { (&title) } } body {
+ h2 { (&title) }
+ ul {
+ @if let Some((parent, _)) = current.rsplit_once('/') {
+ li { a href=(format!("{repo}/+/{revision}/+/tree/{}?format=raw", super::shared::encode_path(parent))) { "../" } }
+ } @else if !current.is_empty() {
+ li { a href=(format!("{repo}/+/{revision}/+/tree?format=raw")) { "../" } }
+ }
+ @for entry in entries {
+ @let path = super::shared::encode_path(&entry.path);
+ li {
+ @if entry.kind == crate::models::tree::Kind::Submodule {
+ (&entry.name) " @ " (&entry.oid)
+ } @else {
+ a href=(format!("{repo}/+/{revision}/+/tree/{path}?format=raw")) { (&entry.name) @if entry.kind == crate::models::tree::Kind::Tree { "/" } }
+ }
+ }
+ }
+ }
+ } }
+ }
+ .into_string();
+ let length = document.len();
+ let body = if method == axum::http::Method::HEAD {
+ axum::body::Body::empty()
+ } else {
+ axum::body::Body::from(document)
+ };
+ axum::response::Response::builder()
+ .status(axum::http::StatusCode::OK)
+ .header(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8")
+ .header(axum::http::header::CONTENT_LENGTH, length)
+ .header(axum::http::header::ETAG, format!("\"{oid}\""))
+ .body(body)
+ .expect("raw directory response is valid")
+ }
+ }
+}
+
+fn content_disposition(path: &str) -> String {
+ let name = path.rsplit('/').next().unwrap_or(path);
+ let fallback = name
+ .chars()
+ .map(|character| {
+ if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
+ character
+ } else {
+ '_'
+ }
+ })
+ .collect::<String>();
+ format!(
+ "inline; filename=\"{fallback}\"; filename*=UTF-8''{}",
+ super::shared::encode_path(name)
+ )
+}