summaryrefslogtreecommitdiff
path: root/docs/handbook/cgit
diff options
context:
space:
mode:
Diffstat (limited to 'docs/handbook/cgit')
-rw-r--r--docs/handbook/cgit/api-reference.md468
-rw-r--r--docs/handbook/cgit/architecture.md422
-rw-r--r--docs/handbook/cgit/authentication.md288
-rw-r--r--docs/handbook/cgit/building.md272
-rw-r--r--docs/handbook/cgit/caching-system.md287
-rw-r--r--docs/handbook/cgit/code-style.md356
-rw-r--r--docs/handbook/cgit/configuration.md351
-rw-r--r--docs/handbook/cgit/css-theming.md522
-rw-r--r--docs/handbook/cgit/deployment.md369
-rw-r--r--docs/handbook/cgit/diff-engine.md352
-rw-r--r--docs/handbook/cgit/filter-system.md358
-rw-r--r--docs/handbook/cgit/html-rendering.md380
-rw-r--r--docs/handbook/cgit/lua-integration.md428
-rw-r--r--docs/handbook/cgit/overview.md262
-rw-r--r--docs/handbook/cgit/repository-discovery.md355
-rw-r--r--docs/handbook/cgit/snapshot-system.md246
-rw-r--r--docs/handbook/cgit/testing.md335
-rw-r--r--docs/handbook/cgit/ui-modules.md544
-rw-r--r--docs/handbook/cgit/url-routing.md331
19 files changed, 6926 insertions, 0 deletions
diff --git a/docs/handbook/cgit/api-reference.md b/docs/handbook/cgit/api-reference.md
new file mode 100644
index 0000000000..0c38564e74
--- /dev/null
+++ b/docs/handbook/cgit/api-reference.md
@@ -0,0 +1,468 @@
+# cgit — API Reference
+
+## Overview
+
+This document catalogs all public function prototypes, types, and global
+variables exported by cgit's header files. Functions are grouped by header
+file and module.
+
+## `cgit.h` — Core Types and Functions
+
+### Core Structures
+
+```c
+struct cgit_environment {
+ const char *cgit_config; /* CGIT_CONFIG env var */
+ const char *http_host; /* HTTP_HOST */
+ const char *https; /* HTTPS */
+ const char *no_http; /* NO_HTTP */
+ const char *http_cookie; /* HTTP_COOKIE */
+ const char *request_method; /* REQUEST_METHOD */
+ const char *query_string; /* QUERY_STRING */
+ const char *http_referer; /* HTTP_REFERER */
+ const char *path_info; /* PATH_INFO */
+ const char *script_name; /* SCRIPT_NAME */
+ const char *server_name; /* SERVER_NAME */
+ const char *server_port; /* SERVER_PORT */
+ const char *http_accept; /* HTTP_ACCEPT */
+ int authenticated; /* authentication result */
+};
+
+struct cgit_query {
+ char *raw;
+ char *repo;
+ char *page;
+ char *search;
+ char *grep;
+ char *head;
+ char *sha1;
+ char *sha2;
+ char *path;
+ char *name;
+ char *url;
+ char *mimetype;
+ char *etag;
+ int nohead;
+ int ofs;
+ int has_symref;
+ int has_sha1;
+ int has_dot;
+ int ignored;
+ char *sort;
+ int showmsg;
+ int ssdiff;
+ int show_all;
+ int context;
+ int follow;
+ int dt;
+ int log_hierarchical_threading;
+};
+
+struct cgit_page {
+ const char *mimetype;
+ const char *charset;
+ const char *filename;
+ const char *etag;
+ const char *title;
+ int status;
+ time_t modified;
+ time_t expires;
+ size_t size;
+};
+
+struct cgit_config {
+ char *root_title;
+ char *root_desc;
+ char *root_readme;
+ char *root_coc;
+ char *root_cla;
+ char *root_homepage;
+ char *root_homepage_title;
+ struct string_list root_links;
+ char *css;
+ struct string_list css_list;
+ char *js;
+ struct string_list js_list;
+ char *logo;
+ char *logo_link;
+ char *favicon;
+ char *header;
+ char *footer;
+ char *head_include;
+ char *module_link;
+ char *virtual_root;
+ char *script_name;
+ char *section;
+ char *cache_root;
+ char *robots;
+ char *clone_prefix;
+ char *clone_url;
+ char *readme;
+ char *agefile;
+ char *project_list;
+ char *strict_export;
+ char *mimetype_file;
+ /* ... filter pointers, integer flags, limits ... */
+ int cache_size;
+ int cache_root_ttl;
+ int cache_repo_ttl;
+ int cache_dynamic_ttl;
+ int cache_static_ttl;
+ int cache_about_ttl;
+ int cache_snapshot_ttl;
+ int cache_scanrc_ttl;
+ int max_repo_count;
+ int max_commit_count;
+ int max_message_length;
+ int max_repodesc_length;
+ int max_blob_size;
+ int max_stats;
+ int max_atom_items;
+ int max_subtree_commits;
+ int summary_branches;
+ int summary_tags;
+ int summary_log;
+ int snapshots;
+ 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_subtree;
+ int enable_tree_linenumbers;
+ int enable_git_config;
+ int enable_filter_overrides;
+ int enable_follow_links;
+ int embedded;
+ int noheader;
+ int noplainemail;
+ int local_time;
+ int case_sensitive_sort;
+ int section_sort;
+ int section_from_path;
+ int side_by_side_diffs;
+ int remove_suffix;
+ int scan_hidden_path;
+ int branch_sort;
+ int commit_sort;
+ int renamelimit;
+};
+
+struct cgit_repo {
+ char *url;
+ char *name;
+ char *basename;
+ char *path;
+ char *desc;
+ char *owner;
+ char *homepage;
+ char *defbranch;
+ char *section;
+ char *clone_url;
+ char *logo;
+ char *logo_link;
+ char *readme;
+ char *module_link;
+ char *extra_head_content;
+ char *snapshot_prefix;
+ struct string_list badges;
+ struct cgit_filter *about_filter;
+ struct cgit_filter *commit_filter;
+ struct cgit_filter *source_filter;
+ struct cgit_filter *email_filter;
+ struct cgit_filter *owner_filter;
+ int snapshots;
+ 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_subtree;
+ int max_stats;
+ int max_subtree_commits;
+ int branch_sort;
+ int commit_sort;
+ int hide;
+ int ignore;
+};
+
+struct cgit_context {
+ struct cgit_environment env;
+ struct cgit_query qry;
+ struct cgit_config cfg;
+ struct cgit_page page;
+ struct cgit_repo *repo;
+};
+```
+
+### Global Variables
+
+```c
+extern struct cgit_context ctx;
+extern struct cgit_repolist cgit_repolist;
+extern const char *cgit_version;
+```
+
+### Repository Management
+
+```c
+extern struct cgit_repo *cgit_add_repo(const char *url);
+extern struct cgit_repo *cgit_get_repoinfo(const char *url);
+```
+
+### Parsing Functions
+
+```c
+extern void cgit_parse_url(const char *url);
+extern struct commitinfo *cgit_parse_commit(struct commit *commit);
+extern struct taginfo *cgit_parse_tag(struct tag *tag);
+extern void cgit_free_commitinfo(struct commitinfo *info);
+extern void cgit_free_taginfo(struct taginfo *info);
+```
+
+### Diff Functions
+
+```c
+typedef void (*filepair_fn)(struct diff_filepair *pair);
+typedef void (*linediff_fn)(char *line, int len);
+
+extern void cgit_diff_tree(const struct object_id *old_oid,
+ const struct object_id *new_oid,
+ filepair_fn fn, const char *prefix,
+ int renamelimit);
+extern void cgit_diff_commit(struct commit *commit, filepair_fn fn,
+ const char *prefix);
+extern void cgit_diff_files(const struct object_id *old_oid,
+ const struct object_id *new_oid,
+ unsigned long *old_size,
+ unsigned long *new_size,
+ int *binary, int context,
+ int ignorews, linediff_fn fn);
+```
+
+### Snapshot Functions
+
+```c
+extern int cgit_parse_snapshots_mask(const char *str);
+
+extern const struct cgit_snapshot_format cgit_snapshot_formats[];
+```
+
+### Filter Functions
+
+```c
+extern struct cgit_filter *cgit_new_filter(const char *cmd, filter_type type);
+extern int cgit_open_filter(struct cgit_filter *filter, ...);
+extern int cgit_close_filter(struct cgit_filter *filter);
+```
+
+### Utility Functions
+
+```c
+extern const char *cgit_repobasename(const char *reponame);
+extern char *cgit_default_repo_desc;
+extern int cgit_ref_path_exists(const char *path, const char *ref, int file_only);
+```
+
+## `html.h` — HTML Output Functions
+
+```c
+extern const char *fmt(const char *format, ...);
+extern char *fmtalloc(const char *format, ...);
+
+extern void html_raw(const char *data, size_t size);
+extern void html(const char *txt);
+extern void htmlf(const char *format, ...);
+extern void html_txt(const char *txt);
+extern void html_ntxt(const char *txt, int len);
+extern void html_attr(const char *txt);
+extern void html_url_path(const char *txt);
+extern void html_url_arg(const char *txt);
+extern void html_hidden(const char *name, const char *value);
+extern void html_option(const char *value, const char *text,
+ const char *selected_value);
+extern void html_link_open(const char *url, const char *title,
+ const char *class);
+extern void html_link_close(void);
+extern void html_include(const char *filename);
+extern void html_checkbox(const char *name, int value);
+extern void html_txt_input(const char *name, const char *value, int size);
+```
+
+## `ui-shared.h` — Page Layout and Links
+
+### HTTP and Layout
+
+```c
+extern void cgit_print_http_headers(void);
+extern void cgit_print_docstart(void);
+extern void cgit_print_docend(void);
+extern void cgit_print_pageheader(void);
+extern void cgit_print_layout_start(void);
+extern void cgit_print_layout_end(void);
+extern void cgit_print_error(const char *msg);
+extern void cgit_print_error_page(int code, const char *msg,
+ const char *fmt, ...);
+```
+
+### URL Generation
+
+```c
+extern const char *cgit_repourl(const char *reponame);
+extern const char *cgit_fileurl(const char *reponame, const char *pagename,
+ const char *filename, const char *query);
+extern const char *cgit_pageurl(const char *reponame, const char *pagename,
+ const char *query);
+extern const char *cgit_currurl(void);
+extern const char *cgit_rooturl(void);
+```
+
+### Link Functions
+
+```c
+extern void cgit_summary_link(const char *name, const char *title,
+ const char *class, const char *head);
+extern void cgit_tag_link(const char *name, const char *title,
+ const char *class, const char *tag);
+extern void cgit_tree_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+extern void cgit_log_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path,
+ int ofs, const char *grep, const char *pattern,
+ int showmsg, int follow);
+extern void cgit_commit_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+extern void cgit_patch_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+extern void cgit_refs_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+extern void cgit_diff_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *new_rev, const char *old_rev,
+ const char *path, int toggle);
+extern void cgit_stats_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *path);
+extern void cgit_plain_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+extern void cgit_blame_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+extern void cgit_object_link(struct object *obj);
+extern void cgit_submodule_link(const char *name, const char *path,
+ const char *commit);
+extern void cgit_print_snapshot_links(const char *repo, const char *head,
+ const char *hex, int snapshots);
+extern void cgit_print_branches(int max);
+extern void cgit_print_tags(int max);
+```
+
+### Diff Helpers
+
+```c
+extern void cgit_print_diff_hunk_header(int oldofs, int oldcnt,
+ int newofs, int newcnt,
+ const char *func);
+extern void cgit_print_diff_line_prefix(int type);
+```
+
+## `cmd.h` — Command Dispatch
+
+```c
+struct cgit_cmd {
+ const char *name;
+ void (*fn)(struct cgit_context *ctx);
+ unsigned int want_hierarchical:1;
+ unsigned int want_repo:1;
+ unsigned int want_layout:1;
+ unsigned int want_vpath:1;
+ unsigned int is_clone:1;
+};
+
+extern struct cgit_cmd *cgit_get_cmd(const char *name);
+```
+
+## `cache.h` — Cache System
+
+```c
+typedef void (*cache_fill_fn)(void *cbdata);
+
+extern int cache_process(int size, const char *path, const char *key,
+ int ttl, cache_fill_fn fn, void *cbdata);
+extern int cache_ls(const char *path);
+extern unsigned long hash_str(const char *str);
+```
+
+## `configfile.h` — Configuration File Parser
+
+```c
+typedef void (*configfile_value_fn)(const char *name, const char *value);
+
+extern int parse_configfile(const char *filename, configfile_value_fn fn);
+```
+
+## `scan-tree.h` — Repository Scanner
+
+```c
+typedef void (*repo_config_fn)(struct cgit_repo *repo,
+ const char *name, const char *value);
+
+extern void scan_projects(const char *path, const char *projectsfile,
+ repo_config_fn fn);
+extern void scan_tree(const char *path, repo_config_fn fn);
+```
+
+## `filter.c` — Filter Types
+
+```c
+#define ABOUT_FILTER 0
+#define COMMIT_FILTER 1
+#define SOURCE_FILTER 2
+#define EMAIL_FILTER 3
+#define AUTH_FILTER 4
+#define OWNER_FILTER 5
+
+typedef int filter_type;
+```
+
+## UI Module Entry Points
+
+Each `ui-*.c` module exposes one or more public functions:
+
+| Module | Function | Description |
+|--------|----------|-------------|
+| `ui-atom.c` | `cgit_print_atom(char *tip, char *path, int max)` | Generate Atom feed |
+| `ui-blame.c` | `cgit_print_blame(void)` | Render blame view |
+| `ui-blob.c` | `cgit_print_blob(const char *hex, char *path, const char *head, int file_only)` | Display blob content |
+| `ui-clone.c` | `cgit_clone_info(void)` | HTTP clone: `info/refs` |
+| `ui-clone.c` | `cgit_clone_objects(void)` | HTTP clone: pack objects |
+| `ui-clone.c` | `cgit_clone_head(void)` | HTTP clone: `HEAD` ref |
+| `ui-commit.c` | `cgit_print_commit(const char *rev, const char *prefix)` | Display commit |
+| `ui-diff.c` | `cgit_print_diff(const char *new_rev, const char *old_rev, const char *prefix, int show_ctrls, int raw)` | Render diff |
+| `ui-diff.c` | `cgit_print_diffstat(const struct object_id *old, const struct object_id *new, const char *prefix)` | Render diffstat |
+| `ui-log.c` | `cgit_print_log(const char *tip, int ofs, int cnt, char *grep, char *pattern, char *path, int pager, int commit_graph, int commit_sort)` | Display log |
+| `ui-patch.c` | `cgit_print_patch(const char *new_rev, const char *old_rev, const char *prefix)` | Generate patch |
+| `ui-plain.c` | `cgit_print_plain(void)` | Serve raw file content |
+| `ui-refs.c` | `cgit_print_refs(void)` | Display branches and tags |
+| `ui-repolist.c` | `cgit_print_repolist(void)` | Repository index page |
+| `ui-snapshot.c` | `cgit_print_snapshot(const char *head, const char *hex, const char *prefix, const char *filename, int snapshots)` | Generate archive |
+| `ui-stats.c` | `cgit_print_stats(void)` | Display statistics |
+| `ui-summary.c` | `cgit_print_summary(void)` | Repository summary page |
+| `ui-ssdiff.c` | `cgit_ssdiff_header_begin(void)` | Start ssdiff output |
+| `ui-ssdiff.c` | `cgit_ssdiff_header_end(void)` | End ssdiff header |
+| `ui-ssdiff.c` | `cgit_ssdiff_footer(void)` | End ssdiff output |
+| `ui-tag.c` | `cgit_print_tag(const char *revname)` | Display tag |
+| `ui-tree.c` | `cgit_print_tree(const char *rev, char *path)` | Display tree |
diff --git a/docs/handbook/cgit/architecture.md b/docs/handbook/cgit/architecture.md
new file mode 100644
index 0000000000..e35633a505
--- /dev/null
+++ b/docs/handbook/cgit/architecture.md
@@ -0,0 +1,422 @@
+# cgit — Architecture
+
+## High-Level Component Map
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ cgit.c │
+│ constructor_environment() [__attribute__((constructor))] │
+│ prepare_context() → config_cb() → querystring_cb() │
+│ authenticate_cookie() → process_request() → main() │
+├──────────────────────────────────────────────────────────────┤
+│ Command Dispatcher │
+│ cmd.c │
+│ cgit_get_cmd() → static cmds[] table (23 entries) │
+│ struct cgit_cmd { name, fn, want_repo, want_vpath, is_clone }│
+├──────────┬───────────┬───────────┬───────────────────────────┤
+│ UI Layer │ Caching │ Filters │ HTTP Clone │
+│ ui-*.c │ cache.c │ filter.c │ ui-clone.c │
+│ (17 mods)│ cache.h │ │ │
+├──────────┴───────────┴───────────┴───────────────────────────┤
+│ Core Utilities │
+│ shared.c — global vars, repo mgmt, diff wrappers │
+│ parsing.c — cgit_parse_commit(), cgit_parse_tag(), │
+│ cgit_parse_url() │
+│ html.c — entity escaping, URL encoding, form helpers │
+│ configfile.c — line-oriented name=value parser │
+│ scan-tree.c — filesystem repository discovery │
+├──────────────────────────────────────────────────────────────┤
+│ Vendored git library │
+│ git/ — full Git 2.46.0 source; linked via cgit.mk │
+│ Provides: object store, diff engine (xdiff), refs, revwalk, │
+│ archive, notes, commit graph, blame, packfile │
+└──────────────────────────────────────────────────────────────┘
+```
+
+## Global State
+
+cgit uses a single global variable to carry all request state:
+
+```c
+/* shared.c */
+struct cgit_repolist cgit_repolist; /* Array of all known repositories */
+struct cgit_context ctx; /* Current request context */
+```
+
+### `struct cgit_context`
+
+```c
+struct cgit_context {
+ struct cgit_environment env; /* CGI env vars (HTTP_HOST, QUERY_STRING, etc.) */
+ struct cgit_query qry; /* Parsed URL/query parameters */
+ struct cgit_config cfg; /* All global config directives */
+ struct cgit_repo *repo; /* Pointer into cgit_repolist.repos[] or NULL */
+ struct cgit_page page; /* HTTP response metadata (mimetype, status, etag) */
+};
+```
+
+### `struct cgit_environment`
+
+Populated by `prepare_context()` via `getenv()`:
+
+```c
+struct cgit_environment {
+ const char *cgit_config; /* $CGIT_CONFIG (default: /etc/cgitrc) */
+ const char *http_host; /* $HTTP_HOST */
+ const char *https; /* $HTTPS ("on" if TLS) */
+ const char *no_http; /* $NO_HTTP (non-NULL → CLI mode) */
+ const char *path_info; /* $PATH_INFO */
+ const char *query_string; /* $QUERY_STRING */
+ const char *request_method; /* $REQUEST_METHOD */
+ const char *script_name; /* $SCRIPT_NAME */
+ const char *server_name; /* $SERVER_NAME */
+ const char *server_port; /* $SERVER_PORT */
+ const char *http_cookie; /* $HTTP_COOKIE */
+ const char *http_referer; /* $HTTP_REFERER */
+ unsigned int content_length; /* $CONTENT_LENGTH */
+ int authenticated; /* Set by auth filter (0 or 1) */
+};
+```
+
+### `struct cgit_page`
+
+Controls HTTP response headers:
+
+```c
+struct cgit_page {
+ time_t modified; /* Last-Modified header */
+ time_t expires; /* Expires header */
+ size_t size; /* Content-Length (0 = omit) */
+ const char *mimetype; /* Content-Type (default: "text/html") */
+ const char *charset; /* charset param (default: "UTF-8") */
+ const char *filename; /* Content-Disposition filename */
+ const char *etag; /* ETag header value */
+ const char *title; /* HTML <title> */
+ int status; /* HTTP status code (0 = 200) */
+ const char *statusmsg; /* HTTP status message */
+};
+```
+
+## Request Lifecycle — Detailed
+
+### Phase 1: Pre-main Initialization
+
+```c
+__attribute__((constructor))
+static void constructor_environment()
+{
+ setenv("GIT_CONFIG_NOSYSTEM", "1", 1);
+ setenv("GIT_ATTR_NOSYSTEM", "1", 1);
+ unsetenv("HOME");
+ unsetenv("XDG_CONFIG_HOME");
+}
+```
+
+This runs before `main()` on every invocation. It prevents Git from loading
+`/etc/gitconfig`, `~/.gitconfig`, or any `$XDG_CONFIG_HOME/git/config`, ensuring
+complete isolation from the host system's Git configuration.
+
+### Phase 2: Context Preparation
+
+`prepare_context()` zero-initializes `ctx` and sets every configuration field
+to its default value. Key defaults:
+
+| Field | Default |
+|-------|---------|
+| `cfg.cache_size` | `0` (disabled) |
+| `cfg.cache_root` | `CGIT_CACHE_ROOT` (`/var/cache/cgit`) |
+| `cfg.cache_repo_ttl` | `5` minutes |
+| `cfg.cache_root_ttl` | `5` minutes |
+| `cfg.cache_static_ttl` | `-1` (never expires) |
+| `cfg.max_repo_count` | `50` |
+| `cfg.max_commit_count` | `50` |
+| `cfg.max_msg_len` | `80` |
+| `cfg.max_repodesc_len` | `80` |
+| `cfg.enable_http_clone` | `1` |
+| `cfg.enable_index_owner` | `1` |
+| `cfg.enable_tree_linenumbers` | `1` |
+| `cfg.summary_branches` | `10` |
+| `cfg.summary_log` | `10` |
+| `cfg.summary_tags` | `10` |
+| `cfg.difftype` | `DIFF_UNIFIED` |
+| `cfg.robots` | `"index, nofollow"` |
+| `cfg.root_title` | `"Git repository browser"` |
+
+The function also reads all CGI environment variables and sets
+`page.mimetype = "text/html"`, `page.charset = PAGE_ENCODING` (`"UTF-8"`).
+
+### Phase 3: Configuration Parsing
+
+```c
+parse_configfile(ctx.env.cgit_config, config_cb);
+```
+
+`parse_configfile()` (in `configfile.c`) opens the file, reads lines of the
+form `name=value`, skips comments (`#` and `;`), and calls the callback for each
+directive. It supports recursive `include=` directives up to 8 levels deep.
+
+`config_cb()` (in `cgit.c`) is a ~200-line chain of `if/else if` blocks that
+maps directive names to `ctx.cfg.*` fields. When `repo.url=` is encountered,
+`cgit_add_repo()` allocates a new repository entry; subsequent `repo.*`
+directives configure that entry via `repo_config()`.
+
+Special directive: `scan-path=` triggers immediate filesystem scanning via
+`scan_tree()` or `scan_projects()`, or via a cached repolist file if
+`cache-size > 0`.
+
+### Phase 4: Query String Parsing
+
+```c
+http_parse_querystring(ctx.qry.raw, querystring_cb);
+```
+
+`querystring_cb()` maps short parameter names to `ctx.qry.*` fields:
+
+| Parameter | Field | Purpose |
+|-----------|-------|---------|
+| `r` | `qry.repo` | Repository URL |
+| `p` | `qry.page` | Page name |
+| `url` | `qry.url` | Combined repo/page/path |
+| `h` | `qry.head` | Branch/ref |
+| `id` | `qry.oid` | Object ID |
+| `id2` | `qry.oid2` | Second object ID (for diffs) |
+| `ofs` | `qry.ofs` | Pagination offset |
+| `q` | `qry.search` | Search query |
+| `qt` | `qry.grep` | Search type |
+| `path` | `qry.path` | File path |
+| `name` | `qry.name` | Snapshot filename |
+| `dt` | `qry.difftype` | Diff type (0/1/2) |
+| `context` | `qry.context` | Diff context lines |
+| `ignorews` | `qry.ignorews` | Ignore whitespace |
+| `follow` | `qry.follow` | Follow renames |
+| `showmsg` | `qry.showmsg` | Show full messages |
+| `s` | `qry.sort` | Sort order |
+| `period` | `qry.period` | Stats period |
+
+The `url=` parameter receives special processing via `cgit_parse_url()` (in
+`parsing.c`), which iteratively splits the URL at `/` characters, looking for
+the longest prefix that matches a known repository URL.
+
+### Phase 5: Authentication
+
+`authenticate_cookie()` checks three cases:
+
+1. **No auth filter** → set `ctx.env.authenticated = 1` and return.
+2. **POST to login page** → call `authenticate_post()`, which reads up to
+ `MAX_AUTHENTICATION_POST_BYTES` (4096) from stdin, pipes it to the auth
+ filter with function `"authenticate-post"`, and exits.
+3. **Normal request** → invoke auth filter with function
+ `"authenticate-cookie"`. The filter's exit code becomes
+ `ctx.env.authenticated`.
+
+The auth filter receives 12 arguments:
+
+```
+function, cookie, method, query_string, http_referer,
+path_info, http_host, https, repo, page, fullurl, loginurl
+```
+
+### Phase 6: Cache Envelope
+
+If `ctx.cfg.cache_size > 0`, the request is wrapped in `cache_process()`:
+
+```c
+cache_process(ctx.cfg.cache_size, ctx.cfg.cache_root,
+ cache_key, ttl, fill_fn);
+```
+
+This constructs a filename from the FNV-1 hash of the cache key, attempts to
+open an existing slot, verifies the key matches, checks expiry, and either
+serves cached content or locks and fills a new slot. See the Caching System
+document for full details.
+
+### Phase 7: Command Dispatch
+
+```c
+cmd = cgit_get_cmd();
+```
+
+`cgit_get_cmd()` (in `cmd.c`) performs a linear scan of the static `cmds[]`
+table:
+
+```c
+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(cla, 0, 0, 0),
+ def_cmd(commit, 1, 1, 0),
+ def_cmd(coc, 0, 0, 0),
+ def_cmd(diff, 1, 1, 0),
+ def_cmd(info, 1, 0, 1),
+ def_cmd(log, 1, 1, 0),
+ def_cmd(ls_cache, 0, 0, 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(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),
+};
+```
+
+The `def_cmd` macro expands to `{#name, name##_fn, want_repo, want_vpath, is_clone}`.
+
+Default page if none specified:
+- With a repository → `"summary"`
+- Without a repository → `"repolist"`
+
+### Phase 8: Repository Preparation
+
+If `cmd->want_repo` is set:
+
+1. `prepare_repo_env()` calls `setenv("GIT_DIR", ctx.repo->path, 1)`,
+ `setup_git_directory_gently()`, and `load_display_notes()`.
+2. `prepare_repo_cmd()` resolves the default branch (via `guess_defbranch()`
+ which checks `HEAD` → `refs/heads/*`), resolves the requested head to an OID,
+ sorts submodules, chooses the README file, and sets the page title.
+
+### Phase 9: Page Rendering
+
+The handler function (`cmd->fn()`) is called. Most handlers follow this
+pattern:
+
+```c
+cgit_print_layout_start(); /* HTTP headers + HTML doctype + header + tabs */
+/* ... page-specific content ... */
+cgit_print_layout_end(); /* footer + closing tags */
+```
+
+`cgit_print_layout_start()` calls:
+- `cgit_print_http_headers()` — Content-Type, Last-Modified, Expires, ETag
+- `cgit_print_docstart()` — `<!DOCTYPE html>`, `<html>`, CSS/JS includes
+- `cgit_print_pageheader()` — header table, navigation tabs, breadcrumbs
+
+## Module Dependency Graph
+
+```
+cgit.c ──→ cmd.c ──→ ui-*.c (all modules)
+ │ │
+ │ └──→ cache.c
+ │
+ ├──→ configfile.c
+ ├──→ scan-tree.c ──→ configfile.c
+ ├──→ ui-shared.c ──→ html.c
+ ├──→ ui-stats.c
+ ├──→ ui-blob.c
+ ├──→ ui-summary.c
+ └──→ filter.c
+
+ui-commit.c ──→ ui-diff.c ──→ ui-ssdiff.c
+ui-summary.c ──→ ui-log.c, ui-refs.c, ui-blob.c, ui-plain.c
+ui-log.c ──→ ui-shared.c
+All ui-*.c ──→ html.c, ui-shared.c
+```
+
+## The `struct cgit_cmd` Pattern
+
+Each command in `cmd.c` is defined as a static function that wraps the
+corresponding UI module:
+
+```c
+static void log_fn(void)
+{
+ cgit_print_log(ctx.qry.oid, ctx.qry.ofs, ctx.cfg.max_commit_count,
+ ctx.qry.grep, ctx.qry.search, ctx.qry.path, 1,
+ ctx.repo->enable_commit_graph,
+ ctx.repo->commit_sort);
+}
+```
+
+The thin wrapper pattern means all context is accessed via the global `ctx`
+struct, and the wrapper simply extracts the relevant fields and passes them to
+the module function.
+
+## Repository List Management
+
+The `cgit_repolist` global is a dynamically-growing array:
+
+```c
+struct cgit_repolist {
+ int length; /* Allocated capacity */
+ int count; /* Number of repos */
+ struct cgit_repo *repos; /* Array */
+};
+```
+
+`cgit_add_repo()` doubles the array capacity when needed (starting from 8).
+Each new repo inherits defaults from `ctx.cfg.*` (snapshots, feature flags,
+filters, etc.).
+
+`cgit_get_repoinfo()` performs a linear scan (O(n)) to find a repo by URL.
+Ignored repos (`repo->ignore == 1`) are skipped.
+
+## Build System
+
+The build works in two stages:
+
+1. **Git build** — `make` in the top-level `cgit/` directory delegates to
+ `make -C git -f ../cgit.mk` which includes Git's own `Makefile`.
+
+2. **cgit link** — `cgit.mk` lists all cgit object files (`CGIT_OBJ_NAMES`),
+ compiles them with `CGIT_CFLAGS` (which embeds `CGIT_CONFIG`,
+ `CGIT_SCRIPT_NAME`, `CGIT_CACHE_ROOT` as string literals), and links them
+ against Git's `libgit.a`.
+
+Lua support is auto-detected via `pkg-config` (checking `luajit`, `lua`,
+`lua5.2`, `lua5.1` in order). Define `NO_LUA=1` to build without Lua.
+Linux systems get `HAVE_LINUX_SENDFILE` which enables the `sendfile()` syscall
+in the cache layer.
+
+## Thread Safety
+
+cgit runs as a **single-process CGI** — one process per HTTP request. There is
+no multi-threading. All global state (`ctx`, `cgit_repolist`, the static
+`diffbuf` in `shared.c`, the static format buffers in `html.c`) is safe because
+each process is fully isolated.
+
+The `fmt()` function in `html.c` uses a ring buffer of 8 static buffers
+(`static char buf[8][1024]`) to allow up to 8 nested `fmt()` calls in a single
+expression. The `bufidx` rotates via `bufidx = (bufidx + 1) & 7`.
+
+## Error Handling
+
+The codebase uses three assertion-style helpers from `shared.c`:
+
+```c
+int chk_zero(int result, char *msg); /* die if result != 0 */
+int chk_positive(int result, char *msg); /* die if result <= 0 */
+int chk_non_negative(int result, char *msg); /* die if result < 0 */
+```
+
+For user-facing errors, `cgit_print_error_page()` sets HTTP status, prints
+headers, renders the page skeleton, and displays the error message.
+
+## Type System
+
+cgit uses three enums defined in `cgit.h`:
+
+```c
+typedef enum {
+ DIFF_UNIFIED, DIFF_SSDIFF, DIFF_STATONLY
+} diff_type;
+
+typedef enum {
+ ABOUT, COMMIT, SOURCE, EMAIL, AUTH, OWNER
+} filter_type;
+```
+
+And three function pointer typedefs:
+
+```c
+typedef void (*configfn)(const char *name, const char *value);
+typedef void (*filepair_fn)(struct diff_filepair *pair);
+typedef void (*linediff_fn)(char *line, int len);
+```
diff --git a/docs/handbook/cgit/authentication.md b/docs/handbook/cgit/authentication.md
new file mode 100644
index 0000000000..a4fe000a87
--- /dev/null
+++ b/docs/handbook/cgit/authentication.md
@@ -0,0 +1,288 @@
+# cgit — Authentication
+
+## Overview
+
+cgit supports cookie-based authentication through the `auth-filter`
+mechanism. The authentication system intercepts requests before page
+rendering and delegates all credential validation to an external filter
+(exec or Lua script).
+
+Source file: `cgit.c` (authentication hooks), `filter.c` (filter execution).
+
+## Architecture
+
+Authentication is entirely filter-driven. cgit itself stores no credentials,
+sessions, or user databases. The auth filter is responsible for:
+
+1. Rendering login forms
+2. Validating credentials
+3. Setting/reading session cookies
+4. Determining authorization per-repository
+
+## Configuration
+
+```ini
+auth-filter=lua:/path/to/auth.lua
+# or
+auth-filter=exec:/path/to/auth.sh
+```
+
+The auth filter type is `AUTH_FILTER` (constant `4`) and receives 12
+arguments.
+
+## Authentication Flow
+
+### Request Processing in `cgit.c`
+
+Authentication is checked in `process_request()` after URL parsing and
+command dispatch:
+
+```c
+/* In process_request() */
+if (ctx.cfg.auth_filter) {
+ /* Step 1: Check current authentication state */
+ authenticate_cookie();
+
+ /* Step 2: Handle POST login attempts */
+ if (ctx.env.request_method &&
+ !strcmp(ctx.env.request_method, "POST"))
+ authenticate_post();
+
+ /* Step 3: Run the auth filter to decide access */
+ cmd->fn(&ctx);
+}
+```
+
+### `authenticate_cookie()`
+
+Opens the auth filter to check the current session cookie:
+
+```c
+static void authenticate_cookie(void)
+{
+ /* Open auth filter with current request context */
+ cgit_open_filter(ctx.cfg.auth_filter,
+ ctx.env.http_cookie, /* current cookies */
+ ctx.env.request_method, /* GET/POST */
+ ctx.env.query_string, /* full query */
+ ctx.env.http_referer, /* referer header */
+ ctx.env.path_info, /* request path */
+ ctx.env.http_host, /* hostname */
+ ctx.env.https ? "1" : "0", /* HTTPS flag */
+ ctx.qry.repo, /* repository name */
+ ctx.qry.page, /* page/command */
+ ctx.env.http_accept, /* accept header */
+ "cookie" /* authentication phase */
+ );
+ /* Read filter's response to determine auth state */
+ ctx.env.authenticated = /* filter exit code */;
+ cgit_close_filter(ctx.cfg.auth_filter);
+}
+```
+
+### `authenticate_post()`
+
+Handles login form submissions:
+
+```c
+static void authenticate_post(void)
+{
+ /* Read POST body for credentials */
+ /* Open auth filter with phase="post" */
+ cgit_open_filter(ctx.cfg.auth_filter,
+ /* ... same 11 args ... */
+ "post" /* authentication phase */
+ );
+ /* Filter processes credentials, may set cookies */
+ cgit_close_filter(ctx.cfg.auth_filter);
+}
+```
+
+### Authorization Check
+
+After authentication, the auth filter is called again before rendering each
+page to determine if the authenticated user has access to the requested
+repository and page:
+
+```c
+static int open_auth_filter(const char *repo, const char *page)
+{
+ cgit_open_filter(ctx.cfg.auth_filter,
+ /* ... request context ... */
+ "authorize" /* authorization phase */
+ );
+ int authorized = cgit_close_filter(ctx.cfg.auth_filter);
+ return authorized == 0; /* 0 = authorized */
+}
+```
+
+## Auth Filter Arguments
+
+The auth filter receives 12 arguments in total:
+
+| # | Argument | Description |
+|---|----------|-------------|
+| 1 | `filter_cmd` | The filter command itself |
+| 2 | `http_cookie` | Raw `HTTP_COOKIE` header value |
+| 3 | `request_method` | HTTP method (`GET`, `POST`) |
+| 4 | `query_string` | Raw query string |
+| 5 | `http_referer` | HTTP Referer header |
+| 6 | `path_info` | PATH_INFO from CGI |
+| 7 | `http_host` | Hostname |
+| 8 | `https` | `"1"` if HTTPS, `"0"` if HTTP |
+| 9 | `repo` | Repository URL |
+| 10 | `page` | Page/command name |
+| 11 | `http_accept` | HTTP Accept header |
+| 12 | `phase` | `"cookie"`, `"post"`, or `"authorize"` |
+
+## Filter Phases
+
+### `cookie` Phase
+
+Called on every request. The filter should:
+1. Read the session cookie from argument 2
+2. Validate the session
+3. Return exit code 0 if authenticated, non-zero otherwise
+
+### `post` Phase
+
+Called when the request method is POST. The filter should:
+1. Read POST body from stdin
+2. Validate credentials
+3. If valid, output a `Set-Cookie` header
+4. Output a redirect response (302)
+
+### `authorize` Phase
+
+Called after authentication to check per-repository access. The filter
+should:
+1. Check if the authenticated user can access the requested repo/page
+2. Return exit code 0 if authorized
+3. Return non-zero to deny access (cgit will show an error page)
+
+## Filter Return Codes
+
+| Exit Code | Meaning |
+|-----------|---------|
+| 0 | Success (authenticated/authorized) |
+| Non-zero | Failure (unauthenticated/unauthorized) |
+
+## Environment Variables
+
+The auth filter also has access to standard CGI environment variables:
+
+```c
+struct cgit_environment {
+ const char *cgit_config; /* $CGIT_CONFIG */
+ const char *http_host; /* $HTTP_HOST */
+ const char *https; /* $HTTPS */
+ const char *no_http; /* $NO_HTTP */
+ const char *http_cookie; /* $HTTP_COOKIE */
+ const char *request_method; /* $REQUEST_METHOD */
+ const char *query_string; /* $QUERY_STRING */
+ const char *http_referer; /* $HTTP_REFERER */
+ const char *path_info; /* $PATH_INFO */
+ const char *script_name; /* $SCRIPT_NAME */
+ const char *server_name; /* $SERVER_NAME */
+ const char *server_port; /* $SERVER_PORT */
+ const char *http_accept; /* $HTTP_ACCEPT */
+ int authenticated; /* set by auth filter */
+};
+```
+
+## Shipped Auth Filter
+
+cgit ships a Lua-based hierarchical authentication filter:
+
+### `filters/simple-hierarchical-auth.lua`
+
+This filter implements path-based access control using a simple user
+database and repository permission map.
+
+Features:
+- Cookie-based session management
+- Per-repository access control
+- Hierarchical path matching
+- Password hashing
+
+Usage:
+
+```ini
+auth-filter=lua:/usr/lib/cgit/filters/simple-hierarchical-auth.lua
+```
+
+## Cache Interaction
+
+Authentication affects cache keys. The cache key includes the
+authentication state and cookie:
+
+```c
+static const char *cache_key(void)
+{
+ return fmt("%s?%s?%s?%s?%s",
+ ctx.qry.raw,
+ ctx.env.http_host,
+ ctx.env.https ? "1" : "0",
+ ctx.env.authenticated ? "1" : "0",
+ ctx.env.http_cookie ? ctx.env.http_cookie : "");
+}
+```
+
+This ensures that:
+- Authenticated and unauthenticated users get separate cache entries
+- Different authenticated users (different cookies) get separate entries
+- The cache never leaks restricted content to unauthorized users
+
+## Security Considerations
+
+1. **HTTPS**: Always use HTTPS when authentication is enabled to protect
+ cookies and credentials in transit
+2. **Cookie flags**: Auth filter scripts should set `Secure`, `HttpOnly`,
+ and `SameSite` cookie flags
+3. **Session expiry**: Implement session timeouts in the auth filter
+4. **Password storage**: Never store passwords in plain text; use bcrypt or
+ similar hashing
+5. **CSRF protection**: The auth filter should implement CSRF tokens for
+ POST login forms
+6. **Cache poisoning**: The cache key includes auth state, but ensure the
+ auth filter is deterministic for the same cookie
+
+## Disabling Authentication
+
+By default, no auth filter is configured and all repositories are publicly
+accessible. To restrict access, set up the auth filter and optionally
+combine with `strict-export` for file-based visibility control.
+
+## Example: Custom Auth Filter (Shell)
+
+```bash
+#!/bin/bash
+# Simple auth filter skeleton
+PHASE="${12}"
+
+case "$PHASE" in
+ cookie)
+ COOKIE="$2"
+ if validate_session "$COOKIE"; then
+ exit 0 # authenticated
+ fi
+ exit 1 # not authenticated
+ ;;
+ post)
+ read -r POST_BODY
+ # Parse username/password from POST_BODY
+ # Validate credentials
+ # Set cookie header
+ echo "Status: 302 Found"
+ echo "Set-Cookie: session=TOKEN; HttpOnly; Secure"
+ echo "Location: $6"
+ echo
+ exit 0
+ ;;
+ authorize)
+ REPO="$9"
+ # Check if current user can access $REPO
+ exit 0 # authorized
+ ;;
+esac
+```
diff --git a/docs/handbook/cgit/building.md b/docs/handbook/cgit/building.md
new file mode 100644
index 0000000000..00f9e1244f
--- /dev/null
+++ b/docs/handbook/cgit/building.md
@@ -0,0 +1,272 @@
+# cgit — Building
+
+## Prerequisites
+
+| Dependency | Required | Purpose |
+|-----------|----------|---------|
+| GCC or Clang | Yes | C compiler (C99) |
+| GNU Make | Yes | Build system |
+| OpenSSL (libcrypto) | Yes | SHA-1 hash implementation (`SHA1_HEADER = <openssl/sha.h>`) |
+| zlib | Yes | Git object compression |
+| libcurl | No | Not used — `NO_CURL=1` is passed by cgit.mk |
+| Lua or LuaJIT | No | Lua filter support; auto-detected via pkg-config |
+| asciidoc / a2x | No | Man page / HTML / PDF documentation generation |
+| Python | No | Git's test harness (for `make test`) |
+
+## Build System Overview
+
+cgit uses a two-stage build that embeds itself within Git's build infrastructure:
+
+```
+cgit/Makefile
+ └── make -C git -f ../cgit.mk ../cgit
+ └── git/Makefile (included by cgit.mk)
+ └── Compile cgit objects + link against libgit.a
+```
+
+### Stage 1: Top-Level Makefile
+
+The top-level `Makefile` lives in `cgit/` and defines all user-configurable
+variables:
+
+```makefile
+CGIT_VERSION = 0.0.5-1-Project-Tick
+CGIT_SCRIPT_NAME = cgit.cgi
+CGIT_SCRIPT_PATH = /var/www/htdocs/cgit
+CGIT_DATA_PATH = $(CGIT_SCRIPT_PATH)
+CGIT_CONFIG = /etc/cgitrc
+CACHE_ROOT = /var/cache/cgit
+prefix = /usr/local
+libdir = $(prefix)/lib
+filterdir = $(libdir)/cgit/filters
+docdir = $(prefix)/share/doc/cgit
+mandir = $(prefix)/share/man
+SHA1_HEADER = <openssl/sha.h>
+GIT_VER = 2.46.0
+GIT_URL = https://www.kernel.org/pub/software/scm/git/git-$(GIT_VER).tar.xz
+```
+
+The main `cgit` target delegates to:
+
+```makefile
+cgit:
+ $(QUIET_SUBDIR0)git $(QUIET_SUBDIR1) -f ../cgit.mk ../cgit NO_CURL=1
+```
+
+This enters the `git/` subdirectory and runs `cgit.mk` from there, prefixing
+all cgit source paths with `../`.
+
+### Stage 2: cgit.mk
+
+`cgit.mk` is run inside the `git/` directory so it can `include Makefile` to
+inherit Git's build variables (`CC`, `CFLAGS`, linker flags, OS detection via
+`config.mak.uname`, etc.).
+
+Key sections:
+
+#### Version tracking
+
+```makefile
+$(CGIT_PREFIX)VERSION: force-version
+ @cd $(CGIT_PREFIX) && '$(SHELL_PATH_SQ)' ./gen-version.sh "$(CGIT_VERSION)"
+```
+
+The `gen-version.sh` script writes a `VERSION` file that is included by the
+build. Only `cgit.o` references `CGIT_VERSION`, so only that object is rebuilt
+when the version changes.
+
+#### CGIT_CFLAGS
+
+```makefile
+CGIT_CFLAGS += -DCGIT_CONFIG='"$(CGIT_CONFIG)"'
+CGIT_CFLAGS += -DCGIT_SCRIPT_NAME='"$(CGIT_SCRIPT_NAME)"'
+CGIT_CFLAGS += -DCGIT_CACHE_ROOT='"$(CACHE_ROOT)"'
+```
+
+These compile-time constants are used in `cgit.c` as default values in
+`prepare_context()`.
+
+#### Lua detection
+
+```makefile
+LUA_PKGCONFIG := $(shell for pc in luajit lua lua5.2 lua5.1; do \
+ $(PKG_CONFIG) --exists $$pc 2>/dev/null && echo $$pc && break; \
+done)
+```
+
+If Lua is found, its `--cflags` and `--libs` are appended to `CGIT_CFLAGS` and
+`CGIT_LIBS`. If not found, `NO_LUA=YesPlease` is set and `-DNO_LUA` is added.
+
+#### Linux sendfile
+
+```makefile
+ifeq ($(uname_S),Linux)
+ HAVE_LINUX_SENDFILE = YesPlease
+endif
+
+ifdef HAVE_LINUX_SENDFILE
+ CGIT_CFLAGS += -DHAVE_LINUX_SENDFILE
+endif
+```
+
+This enables the `sendfile()` syscall in `cache.c` for zero-copy writes from
+cache files to stdout.
+
+#### Object files
+
+All cgit source files are listed explicitly:
+
+```makefile
+CGIT_OBJ_NAMES += cgit.o cache.o cmd.o configfile.o filter.o html.o
+CGIT_OBJ_NAMES += parsing.o scan-tree.o shared.o
+CGIT_OBJ_NAMES += ui-atom.o ui-blame.o ui-blob.o ui-clone.o ui-commit.o
+CGIT_OBJ_NAMES += ui-diff.o ui-log.o ui-patch.o ui-plain.o ui-refs.o
+CGIT_OBJ_NAMES += ui-repolist.o ui-shared.o ui-snapshot.o ui-ssdiff.o
+CGIT_OBJ_NAMES += ui-stats.o ui-summary.o ui-tag.o ui-tree.o
+```
+
+The prefixed paths (`CGIT_OBJS := $(addprefix $(CGIT_PREFIX),$(CGIT_OBJ_NAMES))`)
+point back to the `cgit/` directory from inside `git/`.
+
+## Quick Build
+
+```bash
+cd cgit
+
+# Download the vendored Git source (required on first build)
+make get-git
+
+# Build cgit binary
+make -j$(nproc)
+```
+
+The output is a single binary named `cgit` in the `cgit/` directory.
+
+## Build Variables Reference
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `CGIT_VERSION` | `0.0.5-1-Project-Tick` | Compiled-in version string |
+| `CGIT_SCRIPT_NAME` | `cgit.cgi` | Name of the installed CGI binary |
+| `CGIT_SCRIPT_PATH` | `/var/www/htdocs/cgit` | CGI binary install directory |
+| `CGIT_DATA_PATH` | `$(CGIT_SCRIPT_PATH)` | Static assets (CSS, JS, images) directory |
+| `CGIT_CONFIG` | `/etc/cgitrc` | Default config file path (compiled in) |
+| `CACHE_ROOT` | `/var/cache/cgit` | Default cache directory (compiled in) |
+| `prefix` | `/usr/local` | Install prefix |
+| `libdir` | `$(prefix)/lib` | Library directory |
+| `filterdir` | `$(libdir)/cgit/filters` | Filter scripts install directory |
+| `docdir` | `$(prefix)/share/doc/cgit` | Documentation directory |
+| `mandir` | `$(prefix)/share/man` | Man page directory |
+| `SHA1_HEADER` | `<openssl/sha.h>` | SHA-1 implementation header |
+| `GIT_VER` | `2.46.0` | Git version to download and vendor |
+| `GIT_URL` | `https://...git-$(GIT_VER).tar.xz` | Git source download URL |
+| `NO_LUA` | (unset) | Set to any value to disable Lua support |
+| `LUA_PKGCONFIG` | (auto-detected) | Explicit pkg-config name for Lua |
+| `NO_C99_FORMAT` | (unset) | Define if your printf lacks `%zu`, `%lld` etc. |
+| `HAVE_LINUX_SENDFILE` | (auto on Linux) | Enable `sendfile()` in cache |
+| `V` | (unset) | Set to `1` for verbose build output |
+
+Overrides can be placed in a `cgit.conf` file (included by both `Makefile` and
+`cgit.mk` via `-include cgit.conf`).
+
+## Installation
+
+```bash
+make install # Install binary and static assets
+make install-doc # Install man pages, HTML docs, PDF docs
+make install-man # Man pages only
+make install-html # HTML docs only
+make install-pdf # PDF docs only
+```
+
+### Installed files
+
+| Path | Mode | Source |
+|------|------|--------|
+| `$(CGIT_SCRIPT_PATH)/$(CGIT_SCRIPT_NAME)` | 0755 | `cgit` binary |
+| `$(CGIT_DATA_PATH)/cgit.css` | 0644 | Default stylesheet |
+| `$(CGIT_DATA_PATH)/cgit.js` | 0644 | Client-side JavaScript |
+| `$(CGIT_DATA_PATH)/cgit.png` | 0644 | Default logo |
+| `$(CGIT_DATA_PATH)/favicon.ico` | 0644 | Default favicon |
+| `$(CGIT_DATA_PATH)/robots.txt` | 0644 | Robots exclusion file |
+| `$(filterdir)/*` | (varies) | Filter scripts from `filters/` |
+| `$(mandir)/man5/cgitrc.5` | 0644 | Man page (if `install-man`) |
+
+## Make Targets
+
+| Target | Description |
+|--------|-------------|
+| `all` | Build the cgit binary (default) |
+| `cgit` | Explicit build target |
+| `test` | Build everything (`all` target on git) then run `tests/` |
+| `install` | Install binary, CSS, JS, images, filters |
+| `install-doc` | Install man pages + HTML + PDF |
+| `install-man` | Man pages only |
+| `install-html` | HTML docs only |
+| `install-pdf` | PDF docs only |
+| `clean` | Remove cgit objects, VERSION, CGIT-CFLAGS, tags |
+| `cleanall` | `clean` + `make -C git clean` |
+| `clean-doc` | Remove generated doc files |
+| `get-git` | Download and extract Git source into `git/` |
+| `tags` | Generate ctags for all `*.[ch]` files |
+| `sparse` | Run `sparse` static analysis via cgit.mk |
+| `uninstall` | Remove installed binary and assets |
+| `uninstall-doc` | Remove installed documentation |
+
+## Documentation Generation
+
+Man pages are generated from `cgitrc.5.txt` using `asciidoc`/`a2x`:
+
+```makefile
+MAN5_TXT = $(wildcard *.5.txt)
+DOC_MAN5 = $(patsubst %.txt,%,$(MAN5_TXT))
+DOC_HTML = $(patsubst %.txt,%.html,$(MAN_TXT))
+DOC_PDF = $(patsubst %.txt,%.pdf,$(MAN_TXT))
+
+%.5 : %.5.txt
+ a2x -f manpage $<
+
+$(DOC_HTML): %.html : %.txt
+ $(TXT_TO_HTML) -o $@+ $< && mv $@+ $@
+
+$(DOC_PDF): %.pdf : %.txt
+ a2x -f pdf cgitrc.5.txt
+```
+
+## Cross-Compilation
+
+For cross-compiling (e.g. targeting MinGW on Linux):
+
+```bash
+make CC=x86_64-w64-mingw32-gcc
+```
+
+The `toolchain-mingw32.cmake` file in the repository is for CMake-based
+projects; cgit itself uses Make exclusively.
+
+## Customizing the Build
+
+Create a `cgit.conf` file alongside the Makefile:
+
+```makefile
+# cgit.conf — local build overrides
+CGIT_VERSION = 1.0.0-custom
+CGIT_CONFIG = /usr/local/etc/cgitrc
+CACHE_ROOT = /tmp/cgit-cache
+NO_LUA = 1
+```
+
+This file is `-include`d by both `Makefile` and `cgit.mk`, so it applies to
+all build stages.
+
+## Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| `make: *** No rule to make target 'git/Makefile'` | Run `make get-git` first |
+| `lua.h: No such file or directory` | Install Lua dev package or set `NO_LUA=1` |
+| `openssl/sha.h: No such file or directory` | Install `libssl-dev` / `openssl-devel` |
+| `sendfile: undefined reference` | Set `HAVE_LINUX_SENDFILE=` (empty) on non-Linux |
+| Build fails with `redefinition of 'struct cache_slot'` | Git's `cache.h` conflict — cgit uses `CGIT_CACHE_H` guard |
+| `dlsym: symbol not found: write` | Lua filter's `write()` interposition requires `-ldl` (auto on Linux) |
+| Version shows as `unknown` | Run `./gen-version.sh "$(CGIT_VERSION)"` or check `VERSION` file |
diff --git a/docs/handbook/cgit/caching-system.md b/docs/handbook/cgit/caching-system.md
new file mode 100644
index 0000000000..5d3b723ed5
--- /dev/null
+++ b/docs/handbook/cgit/caching-system.md
@@ -0,0 +1,287 @@
+# cgit — Caching System
+
+## Overview
+
+cgit implements a file-based output cache that stores the fully rendered
+HTML/binary response for each unique request. The cache avoids regenerating
+pages for repeated identical requests. When caching is disabled
+(`cache-size=0`, the default), all output is written directly to `stdout`.
+
+Source files: `cache.c`, `cache.h`.
+
+## Cache Slot Structure
+
+Each cached response is represented by a `cache_slot`:
+
+```c
+struct cache_slot {
+ const char *key; /* request identifier (URL-based) */
+ int keylen; /* strlen(key) */
+ int ttl; /* time-to-live in minutes */
+ cache_fill_fn fn; /* callback to regenerate content */
+ int cache_fd; /* fd for the cache file */
+ int lock_fd; /* fd for the .lock file */
+ const char *cache_name;/* path: cache_root/hash(key) */
+ const char *lock_name; /* path: cache_name + ".lock" */
+ int match; /* 1 if cache file matches key */
+ struct stat cache_st; /* stat of the cache file */
+ int bufsize; /* size of the header buffer */
+ char buf[1024 + 4 * 20]; /* header: key + timestamps */
+};
+```
+
+The `cache_fill_fn` typedef:
+
+```c
+typedef void (*cache_fill_fn)(void *cbdata);
+```
+
+This callback is invoked to produce the page content when the cache needs
+filling. The callback writes directly to `stdout`, which is redirected to the
+lock file while cache filling is in progress.
+
+## Hash Function
+
+Cache file names are derived from the request key using the FNV-1 hash:
+
+```c
+unsigned long hash_str(const char *str)
+{
+ unsigned long h = 0x811c9dc5;
+ unsigned char *s = (unsigned char *)str;
+ while (*s) {
+ h *= 0x01000193;
+ h ^= (unsigned long)*s++;
+ }
+ return h;
+}
+```
+
+The resulting hash is formatted as `%lx` and joined with the configured
+`cache-root` directory to produce the cache file path. The lock file is
+the same path with `.lock` appended.
+
+## Slot Lifecycle
+
+A cache request goes through these phases, managed by `process_slot()`:
+
+### 1. Open (`open_slot`)
+
+Opens the cache file and reads the header. The header contains the original
+key followed by creation and expiry timestamps. If the stored key matches the
+current request key, `slot->match` is set to 1.
+
+```c
+static int open_slot(struct cache_slot *slot)
+{
+ slot->cache_fd = open(slot->cache_name, O_RDONLY);
+ if (slot->cache_fd == -1)
+ return errno;
+ if (fstat(slot->cache_fd, &slot->cache_st))
+ return errno;
+ /* read header into slot->buf */
+ return 0;
+}
+```
+
+### 2. Check Match
+
+If the file exists and the key matches, the code checks whether the entry
+has expired based on the TTL:
+
+```c
+static int is_expired(struct cache_slot *slot)
+{
+ if (slot->ttl < 0)
+ return 0; /* negative TTL = never expires */
+ return slot->cache_st.st_mtime + slot->ttl * 60 < time(NULL);
+}
+```
+
+A TTL of `-1` means the entry never expires (used for `cache-static-ttl`).
+
+### 3. Lock (`lock_slot`)
+
+Creates the `.lock` file with `O_WRONLY | O_CREAT | O_EXCL` and writes the
+header containing the key and timestamps. If locking fails (another process
+holds the lock), the stale cached content is served instead.
+
+```c
+static int lock_slot(struct cache_slot *slot)
+{
+ slot->lock_fd = open(slot->lock_name,
+ O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR);
+ if (slot->lock_fd == -1)
+ return errno;
+ /* write header: key + creation timestamp */
+ return 0;
+}
+```
+
+### 4. Fill (`fill_slot`)
+
+Redirects `stdout` to the lock file using `dup2()`, invokes the
+`cache_fill_fn` callback to generate the page content, then restores `stdout`:
+
+```c
+static int fill_slot(struct cache_slot *slot)
+{
+ /* save original stdout */
+ /* dup2(slot->lock_fd, STDOUT_FILENO) */
+ slot->fn(slot->cbdata);
+ /* restore original stdout */
+ return 0;
+}
+```
+
+### 5. Close and Rename
+
+After filling, the lock file is atomically renamed to the cache file:
+
+```c
+if (rename(slot->lock_name, slot->cache_name))
+ return errno;
+```
+
+This ensures readers never see a partially-written file.
+
+### 6. Print (`print_slot`)
+
+The cache file content (minus the header) is sent to `stdout`. On Linux,
+`sendfile()` is used for zero-copy output:
+
+```c
+static int print_slot(struct cache_slot *slot)
+{
+#ifdef HAVE_LINUX_SENDFILE
+ off_t start = slot->keylen + 1; /* skip header */
+ sendfile(STDOUT_FILENO, slot->cache_fd, &start,
+ slot->cache_st.st_size - start);
+#else
+ /* fallback: read()/write() loop */
+#endif
+}
+```
+
+## Process Slot State Machine
+
+`process_slot()` implements a state machine combining all phases:
+
+```
+START → open_slot()
+ ├── success + key match + not expired → print_slot() → DONE
+ ├── success + key match + expired → lock_slot()
+ │ ├── lock acquired → fill_slot() → close_slot() → open_slot() → print_slot()
+ │ └── lock failed → print_slot() (serve stale)
+ ├── success + key mismatch → lock_slot()
+ │ ├── lock acquired → fill_slot() → close_slot() → open_slot() → print_slot()
+ │ └── lock failed → fill_slot() (direct to stdout)
+ └── open failed → lock_slot()
+ ├── lock acquired → fill_slot() → close_slot() → open_slot() → print_slot()
+ └── lock failed → fill_slot() (direct to stdout, no cache)
+```
+
+## Public API
+
+```c
+/* Process a request through the cache */
+extern int cache_process(int size, const char *path, const char *key,
+ int ttl, cache_fill_fn fn, void *cbdata);
+
+/* List all cache entries (for debugging/administration) */
+extern int cache_ls(const char *path);
+
+/* Hash a string using FNV-1 */
+extern unsigned long hash_str(const char *str);
+```
+
+### `cache_process()`
+
+Parameters:
+- `size` — Maximum number of cache entries (from `cache-size`). If `0`,
+ caching is bypassed and `fn` is called directly.
+- `path` — Cache root directory.
+- `key` — Request identifier (derived from full URL + query string).
+- `ttl` — Time-to-live in minutes.
+- `fn` — Callback function that generates the page content.
+- `cbdata` — Opaque data passed to the callback.
+
+### `cache_ls()`
+
+Scans the cache root directory and prints information about each cache entry
+to `stdout`. Used for administrative inspection.
+
+## TTL Configuration Mapping
+
+Different page types have different TTLs:
+
+| Page Type | Config Directive | Default | Applied When |
+|-----------|-----------------|---------|--------------|
+| Repository list | `cache-root-ttl` | 5 min | `cmd->want_repo == 0` |
+| Repo pages | `cache-repo-ttl` | 5 min | `cmd->want_repo == 1` and dynamic |
+| Dynamic pages | `cache-dynamic-ttl` | 5 min | `cmd->want_vpath == 1` |
+| Static content | `cache-static-ttl` | -1 (never) | SHA-referenced content |
+| About pages | `cache-about-ttl` | 15 min | About/readme view |
+| Snapshots | `cache-snapshot-ttl` | 5 min | Snapshot downloads |
+| Scan results | `cache-scanrc-ttl` | 15 min | scan-path results |
+
+Static content uses a TTL of `-1` because SHA-addressed content is
+immutable — a given commit/tree/blob hash always refers to the same data.
+
+## Cache Key Generation
+
+The cache key is built from the complete query context in `cgit.c`:
+
+```c
+static const char *cache_key(void)
+{
+ return fmt("%s?%s?%s?%s?%s",
+ ctx.qry.raw, ctx.env.http_host,
+ ctx.env.https ? "1" : "0",
+ ctx.env.authenticated ? "1" : "0",
+ ctx.env.http_cookie ? ctx.env.http_cookie : "");
+}
+```
+
+The key captures: raw query string, hostname, HTTPS state, authentication
+state, and cookies. This ensures that authenticated users get different
+cache entries than unauthenticated users.
+
+## Concurrency
+
+The cache supports concurrent access from multiple CGI processes:
+
+1. **Atomic writes**: Content is written to a `.lock` file first, then
+ atomically renamed to the cache file. Readers never see partial content.
+2. **Non-blocking locks**: If a lock is already held, the process either
+ serves stale cached content (if available) or generates content directly
+ to stdout without caching.
+3. **No deadlocks**: Lock files are `O_EXCL`, not `flock()`. If a process
+ crashes while holding a lock, the stale `.lock` file remains. It is
+ typically cleaned up by the next successful writer.
+
+## Cache Directory Management
+
+The cache root directory (`cache-root`, default `/var/cache/cgit`) must be
+writable by the web server user. Cache files are created with mode `0600`
+(`S_IRUSR | S_IWUSR`).
+
+There is no built-in cache eviction. Old cache files persist until a new
+request with the same hash replaces them. Administrators should set up
+periodic cleanup (e.g., a cron job) to purge expired files:
+
+```bash
+find /var/cache/cgit -type f -mmin +60 -delete
+```
+
+## Disabling the Cache
+
+Set `cache-size=0` (the default). When `size` is 0, `cache_process()` calls
+the fill function directly, writing to stdout with no file I/O overhead:
+
+```c
+if (!size) {
+ fn(cbdata);
+ return 0;
+}
+```
diff --git a/docs/handbook/cgit/code-style.md b/docs/handbook/cgit/code-style.md
new file mode 100644
index 0000000000..d4059391dc
--- /dev/null
+++ b/docs/handbook/cgit/code-style.md
@@ -0,0 +1,356 @@
+# cgit — Code Style and Conventions
+
+## Overview
+
+cgit follows C99 conventions with a style influenced by the Linux kernel and
+Git project coding standards. This document describes the patterns, naming
+conventions, and idioms used throughout the codebase.
+
+## Language Standard
+
+cgit is written in C99, compiled with:
+
+```makefile
+CGIT_CFLAGS += -std=c99
+```
+
+No C11 or GNU extensions are required, though some platform-specific features
+(like `sendfile()` on Linux) are conditionally compiled.
+
+## Formatting
+
+### Indentation
+
+- Tabs for indentation (1 tab = 8 spaces display width, consistent with
+ Linux kernel/Git style)
+- No spaces for indentation alignment
+
+### Braces
+
+K&R style (opening brace on same line):
+
+```c
+if (condition) {
+ /* body */
+} else {
+ /* body */
+}
+
+static void function_name(int arg)
+{
+ /* function body */
+}
+```
+
+Functions place the opening brace on its own line. Control structures
+(`if`, `for`, `while`, `switch`) keep it on the same line.
+
+### Line Length
+
+No strict limit, but lines generally stay under 80 characters. Long function
+calls are broken across lines.
+
+## Naming Conventions
+
+### Functions
+
+Public API functions use the `cgit_` prefix:
+
+```c
+void cgit_print_commit(const char *rev, const char *prefix);
+void cgit_print_diff(const char *new_rev, const char *old_rev, ...);
+struct cgit_repo *cgit_add_repo(const char *url);
+struct cgit_repo *cgit_get_repoinfo(const char *url);
+int cgit_parse_snapshots_mask(const char *str);
+```
+
+Static (file-local) functions use descriptive names without prefix:
+
+```c
+static void config_cb(const char *name, const char *value);
+static void querystring_cb(const char *name, const char *value);
+static void process_request(void);
+static int open_slot(struct cache_slot *slot);
+```
+
+### Types
+
+Struct types use `cgit_` prefix with snake_case:
+
+```c
+struct cgit_context;
+struct cgit_repo;
+struct cgit_config;
+struct cgit_query;
+struct cgit_page;
+struct cgit_environment;
+struct cgit_cmd;
+struct cgit_filter;
+struct cgit_snapshot_format;
+```
+
+### Macros and Constants
+
+Uppercase with underscores:
+
+```c
+#define ABOUT_FILTER 0
+#define COMMIT_FILTER 1
+#define SOURCE_FILTER 2
+#define EMAIL_FILTER 3
+#define AUTH_FILTER 4
+#define DIFF_UNIFIED 0
+#define DIFF_SSDIFF 1
+#define DIFF_STATONLY 2
+#define FMT_BUFS 8
+#define FMT_SIZE 8192
+```
+
+### Variables
+
+Global variables use descriptive names:
+
+```c
+struct cgit_context ctx;
+struct cgit_repolist cgit_repolist;
+const char *cgit_version;
+```
+
+## File Organization
+
+### Header Files
+
+Each module has a corresponding header file with include guards:
+
+```c
+#ifndef UI_DIFF_H
+#define UI_DIFF_H
+
+extern void cgit_print_diff(const char *new_rev, const char *old_rev,
+ const char *prefix, int show_ctrls, int raw);
+extern void cgit_print_diffstat(const struct object_id *old,
+ const struct object_id *new,
+ const char *prefix);
+
+#endif /* UI_DIFF_H */
+```
+
+### Source Files
+
+Typical source file structure:
+
+1. License header (if present)
+2. Include directives
+3. Static (file-local) variables
+4. Static helper functions
+5. Public API functions
+
+### Module Pattern
+
+UI modules follow a consistent pattern with `ui-*.c` / `ui-*.h` pairs:
+
+```c
+/* ui-example.c */
+#include "cgit.h"
+#include "ui-example.h"
+#include "html.h"
+#include "ui-shared.h"
+
+static void helper_function(void)
+{
+ /* ... */
+}
+
+void cgit_print_example(void)
+{
+ /* main entry point */
+}
+```
+
+## Common Patterns
+
+### Global Context
+
+cgit uses a single global `struct cgit_context ctx` variable that holds all
+request state. Functions access it directly rather than passing it as a
+parameter:
+
+```c
+/* Access global context directly */
+if (ctx.repo && ctx.repo->enable_blame)
+ cgit_print_blame();
+
+/* Not: cgit_print_blame(&ctx) */
+```
+
+### Callback Functions
+
+Configuration and query parsing use callback function pointers:
+
+```c
+typedef void (*configfile_value_fn)(const char *name, const char *value);
+typedef void (*filepair_fn)(struct diff_filepair *pair);
+typedef void (*linediff_fn)(char *line, int len);
+typedef void (*cache_fill_fn)(void *cbdata);
+```
+
+### String Formatting
+
+The `fmt()` ring buffer is used for temporary string construction:
+
+```c
+const char *url = fmt("%s/%s/", ctx.cfg.virtual_root, repo->url);
+html_attr(url);
+```
+
+Never store `fmt()` results long-term — use `fmtalloc()` or `xstrdup()`.
+
+### NULL Checks
+
+Functions generally check for NULL pointers at the start:
+
+```c
+void cgit_print_blob(const char *hex, const char *path,
+ const char *head, int file_only)
+{
+ if (!hex && !path) {
+ cgit_print_error_page(400, "Bad request",
+ "Need either hex or path");
+ return;
+ }
+ /* ... */
+}
+```
+
+### Memory Management
+
+cgit uses Git's `xmalloc` / `xstrdup` / `xrealloc` wrappers that die on
+allocation failure:
+
+```c
+char *name = xstrdup(value);
+repo = xrealloc(repo, new_size);
+```
+
+No explicit `free()` calls in most paths — the CGI process exits after each
+request, and the OS reclaims all memory.
+
+### Boolean as Int
+
+Boolean values are represented as `int` (0 or 1), consistent with C99
+convention before `_Bool`:
+
+```c
+int enable_blame;
+int enable_commit_graph;
+int binary;
+int match;
+```
+
+### Typedef Avoidance
+
+Structs are generally not typedef'd — they use the `struct` keyword
+explicitly:
+
+```c
+struct cgit_repo *repo;
+struct cache_slot slot;
+```
+
+Exception: function pointer typedefs are used for callbacks:
+
+```c
+typedef void (*configfile_value_fn)(const char *name, const char *value);
+```
+
+## Error Handling
+
+### `die()` for Fatal Errors
+
+Unrecoverable errors use Git's `die()`:
+
+```c
+if (!ctx.repo)
+ die("no repository");
+```
+
+### Error Pages for User Errors
+
+User-facing errors use the error page function:
+
+```c
+cgit_print_error_page(404, "Not Found",
+ "No repository found for '%s'",
+ ctx.qry.repo);
+```
+
+### Return Codes
+
+Functions that can fail return int (0 = success, non-zero = error):
+
+```c
+static int open_slot(struct cache_slot *slot)
+{
+ slot->cache_fd = open(slot->cache_name, O_RDONLY);
+ if (slot->cache_fd == -1)
+ return errno;
+ return 0;
+}
+```
+
+## Preprocessor Usage
+
+Conditional compilation for platform features:
+
+```c
+#ifdef HAVE_LINUX_SENDFILE
+ sendfile(STDOUT_FILENO, slot->cache_fd, &off, size);
+#else
+ /* read/write fallback */
+#endif
+
+#ifdef HAVE_LUA
+ /* Lua filter support */
+#endif
+```
+
+## Git Library Integration
+
+cgit includes Git as a library. It uses Git's internal APIs directly:
+
+```c
+#include "git/cache.h"
+#include "git/object.h"
+#include "git/commit.h"
+#include "git/diff.h"
+#include "git/revision.h"
+#include "git/archive.h"
+```
+
+Functions from Git's library are called without wrapper layers:
+
+```c
+struct commit *commit = lookup_commit_reference(&oid);
+struct tree *tree = parse_tree_indirect(&oid);
+init_revisions(&rev, NULL);
+```
+
+## Documentation
+
+- Code comments are used sparingly, mainly for non-obvious logic
+- No Doxygen or similar documentation generators are used
+- Function documentation is in the header files as prototypes with
+ descriptive parameter names
+- The `cgitrc.5.txt` file provides user-facing documentation in
+ man page format
+
+## Commit Messages
+
+Commit messages follow the standard Git format:
+
+```
+subject: brief description (50 chars or less)
+
+Extended description wrapping at 72 characters. Explain what and why,
+not how.
+```
diff --git a/docs/handbook/cgit/configuration.md b/docs/handbook/cgit/configuration.md
new file mode 100644
index 0000000000..afc29fce07
--- /dev/null
+++ b/docs/handbook/cgit/configuration.md
@@ -0,0 +1,351 @@
+# cgit — Configuration Reference
+
+## Configuration File
+
+Default location: `/etc/cgitrc` (compiled in as `CGIT_CONFIG`). Override at
+runtime by setting the `$CGIT_CONFIG` environment variable.
+
+## File Format
+
+The configuration file uses a simple `name=value` format, parsed by
+`parse_configfile()` in `configfile.c`. Key rules:
+
+- Lines starting with `#` or `;` are comments
+- Leading whitespace on lines is skipped
+- No quoting mechanism — the value is everything after the `=` to end of line
+- Empty lines are ignored
+- Nesting depth for `include=` directives is limited to 8 levels
+
+```c
+int parse_configfile(const char *filename, configfile_value_fn fn)
+{
+ static int nesting;
+ /* ... */
+ if (nesting > 8)
+ return -1;
+ /* ... */
+ while (read_config_line(f, &name, &value))
+ fn(name.buf, value.buf);
+ /* ... */
+}
+```
+
+## Global Directives
+
+All global directives are processed by `config_cb()` in `cgit.c`. When a
+directive is encountered, the value is stored in the corresponding
+`ctx.cfg.*` field.
+
+### Site Identity
+
+| Directive | Default | Type | Description |
+|-----------|---------|------|-------------|
+| `root-title` | `"Git repository browser"` | string | HTML page title for the index page |
+| `root-desc` | `"a fast webinterface for the git dscm"` | string | Subtitle text on the index page |
+| `root-readme` | (none) | path | Path to a file rendered on the site about page |
+| `root-coc` | (none) | path | Path to Code of Conduct file |
+| `root-cla` | (none) | path | Path to Contributor License Agreement file |
+| `root-homepage` | (none) | URL | External homepage URL |
+| `root-homepage-title` | (none) | string | Title text for the homepage link |
+| `root-link` | (none) | string | `label\|url` pairs for navigation links (can repeat) |
+| `logo` | `"/cgit.png"` | URL | Path to the site logo image |
+| `logo-link` | (none) | URL | URL the logo links to |
+| `favicon` | `"/favicon.ico"` | URL | Path to the favicon |
+| `css` | (none) | URL | Stylesheet URL (can repeat for multiple stylesheets) |
+| `js` | (none) | URL | JavaScript URL (can repeat) |
+| `header` | (none) | path | File included at the top of every page |
+| `footer` | (none) | path | File included at the bottom of every page |
+| `head-include` | (none) | path | File included in the HTML `<head>` |
+| `robots` | `"index, nofollow"` | string | Content for `<meta name="robots">` |
+
+### URL Configuration
+
+| Directive | Default | Type | Description |
+|-----------|---------|------|-------------|
+| `virtual-root` | (none) | path | Base URL path when using URL rewriting (always ends with `/`) |
+| `script-name` | `CGIT_SCRIPT_NAME` | path | CGI script name (from `$SCRIPT_NAME` env var) |
+| `clone-prefix` | (none) | string | Prefix for clone URLs when auto-generating |
+| `clone-url` | (none) | string | Clone URL template (`$CGIT_REPO_URL` expanded) |
+
+When `virtual-root` is set, URLs use path-based routing:
+`/cgit/repo/log/path`. Without it, query-string routing is used:
+`?url=repo/log/path`.
+
+### Feature Flags
+
+| Directive | Default | Type | Description |
+|-----------|---------|------|-------------|
+| `enable-http-clone` | `1` | int | Allow HTTP clone operations (HEAD, info/refs, objects/) |
+| `enable-index-links` | `0` | int | Show log/tree/commit links on the repo index page |
+| `enable-index-owner` | `1` | int | Show the Owner column on the repo index page |
+| `enable-blame` | `0` | int | Enable blame view for all repos |
+| `enable-commit-graph` | `0` | int | Show ASCII commit graph in log view |
+| `enable-log-filecount` | `0` | int | Show changed-file count in log view |
+| `enable-log-linecount` | `0` | int | Show added/removed line counts in log |
+| `enable-remote-branches` | `0` | int | Display remote tracking branches |
+| `enable-subject-links` | `0` | int | Show parent commit subjects instead of hashes |
+| `enable-html-serving` | `0` | int | Serve HTML files as-is from plain view |
+| `enable-subtree` | `0` | int | Detect and display git-subtree directories |
+| `enable-tree-linenumbers` | `1` | int | Show line numbers in file/blob view |
+| `enable-git-config` | `0` | int | Read `gitweb.*` and `cgit.*` from repo's git config |
+| `enable-filter-overrides` | `0` | int | Allow repos to override global filters |
+| `enable-follow-links` | `0` | int | Show "follow" links in log view for renames |
+| `embedded` | `0` | int | Omit HTML boilerplate for embedding in another page |
+| `noheader` | `0` | int | Suppress the page header |
+| `noplainemail` | `0` | int | Hide email addresses in output |
+| `local-time` | `0` | int | Display times in local timezone instead of UTC |
+
+### Limits
+
+| Directive | Default | Type | Description |
+|-----------|---------|------|-------------|
+| `max-repo-count` | `50` | int | Repos per page on the index (≤0 → unlimited) |
+| `max-commit-count` | `50` | int | Commits per page in log view |
+| `max-message-length` | `80` | int | Truncate commit subject at this length |
+| `max-repodesc-length` | `80` | int | Truncate repo description at this length |
+| `max-blob-size` | `0` | int (KB) | Max blob size to display (0 = unlimited) |
+| `max-stats` | `0` | int | Stats period (0=disabled, 1=week, 2=month, 3=quarter, 4=year) |
+| `max-atom-items` | `10` | int | Number of entries in Atom feeds |
+| `max-subtree-commits` | `2000` | int | Max commits to scan for subtree trailers |
+| `renamelimit` | `-1` | int | Diff rename detection limit (-1 = Git default) |
+
+### Caching
+
+| Directive | Default | Type | Description |
+|-----------|---------|------|-------------|
+| `cache-size` | `0` | int | Number of cache entries (0 = disabled) |
+| `cache-root` | `CGIT_CACHE_ROOT` | path | Directory for cache files |
+| `cache-root-ttl` | `5` | int (min) | TTL for repo-list pages |
+| `cache-repo-ttl` | `5` | int (min) | TTL for repo-specific pages |
+| `cache-dynamic-ttl` | `5` | int (min) | TTL for dynamic content |
+| `cache-static-ttl` | `-1` | int (min) | TTL for static content (-1 = forever) |
+| `cache-about-ttl` | `15` | int (min) | TTL for about/readme pages |
+| `cache-snapshot-ttl` | `5` | int (min) | TTL for snapshot pages |
+| `cache-scanrc-ttl` | `15` | int (min) | TTL for cached scan-path results |
+
+### Sorting
+
+| Directive | Default | Type | Description |
+|-----------|---------|------|-------------|
+| `case-sensitive-sort` | `1` | int | Case-sensitive repo name sorting |
+| `section-sort` | `1` | int | Sort sections alphabetically |
+| `section-from-path` | `0` | int | Derive section name from path depth (>0 = from start, <0 = from end) |
+| `repository-sort` | `"name"` | string | Default sort field for repo list |
+| `branch-sort` | `0` | int | Branch sort: 0=name, 1=age |
+| `commit-sort` | `0` | int | Commit sort: 0=default, 1=date, 2=topo |
+
+### Snapshots
+
+| Directive | Default | Type | Description |
+|-----------|---------|------|-------------|
+| `snapshots` | (none) | string | Space-separated list of enabled formats: `.tar` `.tar.gz` `.tar.bz2` `.tar.lz` `.tar.xz` `.tar.zst` `.zip`. Also accepts `all`. |
+
+### Filters
+
+| Directive | Default | Type | Description |
+|-----------|---------|------|-------------|
+| `about-filter` | (none) | filter | Filter for rendering README/about content |
+| `source-filter` | (none) | filter | Filter for syntax highlighting source code |
+| `commit-filter` | (none) | filter | Filter for commit messages |
+| `email-filter` | (none) | filter | Filter for email display (2 args: email, page) |
+| `owner-filter` | (none) | filter | Filter for owner display |
+| `auth-filter` | (none) | filter | Authentication filter (12 args) |
+
+Filter values use the format `type:command`:
+- `exec:/path/to/script` — external process filter
+- `lua:/path/to/script.lua` — Lua script filter
+- Plain path without prefix defaults to `exec`
+
+### Display
+
+| Directive | Default | Type | Description |
+|-----------|---------|------|-------------|
+| `summary-branches` | `10` | int | Branches shown on summary page |
+| `summary-tags` | `10` | int | Tags shown on summary page |
+| `summary-log` | `10` | int | Log entries shown on summary page |
+| `side-by-side-diffs` | `0` | int | Default to side-by-side diff view |
+| `remove-suffix` | `0` | int | Remove `.git` suffix from repo URLs |
+| `scan-hidden-path` | `0` | int | Include hidden dirs when scanning |
+
+### Miscellaneous
+
+| Directive | Default | Type | Description |
+|-----------|---------|------|-------------|
+| `agefile` | `"info/web/last-modified"` | path | File in repo checked for modification time |
+| `mimetype-file` | (none) | path | Apache-style mime.types file |
+| `mimetype.<ext>` | (none) | string | MIME type for a file extension |
+| `module-link` | (none) | URL | URL template for submodule links |
+| `strict-export` | (none) | path | Only export repos containing this file |
+| `project-list` | (none) | path | File listing project directories for `scan-path` |
+| `scan-path` | (none) | path | Directory to scan for git repositories |
+| `readme` | (none) | string | Default README file spec (can repeat) |
+| `include` | (none) | path | Include another config file |
+
+## Repository Directives
+
+Repository configuration begins with `repo.url=` which creates a new
+repository entry via `cgit_add_repo()`. Subsequent `repo.*` directives
+modify the most recently created repository via `repo_config()` in `cgit.c`.
+
+| Directive | Description |
+|-----------|-------------|
+| `repo.url` | Repository URL path (triggers new repo creation) |
+| `repo.path` | Filesystem path to the git repository |
+| `repo.name` | Display name |
+| `repo.basename` | Override for basename derivation |
+| `repo.desc` | Repository description |
+| `repo.owner` | Repository owner name |
+| `repo.homepage` | Project homepage URL |
+| `repo.defbranch` | Default branch name |
+| `repo.section` | Section heading for grouped display |
+| `repo.clone-url` | Clone URL (overrides global) |
+| `repo.readme` | README file spec (`[ref:]path`, can repeat) |
+| `repo.logo` | Per-repo logo URL |
+| `repo.logo-link` | Per-repo logo link URL |
+| `repo.extra-head-content` | Extra HTML for `<head>` |
+| `repo.snapshots` | Snapshot format mask (space-separated suffixes) |
+| `repo.snapshot-prefix` | Prefix for snapshot filenames |
+| `repo.enable-blame` | Override global enable-blame |
+| `repo.enable-commit-graph` | Override global enable-commit-graph |
+| `repo.enable-log-filecount` | Override global enable-log-filecount |
+| `repo.enable-log-linecount` | Override global enable-log-linecount |
+| `repo.enable-remote-branches` | Override global enable-remote-branches |
+| `repo.enable-subject-links` | Override global enable-subject-links |
+| `repo.enable-html-serving` | Override global enable-html-serving |
+| `repo.enable-subtree` | Override global enable-subtree |
+| `repo.max-stats` | Override global max-stats |
+| `repo.max-subtree-commits` | Override global max-subtree-commits |
+| `repo.branch-sort` | `"age"` or `"name"` |
+| `repo.commit-sort` | `"date"` or `"topo"` |
+| `repo.module-link` | Submodule URL template |
+| `repo.module-link.<submodule>` | Per-submodule URL |
+| `repo.badge` | Badge entry: `url\|imgurl` or just `imgurl` (can repeat) |
+| `repo.hide` | `1` = hide from listing (still accessible by URL) |
+| `repo.ignore` | `1` = completely ignore this repository |
+
+### Filter overrides (require `enable-filter-overrides=1`)
+
+| Directive | Description |
+|-----------|-------------|
+| `repo.about-filter` | Per-repo about filter |
+| `repo.commit-filter` | Per-repo commit filter |
+| `repo.source-filter` | Per-repo source filter |
+| `repo.email-filter` | Per-repo email filter |
+| `repo.owner-filter` | Per-repo owner filter |
+
+## Repository Defaults
+
+When a new repository is created by `cgit_add_repo()`, it inherits all global
+defaults from `ctx.cfg`:
+
+```c
+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_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->enable_subtree = ctx.cfg.enable_subtree;
+ret->max_stats = ctx.cfg.max_stats;
+ret->max_subtree_commits = ctx.cfg.max_subtree_commits;
+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;
+ret->about_filter = ctx.cfg.about_filter;
+ret->commit_filter = ctx.cfg.commit_filter;
+ret->source_filter = ctx.cfg.source_filter;
+ret->email_filter = ctx.cfg.email_filter;
+ret->owner_filter = ctx.cfg.owner_filter;
+ret->clone_url = ctx.cfg.clone_url;
+```
+
+This means global directives should appear *before* `repo.url=` entries, since
+they set the defaults for subsequently defined repositories.
+
+## Git Config Integration
+
+When `enable-git-config=1`, the `scan-tree` scanner reads each repository's
+`.git/config` and maps gitweb-compatible directives:
+
+```c
+if (!strcmp(key, "gitweb.owner"))
+ config_fn(repo, "owner", value);
+else if (!strcmp(key, "gitweb.description"))
+ config_fn(repo, "desc", value);
+else if (!strcmp(key, "gitweb.category"))
+ config_fn(repo, "section", value);
+else if (!strcmp(key, "gitweb.homepage"))
+ config_fn(repo, "homepage", value);
+else if (skip_prefix(key, "cgit.", &name))
+ config_fn(repo, name, value);
+```
+
+Any `cgit.*` key in the git config is passed directly to the repo config
+handler, allowing per-repo settings without modifying the global cgitrc.
+
+## README File Spec Format
+
+README directives support three forms:
+
+| Format | Meaning |
+|--------|---------|
+| `path` | File on disk, relative to repo path |
+| `/absolute/path` | File on disk, absolute |
+| `ref:path` | File tracked in the git repository at the given ref |
+| `:path` | File tracked in the default branch or query head |
+
+Multiple `readme` directives can be specified. cgit tries each in order and
+uses the first one found (checked via `cgit_ref_path_exists()` for tracked
+files, or `access(R_OK)` for disk files).
+
+## Macro Expansion
+
+The `expand_macros()` function (in `shared.c`) performs environment variable
+substitution in certain directive values (`cache-root`, `scan-path`,
+`project-list`, `include`). A `$VARNAME` or `${VARNAME}` in the value is
+replaced with the corresponding environment variable.
+
+## Example Configuration
+
+```ini
+# Site settings
+root-title=Project Tick Git
+root-desc=Source code for Project Tick
+logo=/cgit/cgit.png
+css=/cgit/cgit.css
+virtual-root=/cgit/
+
+# Features
+enable-commit-graph=1
+enable-blame=1
+enable-http-clone=1
+enable-index-links=1
+snapshots=tar.gz tar.xz zip
+max-stats=quarter
+
+# Caching
+cache-size=1000
+cache-root=/var/cache/cgit
+
+# Filters
+source-filter=exec:/usr/lib/cgit/filters/syntax-highlighting.py
+about-filter=exec:/usr/lib/cgit/filters/about-formatting.sh
+
+# Scanning
+scan-path=/srv/git/
+section-from-path=1
+
+# Or manual repo definitions:
+repo.url=myproject
+repo.path=/srv/git/myproject.git
+repo.desc=My awesome project
+repo.owner=Alice
+repo.readme=master:README.md
+repo.clone-url=https://git.example.com/myproject.git
+repo.snapshots=tar.gz zip
+repo.badge=https://ci.example.com/badge.svg|https://ci.example.com/
+```
diff --git a/docs/handbook/cgit/css-theming.md b/docs/handbook/cgit/css-theming.md
new file mode 100644
index 0000000000..0a7b404595
--- /dev/null
+++ b/docs/handbook/cgit/css-theming.md
@@ -0,0 +1,522 @@
+# cgit — CSS Theming
+
+## Overview
+
+cgit ships with a comprehensive CSS stylesheet (`cgit.css`) that controls
+the visual appearance of all pages. The stylesheet is designed with a light
+color scheme and semantic CSS classes that map directly to cgit's HTML
+structure.
+
+Source file: `cgit.css` (~450 lines).
+
+## Loading Stylesheets
+
+CSS files are specified via the `css` configuration directive:
+
+```ini
+css=/cgit/cgit.css
+```
+
+Multiple stylesheets can be loaded by repeating the directive:
+
+```ini
+css=/cgit/cgit.css
+css=/cgit/custom.css
+```
+
+Stylesheets are included in document order in the `<head>` section via
+`cgit_print_docstart()` in `ui-shared.c`.
+
+## Page Structure
+
+The HTML layout uses this basic structure:
+
+```html
+<body>
+ <div id='cgit'>
+ <table id='header'>...</table> <!-- site header with logo -->
+ <table id='navigation'>...</table> <!-- tab navigation -->
+ <div id='content'> <!-- page content -->
+ <!-- page-specific content -->
+ </div>
+ <div class='footer'>...</div> <!-- footer -->
+ </div>
+</body>
+```
+
+## Base Styles
+
+### Body and Layout
+
+```css
+body {
+ font-family: sans-serif;
+ font-size: 11px;
+ color: #000;
+ background: white;
+ padding: 4px;
+}
+
+div#cgit {
+ padding: 0;
+ margin: 0;
+ font-family: monospace;
+ font-size: 12px;
+}
+```
+
+### Header
+
+```css
+table#header {
+ width: 100%;
+ margin-bottom: 1em;
+}
+
+table#header td.logo {
+ /* logo cell */
+}
+
+table#header td.main {
+ font-size: 250%;
+ font-weight: bold;
+ vertical-align: bottom;
+ padding-left: 10px;
+}
+
+table#header td.sub {
+ color: #999;
+ font-size: 75%;
+ vertical-align: top;
+ padding-left: 10px;
+}
+```
+
+### Navigation Tabs
+
+```css
+table#navigation {
+ width: 100%;
+}
+
+table#navigation a {
+ padding: 2px 6px;
+ color: #000;
+ text-decoration: none;
+}
+
+table#navigation a:hover {
+ color: #00f;
+}
+```
+
+## Content Areas
+
+### Repository List
+
+```css
+table.list {
+ border-collapse: collapse;
+ border: solid 1px #aaa;
+ width: 100%;
+}
+
+table.list th {
+ text-align: left;
+ font-weight: bold;
+ background: #ddd;
+ border-bottom: solid 1px #aaa;
+ padding: 2px 4px;
+}
+
+table.list td {
+ padding: 2px 4px;
+ border: none;
+}
+
+table.list tr:hover {
+ background: #eee;
+}
+
+table.list td a {
+ color: #00f;
+ text-decoration: none;
+}
+
+table.list td a:hover {
+ text-decoration: underline;
+}
+```
+
+### Sections
+
+```css
+div.section-header {
+ background: #eee;
+ border: solid 1px #ddd;
+ padding: 2px 4px;
+ font-weight: bold;
+ margin-top: 1em;
+}
+```
+
+## Diff Styles
+
+### Diffstat
+
+```css
+table.diffstat {
+ border-collapse: collapse;
+ border: solid 1px #aaa;
+}
+
+table.diffstat td {
+ padding: 1px 4px;
+ border: none;
+}
+
+table.diffstat td.mode {
+ font-weight: bold;
+ /* status indicator: A/M/D/R */
+}
+
+table.diffstat td.graph {
+ width: 500px;
+}
+
+table.diffstat td.graph span.add {
+ background: #5f5;
+ /* green bar for additions */
+}
+
+table.diffstat td.graph span.rem {
+ background: #f55;
+ /* red bar for deletions */
+}
+
+table.diffstat .total {
+ font-weight: bold;
+ text-align: center;
+}
+```
+
+### Unified Diff
+
+```css
+table.diff {
+ width: 100%;
+}
+
+table.diff td div.head {
+ font-weight: bold;
+ margin-top: 1em;
+ color: #000;
+}
+
+table.diff td div.hunk {
+ color: #009;
+ /* hunk header @@ ... @@ */
+}
+
+table.diff td div.add {
+ color: green;
+ background: #dfd;
+}
+
+table.diff td div.del {
+ color: red;
+ background: #fdd;
+}
+```
+
+### Side-by-Side Diff
+
+```css
+table.ssdiff {
+ width: 100%;
+}
+
+table.ssdiff td {
+ font-family: monospace;
+ font-size: 12px;
+ padding: 1px 4px;
+ vertical-align: top;
+}
+
+table.ssdiff td.lineno {
+ text-align: right;
+ width: 3em;
+ background: #eee;
+ color: #999;
+}
+
+table.ssdiff td.add {
+ background: #dfd;
+}
+
+table.ssdiff td.del {
+ background: #fdd;
+}
+
+table.ssdiff td.changed {
+ background: #ffc;
+}
+
+table.ssdiff span.add {
+ background: #afa;
+ font-weight: bold;
+}
+
+table.ssdiff span.del {
+ background: #faa;
+ font-weight: bold;
+}
+```
+
+## Blob/Tree View
+
+```css
+table.blob {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+table.blob td {
+ font-family: monospace;
+ font-size: 12px;
+ padding: 0 4px;
+ vertical-align: top;
+}
+
+table.blob td.linenumbers {
+ text-align: right;
+ color: #999;
+ background: #eee;
+ width: 3em;
+ border-right: solid 1px #ddd;
+}
+
+table.blob td.lines {
+ white-space: pre;
+}
+```
+
+### Tree Listing
+
+```css
+table.list td.ls-mode {
+ font-family: monospace;
+ width: 10em;
+}
+
+table.list td.ls-size {
+ text-align: right;
+ width: 5em;
+}
+```
+
+## Commit View
+
+```css
+table.commit-info {
+ border-collapse: collapse;
+ border: solid 1px #aaa;
+ margin-bottom: 1em;
+}
+
+table.commit-info th {
+ text-align: left;
+ font-weight: bold;
+ padding: 2px 6px;
+ vertical-align: top;
+}
+
+table.commit-info td {
+ padding: 2px 6px;
+}
+
+div.commit-subject {
+ font-weight: bold;
+ font-size: 125%;
+ margin: 1em 0 0.5em;
+}
+
+div.commit-msg {
+ white-space: pre;
+ font-family: monospace;
+}
+
+div.notes-header {
+ font-weight: bold;
+ padding-top: 1em;
+}
+
+div.notes {
+ white-space: pre;
+ font-family: monospace;
+ border-left: solid 3px #dd5;
+ padding: 0.5em;
+ background: #ffe;
+}
+```
+
+## Log View
+
+```css
+div.commit-graph {
+ font-family: monospace;
+ white-space: pre;
+ color: #000;
+}
+
+/* Column colors for commit graph */
+.column1 { color: #a00; }
+.column2 { color: #0a0; }
+.column3 { color: #00a; }
+.column4 { color: #aa0; }
+.column5 { color: #0aa; }
+.column6 { color: #a0a; }
+```
+
+## Stats View
+
+```css
+table.stats {
+ border-collapse: collapse;
+ border: solid 1px #aaa;
+}
+
+table.stats th {
+ text-align: left;
+ padding: 2px 6px;
+ background: #ddd;
+}
+
+table.stats td {
+ padding: 2px 6px;
+}
+
+div.stats-graph {
+ /* bar chart container */
+}
+```
+
+## Form Elements
+
+```css
+div.cgit-panel {
+ float: right;
+ margin: 0 0 0.5em 0.5em;
+ padding: 4px;
+ border: solid 1px #aaa;
+ background: #eee;
+}
+
+div.cgit-panel b {
+ display: block;
+ margin-bottom: 2px;
+}
+
+div.cgit-panel select,
+div.cgit-panel input {
+ font-size: 11px;
+}
+```
+
+## Customization Strategies
+
+### Method 1: Override Stylesheet
+
+Create a custom CSS file that overrides specific rules:
+
+```css
+/* /cgit/custom.css */
+body {
+ background: #1a1a2e;
+ color: #e0e0e0;
+}
+
+div#cgit {
+ background: #16213e;
+}
+
+table.list th {
+ background: #0f3460;
+ color: #e0e0e0;
+}
+```
+
+```ini
+css=/cgit/cgit.css
+css=/cgit/custom.css
+```
+
+### Method 2: Replace Stylesheet
+
+Replace the default stylesheet entirely:
+
+```ini
+css=/cgit/mytheme.css
+```
+
+### Method 3: head-include
+
+Inject inline styles via the `head-include` directive:
+
+```ini
+head-include=/etc/cgit/extra-head.html
+```
+
+```html
+<!-- /etc/cgit/extra-head.html -->
+<style>
+ body { background: #f0f0f0; }
+</style>
+```
+
+## CSS Classes Reference
+
+### Layout Classes
+
+| Class/ID | Element | Description |
+|----------|---------|-------------|
+| `#cgit` | div | Main container |
+| `#header` | table | Site header |
+| `#navigation` | table | Tab navigation |
+| `#content` | div | Page content area |
+| `.footer` | div | Page footer |
+
+### Content Classes
+
+| Class | Element | Description |
+|-------|---------|-------------|
+| `.list` | table | Data listing (repos, files, refs) |
+| `.blob` | table | File content display |
+| `.diff` | table | Unified diff |
+| `.ssdiff` | table | Side-by-side diff |
+| `.diffstat` | table | Diff statistics |
+| `.commit-info` | table | Commit metadata |
+| `.stats` | table | Statistics data |
+| `.cgit-panel` | div | Control panel |
+
+### Diff Classes
+
+| Class | Element | Description |
+|-------|---------|-------------|
+| `.add` | div/span | Added lines/chars |
+| `.del` | div/span | Deleted lines/chars |
+| `.hunk` | div | Hunk header |
+| `.ctx` | div | Context lines |
+| `.head` | div | File header |
+| `.changed` | td | Modified line (ssdiff) |
+| `.lineno` | td | Line number column |
+
+### Status Classes
+
+| Class | Description |
+|-------|-------------|
+| `.upd` | Modified file |
+| `.add` | Added file |
+| `.del` | Deleted file |
+| `.mode` | File mode indicator |
+| `.graph` | Graph bar container |
diff --git a/docs/handbook/cgit/deployment.md b/docs/handbook/cgit/deployment.md
new file mode 100644
index 0000000000..8c991726af
--- /dev/null
+++ b/docs/handbook/cgit/deployment.md
@@ -0,0 +1,369 @@
+# cgit — Deployment Guide
+
+## Overview
+
+cgit runs as a CGI application under a web server. This guide covers
+compilation, installation, web server configuration, and production tuning.
+
+## Prerequisites
+
+Build dependencies:
+- GCC or Clang (C99 compiler)
+- GNU Make
+- OpenSSL or compatible TLS library (for libgit HTTPS)
+- zlib (for git object decompression)
+- Optional: Lua or LuaJIT (for Lua filters)
+- Optional: pkg-config (for Lua detection)
+
+Runtime dependencies:
+- A CGI-capable web server (Apache, Nginx+fcgiwrap, lighttpd)
+- Git repositories on the filesystem
+
+## Building
+
+```bash
+# Clone/download the source
+cd cgit/
+
+# Build with defaults
+make
+
+# Or with custom settings
+make prefix=/usr CGIT_SCRIPT_PATH=/var/www/cgi-bin \
+ CGIT_CONFIG=/etc/cgitrc CACHE_ROOT=/var/cache/cgit
+
+# Install
+make install
+```
+
+### Build Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `prefix` | `/usr/local` | Installation prefix |
+| `CGIT_SCRIPT_PATH` | `$(prefix)/lib/cgit` | CGI binary directory |
+| `CGIT_DATA_PATH` | `$(prefix)/share/cgit` | Static files (CSS, images) |
+| `CGIT_CONFIG` | `/etc/cgitrc` | Default config file path |
+| `CACHE_ROOT` | `/var/cache/cgit` | Default cache directory |
+| `CGIT_SCRIPT_NAME` | `"/"` | Default CGI script name |
+| `NO_LUA` | (unset) | Set to 1 to disable Lua |
+
+### Installed Files
+
+```
+$(CGIT_SCRIPT_PATH)/cgit.cgi # CGI binary
+$(CGIT_DATA_PATH)/cgit.css # Stylesheet
+$(CGIT_DATA_PATH)/cgit.js # JavaScript
+$(CGIT_DATA_PATH)/cgit.png # Logo image
+$(CGIT_DATA_PATH)/robots.txt # Robots exclusion file
+```
+
+## Apache Configuration
+
+### CGI Module
+
+```apache
+# Enable CGI
+LoadModule cgi_module modules/mod_cgi.so
+
+# Basic CGI setup
+ScriptAlias /cgit/ /usr/lib/cgit/cgit.cgi/
+Alias /cgit-data/ /usr/share/cgit/
+
+<Directory "/usr/lib/cgit/">
+ AllowOverride None
+ Options +ExecCGI
+ Require all granted
+</Directory>
+
+<Directory "/usr/share/cgit/">
+ AllowOverride None
+ Require all granted
+</Directory>
+```
+
+### URL Rewriting (Clean URLs)
+
+```apache
+# Enable clean URLs via mod_rewrite
+RewriteEngine On
+RewriteRule ^/cgit/(.*)$ /usr/lib/cgit/cgit.cgi/$1 [PT]
+```
+
+With corresponding cgitrc:
+
+```ini
+virtual-root=/cgit/
+css=/cgit-data/cgit.css
+logo=/cgit-data/cgit.png
+```
+
+## Nginx Configuration
+
+Nginx does not support CGI natively. Use `fcgiwrap` or `spawn-fcgi`:
+
+### With fcgiwrap
+
+```bash
+# Install fcgiwrap
+# Start it (systemd, OpenRC, or manual)
+fcgiwrap -s unix:/run/fcgiwrap.sock &
+```
+
+```nginx
+server {
+ listen 80;
+ server_name git.example.com;
+
+ root /usr/share/cgit;
+
+ # Serve static files directly
+ location /cgit-data/ {
+ alias /usr/share/cgit/;
+ }
+
+ # Pass CGI requests to fcgiwrap
+ location /cgit {
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi;
+ fastcgi_param PATH_INFO $uri;
+ fastcgi_param QUERY_STRING $args;
+ fastcgi_param HTTP_HOST $server_name;
+ fastcgi_pass unix:/run/fcgiwrap.sock;
+ }
+}
+```
+
+### With spawn-fcgi
+
+```bash
+spawn-fcgi -s /run/cgit.sock -n -- /usr/bin/fcgiwrap
+```
+
+## lighttpd Configuration
+
+```lighttpd
+server.modules += ("mod_cgi", "mod_alias", "mod_rewrite")
+
+alias.url = (
+ "/cgit-data/" => "/usr/share/cgit/",
+ "/cgit/" => "/usr/lib/cgit/cgit.cgi"
+)
+
+cgi.assign = (
+ "cgit.cgi" => ""
+)
+
+url.rewrite-once = (
+ "^/cgit/(.*)$" => "/cgit/cgit.cgi/$1"
+)
+```
+
+## Configuration File
+
+Create `/etc/cgitrc`:
+
+```ini
+# Site identity
+root-title=My Git Server
+root-desc=Git repository browser
+css=/cgit-data/cgit.css
+logo=/cgit-data/cgit.png
+favicon=/cgit-data/favicon.ico
+
+# URL routing
+virtual-root=/cgit/
+
+# Features
+enable-commit-graph=1
+enable-blame=1
+enable-http-clone=1
+enable-index-links=1
+snapshots=tar.gz tar.xz zip
+max-stats=quarter
+
+# Caching (recommended for production)
+cache-size=1000
+cache-root=/var/cache/cgit
+cache-root-ttl=5
+cache-repo-ttl=5
+cache-static-ttl=-1
+
+# Repository discovery
+scan-path=/srv/git/
+section-from-path=1
+enable-git-config=1
+
+# Filters
+source-filter=exec:/usr/lib/cgit/filters/syntax-highlighting.py
+about-filter=exec:/usr/lib/cgit/filters/about-formatting.sh
+```
+
+## Cache Directory Setup
+
+```bash
+# Create cache directory
+mkdir -p /var/cache/cgit
+
+# Set ownership to web server user
+chown www-data:www-data /var/cache/cgit
+chmod 700 /var/cache/cgit
+
+# Optional: periodic cleanup cron job
+echo "*/30 * * * * find /var/cache/cgit -type f -mmin +60 -delete" | \
+ crontab -u www-data -
+```
+
+## Repository Permissions
+
+The web server user needs read access to all git repositories:
+
+```bash
+# Option 1: Add web server user to git group
+usermod -aG git www-data
+
+# Option 2: Set directory permissions
+chmod -R g+rX /srv/git/
+
+# Option 3: Use ACLs
+setfacl -R -m u:www-data:rX /srv/git/
+setfacl -R -d -m u:www-data:rX /srv/git/
+```
+
+## HTTPS Setup
+
+For production, serve cgit over HTTPS:
+
+```nginx
+server {
+ listen 443 ssl;
+ server_name git.example.com;
+
+ ssl_certificate /etc/ssl/certs/git.example.com.pem;
+ ssl_certificate_key /etc/ssl/private/git.example.com.key;
+
+ # ... cgit configuration ...
+}
+
+server {
+ listen 80;
+ server_name git.example.com;
+ return 301 https://$server_name$request_uri;
+}
+```
+
+## Performance Tuning
+
+### Enable Caching
+
+The response cache is essential for performance:
+
+```ini
+cache-size=1000 # number of cache entries
+cache-root-ttl=5 # repo list: 5 minutes
+cache-repo-ttl=5 # repo pages: 5 minutes
+cache-static-ttl=-1 # static content: forever
+cache-about-ttl=15 # about pages: 15 minutes
+```
+
+### Limit Resource Usage
+
+```ini
+max-repo-count=100 # repos per page
+max-commit-count=50 # commits per page
+max-blob-size=512 # max blob display (KB)
+max-message-length=120 # truncate long subjects
+max-repodesc-length=80 # truncate descriptions
+```
+
+### Use Lua Filters
+
+Lua filters avoid fork/exec overhead:
+
+```ini
+source-filter=lua:/usr/share/cgit/filters/syntax-highlight.lua
+email-filter=lua:/usr/share/cgit/filters/email-libravatar.lua
+```
+
+### Optimize Git Access
+
+```bash
+# Run periodic git gc on repositories
+for repo in /srv/git/*.git; do
+ git -C "$repo" gc --auto
+done
+
+# Ensure pack files are optimized
+for repo in /srv/git/*.git; do
+ git -C "$repo" repack -a -d
+done
+```
+
+## Monitoring
+
+### Check Cache Status
+
+```bash
+# Count cache entries
+ls /var/cache/cgit/ | wc -l
+
+# Check cache hit rate (if access logs are enabled)
+grep "cgit.cgi" /var/log/nginx/access.log | tail -100
+```
+
+### Health Check
+
+```bash
+# Verify cgit is responding
+curl -s -o /dev/null -w "%{http_code}" http://localhost/cgit/
+```
+
+## Docker Deployment
+
+```dockerfile
+FROM alpine:latest
+
+RUN apk add --no-cache \
+ git make gcc musl-dev openssl-dev zlib-dev lua5.3-dev \
+ fcgiwrap nginx
+
+COPY cgit/ /build/cgit/
+WORKDIR /build/cgit
+RUN make && make install
+
+COPY cgitrc /etc/cgitrc
+COPY nginx.conf /etc/nginx/conf.d/cgit.conf
+
+EXPOSE 80
+CMD ["sh", "-c", "fcgiwrap -s unix:/run/fcgiwrap.sock & nginx -g 'daemon off;'"]
+```
+
+## systemd Service
+
+```ini
+# /etc/systemd/system/fcgiwrap-cgit.service
+[Unit]
+Description=fcgiwrap for cgit
+After=network.target
+
+[Service]
+ExecStart=/usr/bin/fcgiwrap -s unix:/run/fcgiwrap.sock
+User=www-data
+Group=www-data
+
+[Install]
+WantedBy=multi-user.target
+```
+
+## Troubleshooting
+
+| Symptom | Cause | Solution |
+|---------|-------|----------|
+| 500 Internal Server Error | CGI binary not executable | `chmod +x cgit.cgi` |
+| Blank page | Missing CSS path | Check `css=` directive |
+| No repositories shown | Wrong `scan-path` | Verify path and permissions |
+| Cache errors | Permission denied | Fix cache dir ownership |
+| Lua filter fails | Lua not compiled in | Rebuild without `NO_LUA` |
+| Clone fails | `enable-http-clone=0` | Set to `1` |
+| Missing styles | Static file alias wrong | Check web server alias config |
+| Timeout on large repos | No caching | Enable `cache-size` |
diff --git a/docs/handbook/cgit/diff-engine.md b/docs/handbook/cgit/diff-engine.md
new file mode 100644
index 0000000000..c82092842c
--- /dev/null
+++ b/docs/handbook/cgit/diff-engine.md
@@ -0,0 +1,352 @@
+# cgit — Diff Engine
+
+## Overview
+
+cgit's diff engine renders differences between commits, trees, and blobs.
+It supports three diff modes: unified, side-by-side, and stat-only. The
+engine leverages libgit's internal diff machinery and adds HTML rendering on
+top.
+
+Source files: `ui-diff.c`, `ui-diff.h`, `ui-ssdiff.c`, `ui-ssdiff.h`,
+`shared.c` (diff helpers).
+
+## Diff Types
+
+```c
+#define DIFF_UNIFIED 0 /* traditional unified diff */
+#define DIFF_SSDIFF 1 /* side-by-side diff */
+#define DIFF_STATONLY 2 /* only show diffstat */
+```
+
+The diff type is selected by the `ss` query parameter or the
+`side-by-side-diffs` configuration directive.
+
+## Diffstat
+
+### File Info Structure
+
+```c
+struct fileinfo {
+ char status; /* 'A'dd, 'D'elete, 'M'odify, 'R'ename, etc. */
+ unsigned long old_size;
+ unsigned long new_size;
+ int binary;
+ struct object_id old_oid; /* old blob SHA */
+ struct object_id new_oid; /* new blob SHA */
+ unsigned short old_mode;
+ unsigned short new_mode;
+ char *old_path;
+ char *new_path;
+ int added; /* lines added */
+ int removed; /* lines removed */
+};
+```
+
+### Collecting File Changes: `inspect_filepair()`
+
+For each changed file in a commit, `inspect_filepair()` records the change
+information:
+
+```c
+static void inspect_filepair(struct diff_filepair *pair)
+{
+ /* populate a fileinfo entry from the diff_filepair */
+ files++;
+ switch (pair->status) {
+ case DIFF_STATUS_ADDED:
+ info->status = 'A';
+ break;
+ case DIFF_STATUS_DELETED:
+ info->status = 'D';
+ break;
+ case DIFF_STATUS_MODIFIED:
+ info->status = 'M';
+ break;
+ case DIFF_STATUS_RENAMED:
+ info->status = 'R';
+ /* old_path and new_path differ */
+ break;
+ case DIFF_STATUS_COPIED:
+ info->status = 'C';
+ break;
+ /* ... */
+ }
+}
+```
+
+### Rendering Diffstat: `cgit_print_diffstat()`
+
+```c
+void cgit_print_diffstat(const struct object_id *old,
+ const struct object_id *new,
+ const char *prefix)
+```
+
+Renders an HTML table showing changed files with bar graphs:
+
+```html
+<table summary='diffstat' class='diffstat'>
+ <tr>
+ <td class='mode'>M</td>
+ <td class='upd'><a href='...'>src/main.c</a></td>
+ <td class='right'>42</td>
+ <td class='graph'>
+ <span class='add' style='width: 70%'></span>
+ <span class='rem' style='width: 30%'></span>
+ </td>
+ </tr>
+ ...
+ <tr class='total'>
+ <td colspan='3'>5 files changed, 120 insertions, 45 deletions</td>
+ </tr>
+</table>
+```
+
+The bar graph width is calculated proportionally to the maximum changed
+lines across all files.
+
+## Unified Diff
+
+### `cgit_print_diff()`
+
+The main diff rendering function:
+
+```c
+void cgit_print_diff(const char *new_rev, const char *old_rev,
+ const char *prefix, int show_ctrls, int raw)
+```
+
+Parameters:
+- `new_rev` — New commit SHA
+- `old_rev` — Old commit SHA (optional; defaults to parent)
+- `prefix` — Path prefix filter (show only diffs under this path)
+- `show_ctrls` — Show diff controls (diff type toggle buttons)
+- `raw` — Output raw diff without HTML wrapping
+
+### Diff Controls
+
+When `show_ctrls=1`, diff mode toggle buttons are rendered:
+
+```html
+<div class='cgit-panel'>
+ <b>Diff options</b>
+ <form method='get' action='...'>
+ <select name='dt'>
+ <option value='0'>unified</option>
+ <option value='1'>ssdiff</option>
+ <option value='2'>stat only</option>
+ </select>
+ <input type='submit' value='Go'/>
+ </form>
+</div>
+```
+
+### Filepair Callback: `filepair_cb()`
+
+For each changed file, `filepair_cb()` renders the diff:
+
+```c
+static void filepair_cb(struct diff_filepair *pair)
+{
+ /* emit file header */
+ htmlf("<div class='head'>%s</div>", pair->one->path);
+ /* set up diff options */
+ xdiff_opts.ctxlen = ctx.qry.context ?: 3;
+ /* run the diff and emit line-by-line output */
+ /* each line gets a CSS class: .add, .del, or .ctx */
+}
+```
+
+### Hunk Headers
+
+```c
+void cgit_print_diff_hunk_header(int oldofs, int oldcnt,
+ int newofs, int newcnt,
+ const char *func)
+```
+
+Renders hunk headers as:
+
+```html
+<div class='hunk'>@@ -oldofs,oldcnt +newofs,newcnt @@ func</div>
+```
+
+### Line Rendering
+
+Each diff line is rendered with a status prefix and CSS class:
+
+| Line Type | CSS Class | Prefix |
+|-----------|----------|--------|
+| Added | `.add` | `+` |
+| Removed | `.del` | `-` |
+| Context | `.ctx` | ` ` |
+| Hunk header | `.hunk` | `@@` |
+
+## Side-by-Side Diff (`ui-ssdiff.c`)
+
+The side-by-side diff view renders old and new versions in adjacent columns.
+
+### LCS Algorithm
+
+`ui-ssdiff.c` implements a Longest Common Subsequence (LCS) algorithm to
+align lines between old and new versions:
+
+```c
+/* LCS computation for line alignment */
+static int *lcs(char *a, int an, char *b, int bn)
+{
+ int *prev, *curr;
+ /* dynamic programming: build LCS table */
+ prev = calloc(bn + 1, sizeof(int));
+ curr = calloc(bn + 1, sizeof(int));
+ for (int i = 1; i <= an; i++) {
+ for (int j = 1; j <= bn; j++) {
+ if (a[i-1] == b[j-1])
+ curr[j] = prev[j-1] + 1;
+ else
+ curr[j] = MAX(prev[j], curr[j-1]);
+ }
+ SWAP(prev, curr);
+ }
+ return prev;
+}
+```
+
+### Deferred Lines
+
+Side-by-side rendering uses a deferred output model:
+
+```c
+struct deferred_lines {
+ int line_no;
+ char *line;
+ struct deferred_lines *next;
+};
+```
+
+Lines are collected and paired before output. For modified lines, the LCS
+algorithm identifies character-level changes and highlights them with
+`<span class='add'>` or `<span class='del'>` within each line.
+
+### Tab Expansion
+
+```c
+static char *replace_tabs(char *line)
+```
+
+Tabs are expanded to spaces for proper column alignment in side-by-side
+view. The tab width is 8 characters.
+
+### Rendering
+
+Side-by-side output uses a two-column `<table>`:
+
+```html
+<table class='ssdiff'>
+ <tr>
+ <td class='lineno'><a>42</a></td>
+ <td class='del'>old line content</td>
+ <td class='lineno'><a>42</a></td>
+ <td class='add'>new line content</td>
+ </tr>
+</table>
+```
+
+Changed characters within a line are highlighted with inline spans.
+
+## Low-Level Diff Helpers (`shared.c`)
+
+### Tree Diff
+
+```c
+void cgit_diff_tree(const struct object_id *old_oid,
+ const struct object_id *new_oid,
+ filepair_fn fn, const char *prefix,
+ int renamelimit)
+```
+
+Computes the diff between two tree objects (typically from two commits).
+Calls `fn` for each changed file pair. `renamelimit` controls rename
+detection threshold.
+
+### Commit Diff
+
+```c
+void cgit_diff_commit(struct commit *commit, filepair_fn fn,
+ const char *prefix)
+```
+
+Diffs a commit against its first parent. For root commits (no parent),
+diffs against an empty tree.
+
+### File Diff
+
+```c
+void cgit_diff_files(const struct object_id *old_oid,
+ const struct object_id *new_oid,
+ unsigned long *old_size,
+ unsigned long *new_size,
+ int *binary, int context,
+ int ignorews, linediff_fn fn)
+```
+
+Performs a line-level diff between two blobs. The `linediff_fn` callback is
+invoked for each output line (add/remove/context).
+
+## Diff in Context: Commit View
+
+`ui-commit.c` uses the diff engine to show changes in commit view:
+
+```c
+void cgit_print_commit(const char *rev, const char *prefix)
+{
+ /* ... commit metadata ... */
+ cgit_print_diff(ctx.qry.sha1, info->parent_sha1, prefix, 0, 0);
+}
+```
+
+## Diff in Context: Log View
+
+`ui-log.c` can optionally show per-commit diffstats:
+
+```c
+if (ctx.cfg.enable_log_filecount) {
+ cgit_diff_commit(commit, inspect_filepair, NULL);
+ /* display changed files count, added/removed */
+}
+```
+
+## Binary Detection
+
+Files are marked as binary when diffing if the content contains null bytes
+or exceeds the configured max-blob-size. Binary files are shown as:
+
+```
+Binary files differ
+```
+
+No line-level diff is performed for binary content.
+
+## Diff Configuration
+
+| Directive | Default | Effect |
+|-----------|---------|--------|
+| `side-by-side-diffs` | 0 | Default diff type |
+| `renamelimit` | -1 | Rename detection limit |
+| `max-blob-size` | 0 | Max blob size for display |
+| `enable-log-filecount` | 0 | Show file counts in log |
+| `enable-log-linecount` | 0 | Show line counts in log |
+
+## Raw Diff Output
+
+The `rawdiff` command outputs a plain-text unified diff without HTML
+wrapping, suitable for piping or downloading:
+
+```c
+static void cmd_rawdiff(struct cgit_context *ctx)
+{
+ ctx->page.mimetype = "text/plain";
+ cgit_print_diff(ctx->qry.sha1, ctx->qry.sha2,
+ ctx->qry.path, 0, 1 /* raw */);
+}
+```
diff --git a/docs/handbook/cgit/filter-system.md b/docs/handbook/cgit/filter-system.md
new file mode 100644
index 0000000000..be6f94e4b7
--- /dev/null
+++ b/docs/handbook/cgit/filter-system.md
@@ -0,0 +1,358 @@
+# cgit — Filter System
+
+## Overview
+
+cgit provides a pluggable content filtering pipeline that transforms text
+before it is rendered in HTML output. Filters are used for tasks such as
+syntax highlighting, README rendering, email obfuscation, and authentication.
+
+Source file: `filter.c`.
+
+## Filter Types
+
+Six filter types are defined, each identified by a constant and linked to an
+entry in the `filter_specs[]` table:
+
+```c
+#define ABOUT_FILTER 0 /* README/about page rendering */
+#define COMMIT_FILTER 1 /* commit message formatting */
+#define SOURCE_FILTER 2 /* source code syntax highlighting */
+#define EMAIL_FILTER 3 /* email address display */
+#define AUTH_FILTER 4 /* authentication/authorization */
+#define OWNER_FILTER 5 /* owner field display */
+```
+
+### Filter Specs Table
+
+```c
+static struct {
+ char *prefix;
+ int args;
+} filter_specs[] = {
+ [ABOUT_FILTER] = { "about", 1 },
+ [COMMIT_FILTER] = { "commit", 0 },
+ [SOURCE_FILTER] = { "source", 1 },
+ [EMAIL_FILTER] = { "email", 2 }, /* email, page */
+ [AUTH_FILTER] = { "auth", 12 },
+ [OWNER_FILTER] = { "owner", 0 },
+};
+```
+
+The `args` field specifies the number of *extra* arguments the filter
+receives (beyond the filter command itself).
+
+## Filter Structure
+
+```c
+struct cgit_filter {
+ char *cmd; /* command or script path */
+ int type; /* filter type constant */
+ int (*open)(struct cgit_filter *, ...); /* start filter */
+ int (*close)(struct cgit_filter *); /* finish filter */
+ void (*fprintf)(struct cgit_filter *, FILE *, const char *fmt, ...);
+ void (*cleanup)(struct cgit_filter *); /* free resources */
+ int argument_count; /* from filter_specs */
+};
+```
+
+Two implementations exist:
+
+| Implementation | Struct | Description |
+|---------------|--------|-------------|
+| Exec filter | `struct cgit_exec_filter` | Fork/exec an external process |
+| Lua filter | `struct cgit_lua_filter` | Execute a Lua script in-process |
+
+## Exec Filters
+
+Exec filters fork a child process and redirect `stdout` through a pipe. All
+data written to `stdout` while the filter is open passes through the child
+process, which can transform it before output.
+
+### Structure
+
+```c
+struct cgit_exec_filter {
+ struct cgit_filter base;
+ char *cmd;
+ char **argv;
+ int old_stdout; /* saved fd for restoring stdout */
+ int pipe_fh[2]; /* pipe: [read, write] */
+ pid_t pid; /* child process id */
+};
+```
+
+### Open Phase
+
+```c
+static int open_exec_filter(struct cgit_filter *base, ...)
+{
+ struct cgit_exec_filter *f = (struct cgit_exec_filter *)base;
+ /* create pipe */
+ pipe(f->pipe_fh);
+ /* save stdout */
+ f->old_stdout = dup(STDOUT_FILENO);
+ /* fork */
+ f->pid = fork();
+ if (f->pid == 0) {
+ /* child: redirect stdin from pipe read end */
+ dup2(f->pipe_fh[0], STDIN_FILENO);
+ close(f->pipe_fh[0]);
+ close(f->pipe_fh[1]);
+ /* exec the filter command with extra args from va_list */
+ execvp(f->cmd, f->argv);
+ /* on failure: */
+ exit(1);
+ }
+ /* parent: redirect stdout to pipe write end */
+ dup2(f->pipe_fh[1], STDOUT_FILENO);
+ close(f->pipe_fh[0]);
+ close(f->pipe_fh[1]);
+ return 0;
+}
+```
+
+### Close Phase
+
+```c
+static int close_exec_filter(struct cgit_filter *base)
+{
+ struct cgit_exec_filter *f = (struct cgit_exec_filter *)base;
+ int status;
+ fflush(stdout);
+ /* restore original stdout */
+ dup2(f->old_stdout, STDOUT_FILENO);
+ close(f->old_stdout);
+ /* wait for child */
+ waitpid(f->pid, &status, 0);
+ /* return child exit status */
+ if (WIFEXITED(status))
+ return WEXITSTATUS(status);
+ return -1;
+}
+```
+
+### Argument Passing
+
+Extra arguments (from `filter_specs[].args`) are passed via `va_list` in the
+open function and become `argv` entries for the child process:
+
+| Filter Type | argv[0] | argv[1] | argv[2] | ... |
+|-------------|---------|---------|---------|-----|
+| ABOUT | cmd | filename | — | — |
+| SOURCE | cmd | filename | — | — |
+| COMMIT | cmd | — | — | — |
+| OWNER | cmd | — | — | — |
+| EMAIL | cmd | email | page | — |
+| AUTH | cmd | (12 args: method, mimetype, http_host, https, authenticated, username, http_cookie, request_method, query_string, referer, path, http_accept) |
+
+## Lua Filters
+
+When cgit is compiled with Lua support, filters can be Lua scripts executed
+in-process without fork/exec overhead.
+
+### Structure
+
+```c
+struct cgit_lua_filter {
+ struct cgit_filter base;
+ char *script_file;
+ lua_State *lua_state;
+};
+```
+
+### Lua API
+
+The Lua script must define a `filter_open()` and `filter_close()` function.
+Data is passed to the Lua script through a custom `write()` function
+registered in the Lua environment.
+
+```lua
+-- Example source filter
+function filter_open(filename)
+ -- Called when the filter opens
+ -- filename is the file being processed
+end
+
+function write(str)
+ -- Called with chunks of content to filter
+ -- Write transformed output
+ html(str)
+end
+
+function filter_close()
+ -- Called when filtering is complete
+ return 0 -- return exit code
+end
+```
+
+### Lua C Bindings
+
+cgit registers several C functions into the Lua environment:
+
+```c
+lua_pushcfunction(lua_state, lua_html); /* html() */
+lua_pushcfunction(lua_state, lua_html_txt); /* html_txt() */
+lua_pushcfunction(lua_state, lua_html_attr); /* html_attr() */
+lua_pushcfunction(lua_state, lua_html_url_path); /* html_url_path() */
+lua_pushcfunction(lua_state, lua_html_url_arg); /* html_url_arg() */
+lua_pushcfunction(lua_state, lua_html_include); /* include() */
+```
+
+These correspond to the C functions in `html.c` and allow the Lua script to
+produce properly escaped HTML output.
+
+### Lua Filter Open
+
+```c
+static int open_lua_filter(struct cgit_filter *base, ...)
+{
+ struct cgit_lua_filter *f = (struct cgit_lua_filter *)base;
+ /* Load and execute the Lua script if not already loaded */
+ if (!f->lua_state) {
+ f->lua_state = luaL_newstate();
+ luaL_openlibs(f->lua_state);
+ /* register C bindings */
+ /* load script file */
+ }
+ /* redirect write() calls to the Lua state */
+ /* call filter_open() in the Lua script, passing extra args */
+ return 0;
+}
+```
+
+### Lua Filter Close
+
+```c
+static int close_lua_filter(struct cgit_filter *base)
+{
+ struct cgit_lua_filter *f = (struct cgit_lua_filter *)base;
+ /* call filter_close() in the Lua script */
+ /* return the script's exit code */
+ return lua_tointeger(f->lua_state, -1);
+}
+```
+
+## Filter Construction
+
+`cgit_new_filter()` creates a new filter instance:
+
+```c
+struct cgit_filter *cgit_new_filter(const char *cmd, filter_type type)
+{
+ if (!cmd || !*cmd)
+ return NULL;
+
+ if (!prefixcmp(cmd, "lua:")) {
+ /* create Lua filter */
+ return new_lua_filter(cmd + 4, type);
+ }
+ if (!prefixcmp(cmd, "exec:")) {
+ /* create exec filter, stripping prefix */
+ return new_exec_filter(cmd + 5, type);
+ }
+ /* default: treat as exec filter */
+ return new_exec_filter(cmd, type);
+}
+```
+
+Prefix rules:
+- `lua:/path/to/script.lua` → Lua filter
+- `exec:/path/to/script` → exec filter
+- `/path/to/script` (no prefix) → exec filter (backward compatibility)
+
+## Filter Usage Points
+
+### About Filter (`ABOUT_FILTER`)
+
+Applied when rendering README and about pages. Called from `ui-summary.c`
+and the about view:
+
+```c
+cgit_open_filter(ctx.repo->about_filter, filename);
+/* write README content */
+cgit_close_filter(ctx.repo->about_filter);
+```
+
+Common use: converting Markdown to HTML.
+
+### Source Filter (`SOURCE_FILTER`)
+
+Applied when displaying file contents in blob/tree views. Called from
+`ui-tree.c`:
+
+```c
+cgit_open_filter(ctx.repo->source_filter, filename);
+/* write file content */
+cgit_close_filter(ctx.repo->source_filter);
+```
+
+Common use: syntax highlighting.
+
+### Commit Filter (`COMMIT_FILTER`)
+
+Applied to commit messages in log and commit views. Called from `ui-log.c`
+and `ui-commit.c`:
+
+```c
+cgit_open_filter(ctx.repo->commit_filter);
+html_txt(info->msg);
+cgit_close_filter(ctx.repo->commit_filter);
+```
+
+Common use: linkifying issue references.
+
+### Email Filter (`EMAIL_FILTER`)
+
+Applied to author/committer email addresses. Receives the email address and
+current page name as arguments:
+
+```c
+cgit_open_filter(ctx.repo->email_filter, email, page);
+html_txt(email);
+cgit_close_filter(ctx.repo->email_filter);
+```
+
+Common use: gravatar integration, email obfuscation.
+
+### Auth Filter (`AUTH_FILTER`)
+
+Used for cookie-based authentication. Receives 12 arguments covering the
+full HTTP request context. See `authentication.md` for details.
+
+### Owner Filter (`OWNER_FILTER`)
+
+Applied when displaying the repository owner.
+
+## Shipped Filter Scripts
+
+cgit ships with filter scripts in the `filters/` directory:
+
+| Script | Type | Description |
+|--------|------|-------------|
+| `syntax-highlighting.py` | SOURCE | Python-based syntax highlighter using Pygments |
+| `syntax-highlighting.sh` | SOURCE | Shell-based highlighter (highlight command) |
+| `about-formatting.sh` | ABOUT | Renders markdown via `markdown` or `rst2html` |
+| `html-converters/md2html` | ABOUT | Standalone markdown-to-HTML converter |
+| `html-converters/rst2html` | ABOUT | reStructuredText-to-HTML converter |
+| `html-converters/txt2html` | ABOUT | Plain text to HTML converter |
+| `email-gravatar.py` | EMAIL | Adds gravatar avatars |
+| `email-libravatar.lua` | EMAIL | Lua-based libravatar integration |
+| `simple-hierarchical-auth.lua` | AUTH | Lua path-based authentication |
+
+## Error Handling
+
+If an exec filter's child process exits with a non-zero status, `close()`
+returns that status code. The calling code can check this to fall back to
+unfiltered output.
+
+If a Lua filter throws an error, the error message is logged via
+`die("lua error")` and the filter is aborted.
+
+## Performance Considerations
+
+- **Exec filters** have per-invocation fork/exec overhead. For high-traffic
+ sites, consider Lua filters or enabling the response cache.
+- **Lua filters** run in-process with no fork overhead but require Lua support
+ to be compiled in.
+- Filters are not called when serving cached responses — the cached output
+ already includes the filtered content.
diff --git a/docs/handbook/cgit/html-rendering.md b/docs/handbook/cgit/html-rendering.md
new file mode 100644
index 0000000000..dab14d66b2
--- /dev/null
+++ b/docs/handbook/cgit/html-rendering.md
@@ -0,0 +1,380 @@
+# cgit — HTML Rendering Engine
+
+## Overview
+
+cgit generates all HTML output through a set of low-level rendering functions
+defined in `html.c` and `html.h`. These functions handle entity escaping,
+URL encoding, and formatted output. Higher-level page structure is built by
+`ui-shared.c`.
+
+Source files: `html.c`, `html.h`, `ui-shared.c`, `ui-shared.h`.
+
+## Output Model
+
+All output functions write directly to `stdout` via `write(2)`. There is no
+internal buffering beyond the standard I/O buffer. This design works because
+cgit runs as a CGI process — each request is a separate process with its own
+stdout connected to the web server.
+
+## Core Output Functions
+
+### Raw Output
+
+```c
+void html_raw(const char *data, size_t size);
+```
+
+Writes raw bytes to stdout without any escaping. Used for binary content
+and pre-escaped strings.
+
+### Escaped Text Output
+
+```c
+void html(const char *txt);
+```
+
+Writes a string with HTML entity escaping:
+- `<` → `&lt;`
+- `>` → `&gt;`
+- `&` → `&amp;`
+
+```c
+void html_txt(const char *txt);
+```
+
+Same as `html()` but also escapes:
+- `"` → `&quot;`
+- `'` → `&#x27;`
+
+Used for text content that appears inside HTML tags.
+
+```c
+void html_ntxt(const char *txt, int len);
+```
+
+Length-limited version of `html_txt()`. Writes at most `len` characters,
+appending `...` if truncated.
+
+### Attribute Escaping
+
+```c
+void html_attr(const char *txt);
+```
+
+Escapes text for use in HTML attribute values. Escapes the same characters
+as `html_txt()`.
+
+## URL Encoding
+
+### URL Escape Table
+
+`html.c` defines a 256-entry escape table for URL encoding:
+
+```c
+static const char *url_escape_table[256] = {
+ "%00", "%01", "%02", ...,
+ [' '] = "+",
+ ['!'] = NULL, /* pass through */
+ ['"'] = "%22",
+ ['#'] = "%23",
+ ['%'] = "%25",
+ ['&'] = "%26",
+ ['+'] = "%2B",
+ ['?'] = "%3F",
+ /* letters, digits, '-', '_', '.', '~' pass through (NULL) */
+ ...
+};
+```
+
+Characters with a `NULL` entry pass through unmodified. All others are
+replaced with their percent-encoded representations.
+
+### URL Path Encoding
+
+```c
+void html_url_path(const char *txt);
+```
+
+Encodes a URL path component. Uses `url_escape_table` but preserves `/`
+characters (they are structural in paths).
+
+### URL Argument Encoding
+
+```c
+void html_url_arg(const char *txt);
+```
+
+Encodes a URL query parameter value. Uses `url_escape_table` including
+encoding `/` characters.
+
+## Formatted Output
+
+### `fmt()` — Ring Buffer Formatter
+
+```c
+const char *fmt(const char *format, ...);
+```
+
+A `printf`-style formatter that returns a pointer to an internal static
+buffer. Uses a ring of 8 buffers (each 8 KB) to allow multiple `fmt()`
+calls in a single expression:
+
+```c
+#define FMT_BUFS 8
+#define FMT_SIZE 8192
+
+static char bufs[FMT_BUFS][FMT_SIZE];
+static int bufidx;
+
+const char *fmt(const char *format, ...)
+{
+ bufidx = (bufidx + 1) % FMT_BUFS;
+ va_list args;
+ va_start(args, format);
+ vsnprintf(bufs[bufidx], FMT_SIZE, format, args);
+ va_end(args);
+ return bufs[bufidx];
+}
+```
+
+This is used extensively throughout cgit for constructing strings without
+explicit memory management. The ring buffer avoids use-after-free for up to
+8 nested calls.
+
+### `fmtalloc()` — Heap Formatter
+
+```c
+char *fmtalloc(const char *format, ...);
+```
+
+Like `fmt()` but allocates a new heap buffer with `xstrfmt()`. Used when
+the result must outlive the ring buffer cycle.
+
+### `htmlf()` — Formatted HTML
+
+```c
+void htmlf(const char *format, ...);
+```
+
+`printf`-style output directly to stdout. Does NOT perform HTML escaping —
+the caller must ensure the format string and arguments are safe.
+
+## Form Helpers
+
+### Hidden Fields
+
+```c
+void html_hidden(const char *name, const char *value);
+```
+
+Generates a hidden form field:
+
+```html
+<input type='hidden' name='name' value='value' />
+```
+
+Values are attribute-escaped.
+
+### Option Elements
+
+```c
+void html_option(const char *value, const char *text, const char *selected_value);
+```
+
+Generates an `<option>` element, marking it as selected if `value` matches
+`selected_value`:
+
+```html
+<option value='value' selected='selected'>text</option>
+```
+
+### Checkbox Input
+
+```c
+void html_checkbox(const char *name, int value);
+```
+
+Generates a checkbox input.
+
+### Text Input
+
+```c
+void html_txt_input(const char *name, const char *value, int size);
+```
+
+Generates a text input field.
+
+## Link Generation
+
+```c
+void html_link_open(const char *url, const char *title, const char *class);
+void html_link_close(void);
+```
+
+Generate `<a>` tags with optional title and class attributes. URL is
+path-escaped.
+
+## File Inclusion
+
+```c
+void html_include(const char *filename);
+```
+
+Reads a file from disk and writes its contents to stdout without escaping.
+Used for header/footer file inclusion configured via the `header` and
+`footer` directives.
+
+## Page Structure (`ui-shared.c`)
+
+### HTTP Headers
+
+```c
+void cgit_print_http_headers(void);
+```
+
+Emits HTTP response headers based on `ctx.page`:
+
+```
+Status: 200 OK
+Content-Type: text/html; charset=utf-8
+Last-Modified: Thu, 01 Jan 2024 00:00:00 GMT
+Expires: Thu, 01 Jan 2024 01:00:00 GMT
+ETag: "abc123"
+```
+
+Fields are only emitted when the corresponding `ctx.page` fields are set.
+
+### HTML Document Head
+
+```c
+void cgit_print_docstart(void);
+```
+
+Emits the HTML5 doctype, `<html>`, and `<head>` section:
+
+```html
+<!DOCTYPE html>
+<html lang='en'>
+<head>
+ <title>repo - page</title>
+ <meta name='generator' content='cgit v0.0.5-1-Project-Tick'/>
+ <meta name='robots' content='index, nofollow'/>
+ <link rel='stylesheet' href='/cgit/cgit.css'/>
+ <link rel='icon' href='/favicon.ico'/>
+</head>
+```
+
+### Page Header
+
+```c
+void cgit_print_pageheader(void);
+```
+
+Renders the page header with logo, navigation tabs, and search form.
+Navigation tabs are context-sensitive — repository pages show
+summary/refs/log/tree/commit/diff/stats/etc.
+
+### Page Footer
+
+```c
+void cgit_print_docend(void);
+```
+
+Closes the HTML document with footer content and closing tags.
+
+### Full Page Layout
+
+```c
+void cgit_print_layout_start(void);
+void cgit_print_layout_end(void);
+```
+
+These wrap the page content, calling `cgit_print_http_headers()`,
+`cgit_print_docstart()`, `cgit_print_pageheader()`, etc. Commands with
+`want_layout=1` have their output wrapped in this skeleton.
+
+## Repository Navigation
+
+```c
+void cgit_print_repoheader(void);
+```
+
+For each page within a repository, renders:
+- Repository name and description
+- Navigation tabs: summary, refs, log, tree, commit, diff, stats
+- Clone URLs
+- Badges
+
+## Link Functions
+
+`ui-shared.c` provides numerous helper functions for generating
+context-aware links:
+
+```c
+void cgit_summary_link(const char *name, const char *title,
+ const char *class, const char *head);
+void cgit_tag_link(const char *name, const char *title,
+ const char *class, const char *tag);
+void cgit_tree_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+void cgit_log_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path,
+ int ofs, const char *grep, const char *pattern,
+ int showmsg, int follow);
+void cgit_commit_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+void cgit_patch_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+void cgit_refs_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+void cgit_diff_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *new_rev, const char *old_rev,
+ const char *path, int toggle_hierarchical_threading);
+void cgit_stats_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *path);
+void cgit_plain_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+void cgit_blame_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+void cgit_object_link(struct object *obj);
+void cgit_submodule_link(const char *name, const char *path,
+ const char *commit);
+```
+
+Each function builds a complete `<a>` tag with the appropriate URL, including
+all required query parameters for the target page.
+
+## Diff Output Helpers
+
+```c
+void cgit_print_diff_hunk_header(int oldofs, int oldcnt,
+ int newofs, int newcnt, const char *func);
+void cgit_print_diff_line_prefix(int type);
+```
+
+These render diff hunks with proper CSS classes for syntax coloring (`.add`,
+`.del`, `.hunk`).
+
+## Error Pages
+
+```c
+void cgit_print_error(const char *msg);
+void cgit_print_error_page(int code, const char *msg, const char *fmt, ...);
+```
+
+`cgit_print_error_page()` sets the HTTP status code and wraps the error
+message in a full page layout.
+
+## Encoding
+
+All text output assumes UTF-8. The `Content-Type` header is always
+`charset=utf-8`. There is no character set conversion.
diff --git a/docs/handbook/cgit/lua-integration.md b/docs/handbook/cgit/lua-integration.md
new file mode 100644
index 0000000000..26d605862e
--- /dev/null
+++ b/docs/handbook/cgit/lua-integration.md
@@ -0,0 +1,428 @@
+# cgit — Lua Integration
+
+## Overview
+
+cgit supports Lua as an in-process scripting language for content filters.
+Lua filters avoid the fork/exec overhead of shell-based filters and have
+direct access to cgit's HTML output functions. Lua support is optional and
+auto-detected at compile time.
+
+Source files: `filter.c` (Lua filter implementation), `cgit.mk` (Lua detection).
+
+## Compile-Time Detection
+
+Lua support is detected by `cgit.mk` using `pkg-config`:
+
+```makefile
+ifndef NO_LUA
+LUAPKGS := luajit lua lua5.2 lua5.1
+LUAPKG := $(shell for p in $(LUAPKGS); do \
+ $(PKG_CONFIG) --exists $$p 2>/dev/null && echo $$p && break; done)
+ifneq ($(LUAPKG),)
+ CGIT_CFLAGS += -DHAVE_LUA $(shell $(PKG_CONFIG) --cflags $(LUAPKG))
+ CGIT_LIBS += $(shell $(PKG_CONFIG) --libs $(LUAPKG))
+endif
+endif
+```
+
+Detection order: `luajit` → `lua` → `lua5.2` → `lua5.1`.
+
+To disable Lua even when available:
+
+```bash
+make NO_LUA=1
+```
+
+The `HAVE_LUA` preprocessor define gates all Lua-related code:
+
+```c
+#ifdef HAVE_LUA
+/* Lua filter implementation */
+#else
+/* stub: cgit_new_filter() returns NULL for lua: prefix */
+#endif
+```
+
+## Lua Filter Structure
+
+```c
+struct cgit_lua_filter {
+ struct cgit_filter base; /* common filter fields */
+ char *script_file; /* path to Lua script */
+ lua_State *lua_state; /* Lua interpreter state */
+};
+```
+
+The `lua_State` is lazily initialized on first use and reused for subsequent
+invocations of the same filter.
+
+## C API Exposed to Lua
+
+cgit registers these C functions in the Lua environment:
+
+### `html(str)`
+
+Writes raw HTML to stdout (no escaping):
+
+```c
+static int lua_html(lua_State *L)
+{
+ const char *str = luaL_checkstring(L, 1);
+ html(str);
+ return 0;
+}
+```
+
+### `html_txt(str)`
+
+Writes HTML-escaped text:
+
+```c
+static int lua_html_txt(lua_State *L)
+{
+ const char *str = luaL_checkstring(L, 1);
+ html_txt(str);
+ return 0;
+}
+```
+
+### `html_attr(str)`
+
+Writes attribute-escaped text:
+
+```c
+static int lua_html_attr(lua_State *L)
+{
+ const char *str = luaL_checkstring(L, 1);
+ html_attr(str);
+ return 0;
+}
+```
+
+### `html_url_path(str)`
+
+Writes a URL-encoded path:
+
+```c
+static int lua_html_url_path(lua_State *L)
+{
+ const char *str = luaL_checkstring(L, 1);
+ html_url_path(str);
+ return 0;
+}
+```
+
+### `html_url_arg(str)`
+
+Writes a URL-encoded query argument:
+
+```c
+static int lua_html_url_arg(lua_State *L)
+{
+ const char *str = luaL_checkstring(L, 1);
+ html_url_arg(str);
+ return 0;
+}
+```
+
+### `html_include(filename)`
+
+Includes a file's contents in the output:
+
+```c
+static int lua_html_include(lua_State *L)
+{
+ const char *filename = luaL_checkstring(L, 1);
+ html_include(filename);
+ return 0;
+}
+```
+
+## Lua Filter Lifecycle
+
+### Initialization
+
+On first `open()`, the Lua state is created and the script is loaded:
+
+```c
+static int open_lua_filter(struct cgit_filter *base, ...)
+{
+ struct cgit_lua_filter *f = (struct cgit_lua_filter *)base;
+
+ if (!f->lua_state) {
+ /* Create new Lua state */
+ f->lua_state = luaL_newstate();
+ luaL_openlibs(f->lua_state);
+
+ /* Register C functions */
+ lua_pushcfunction(f->lua_state, lua_html);
+ lua_setglobal(f->lua_state, "html");
+ lua_pushcfunction(f->lua_state, lua_html_txt);
+ lua_setglobal(f->lua_state, "html_txt");
+ lua_pushcfunction(f->lua_state, lua_html_attr);
+ lua_setglobal(f->lua_state, "html_attr");
+ lua_pushcfunction(f->lua_state, lua_html_url_path);
+ lua_setglobal(f->lua_state, "html_url_path");
+ lua_pushcfunction(f->lua_state, lua_html_url_arg);
+ lua_setglobal(f->lua_state, "html_url_arg");
+ lua_pushcfunction(f->lua_state, lua_html_include);
+ lua_setglobal(f->lua_state, "include");
+
+ /* Load and execute the script file */
+ if (luaL_dofile(f->lua_state, f->script_file))
+ die("lua error: %s",
+ lua_tostring(f->lua_state, -1));
+ }
+
+ /* Redirect stdout writes to lua write() function */
+
+ /* Call filter_open() with filter-specific arguments */
+ lua_getglobal(f->lua_state, "filter_open");
+ /* push arguments from va_list */
+ lua_call(f->lua_state, nargs, 0);
+
+ return 0;
+}
+```
+
+### Data Flow
+
+While the filter is open, data written to stdout is intercepted via a custom
+`write()` function:
+
+```c
+/* The fprintf callback for Lua filters */
+static void lua_fprintf(struct cgit_filter *base, FILE *f,
+ const char *fmt, ...)
+{
+ struct cgit_lua_filter *lf = (struct cgit_lua_filter *)base;
+ /* format the string */
+ /* call the Lua write() function with the formatted text */
+ lua_getglobal(lf->lua_state, "write");
+ lua_pushstring(lf->lua_state, buf);
+ lua_call(lf->lua_state, 1, 0);
+}
+```
+
+### Close
+
+```c
+static int close_lua_filter(struct cgit_filter *base)
+{
+ struct cgit_lua_filter *f = (struct cgit_lua_filter *)base;
+
+ /* Call filter_close() */
+ lua_getglobal(f->lua_state, "filter_close");
+ lua_call(f->lua_state, 0, 1);
+
+ /* Get return code */
+ int rc = lua_tointeger(f->lua_state, -1);
+ lua_pop(f->lua_state, 1);
+
+ return rc;
+}
+```
+
+### Cleanup
+
+```c
+static void cleanup_lua_filter(struct cgit_filter *base)
+{
+ struct cgit_lua_filter *f = (struct cgit_lua_filter *)base;
+ if (f->lua_state)
+ lua_close(f->lua_state);
+}
+```
+
+## Lua Script Interface
+
+### Required Functions
+
+A Lua filter script must define these functions:
+
+```lua
+function filter_open(...)
+ -- Called when the filter opens
+ -- Arguments are filter-type specific
+end
+
+function write(str)
+ -- Called with content chunks to process
+ -- Transform and output using html() functions
+end
+
+function filter_close()
+ -- Called when filtering is complete
+ return 0 -- return exit code
+end
+```
+
+### Available Global Functions
+
+| Function | Description |
+|----------|-------------|
+| `html(str)` | Output raw HTML |
+| `html_txt(str)` | Output HTML-escaped text |
+| `html_attr(str)` | Output attribute-escaped text |
+| `html_url_path(str)` | Output URL-path-encoded text |
+| `html_url_arg(str)` | Output URL-argument-encoded text |
+| `include(filename)` | Include file contents in output |
+
+All standard Lua libraries are available (`string`, `table`, `math`, `io`,
+`os`, etc.).
+
+## Example Filters
+
+### Source Highlighting Filter
+
+```lua
+-- syntax-highlighting.lua
+local filename = ""
+local buffer = {}
+
+function filter_open(fn)
+ filename = fn
+ buffer = {}
+end
+
+function write(str)
+ table.insert(buffer, str)
+end
+
+function filter_close()
+ local content = table.concat(buffer)
+ local ext = filename:match("%.(%w+)$") or ""
+
+ -- Simple keyword highlighting
+ local keywords = {
+ ["function"] = true, ["local"] = true,
+ ["if"] = true, ["then"] = true,
+ ["end"] = true, ["return"] = true,
+ ["for"] = true, ["while"] = true,
+ ["do"] = true, ["else"] = true,
+ }
+
+ html("<pre><code>")
+ for line in content:gmatch("([^\n]*)\n?") do
+ html_txt(line)
+ html("\n")
+ end
+ html("</code></pre>")
+
+ return 0
+end
+```
+
+### Email Obfuscation Filter
+
+```lua
+-- email-obfuscate.lua
+function filter_open(email, page)
+ -- email = the email address
+ -- page = current page name
+end
+
+function write(str)
+ -- Replace @ with [at] for display
+ local obfuscated = str:gsub("@", " [at] ")
+ html_txt(obfuscated)
+end
+
+function filter_close()
+ return 0
+end
+```
+
+### About/README Filter
+
+```lua
+-- about-markdown.lua
+local buffer = {}
+
+function filter_open(filename)
+ buffer = {}
+end
+
+function write(str)
+ table.insert(buffer, str)
+end
+
+function filter_close()
+ local content = table.concat(buffer)
+ -- Process markdown (using a Lua markdown library)
+ -- or shell out to a converter
+ local handle = io.popen("cmark", "w")
+ handle:write(content)
+ local result = handle:read("*a")
+ handle:close()
+ html(result)
+ return 0
+end
+```
+
+### Auth Filter (Lua)
+
+```lua
+-- auth.lua
+-- The auth filter receives 12 arguments
+function filter_open(cookie, method, query, referer, path,
+ host, https, repo, page, accept, phase)
+ if phase == "cookie" then
+ -- Validate session cookie
+ if valid_session(cookie) then
+ return 0 -- authenticated
+ end
+ return 1 -- not authenticated
+ elseif phase == "post" then
+ -- Handle login form submission
+ elseif phase == "authorize" then
+ -- Check repository access
+ end
+end
+
+function write(str)
+ html(str)
+end
+
+function filter_close()
+ return 0
+end
+```
+
+## Performance
+
+Lua filters offer significant performance advantages over exec filters:
+
+| Aspect | Exec Filter | Lua Filter |
+|--------|-------------|------------|
+| Startup | fork() + exec() per request | One-time Lua state creation |
+| Process | New process per invocation | In-process |
+| Memory | Separate address space | Shared memory |
+| Latency | ~1-5ms fork overhead | ~0.01ms function call |
+| Libraries | Any language | Lua libraries only |
+
+## Limitations
+
+- Lua scripts run in the same process as cgit — a crash in the script
+ crashes cgit
+- Standard Lua I/O functions (`print`, `io.write`) bypass cgit's output
+ pipeline — use `html()` and friends instead
+- The Lua state persists between invocations within the same CGI process,
+ but CGI processes are typically short-lived
+- Error handling is via `die()` — a Lua error terminates the CGI process
+
+## Configuration
+
+```ini
+# Use Lua filter for source highlighting
+source-filter=lua:/usr/share/cgit/filters/syntax-highlight.lua
+
+# Use Lua filter for about pages
+about-filter=lua:/usr/share/cgit/filters/about-markdown.lua
+
+# Use Lua filter for authentication
+auth-filter=lua:/usr/share/cgit/filters/simple-hierarchical-auth.lua
+
+# Use Lua filter for email display
+email-filter=lua:/usr/share/cgit/filters/email-libravatar.lua
+```
diff --git a/docs/handbook/cgit/overview.md b/docs/handbook/cgit/overview.md
new file mode 100644
index 0000000000..bb09d33e8b
--- /dev/null
+++ b/docs/handbook/cgit/overview.md
@@ -0,0 +1,262 @@
+# cgit — Overview
+
+## What Is cgit?
+
+cgit is a fast, lightweight web frontend for Git repositories, implemented as a
+CGI application written in C. It links directly against libgit (the C library
+that forms the core of the `git` command-line tool), giving it native access to
+repository objects without spawning external processes for every request. This
+design makes cgit one of the fastest Git web interfaces available.
+
+The Project Tick fork carries version `0.0.5-1-Project-Tick` (defined in the
+top-level `Makefile` as `CGIT_VERSION`). It builds against Git 2.46.0 and
+extends the upstream cgit with features such as subtree display, SPDX license
+detection, badge support, Code of Conduct / CLA pages, root links, and an
+enhanced summary page with repository metadata.
+
+## Key Design Goals
+
+| Goal | How cgit achieves it |
+|------|---------------------|
+| **Speed** | Direct libgit linkage; file-based response cache; `sendfile()` on Linux |
+| **Security** | `GIT_CONFIG_NOSYSTEM=1` set at load time; HTML entity escaping in every output function; directory-traversal guards; auth-filter framework |
+| **Simplicity** | Single CGI binary; flat config file (`cgitrc`); no database requirement |
+| **Extensibility** | Pluggable filter system (exec / Lua) for about, commit, source, email, owner, and auth content |
+
+## Source File Map
+
+The entire cgit source tree lives in `cgit/`. Every `.c` file has a matching
+`.h` (with a few exceptions such as `shared.c` and `parsing.c` which declare
+their interfaces in `cgit.h`).
+
+### Core files
+
+| File | Purpose |
+|------|---------|
+| `cgit.h` | Master header — includes libgit headers; defines all major types (`cgit_repo`, `cgit_config`, `cgit_query`, `cgit_context`, etc.) and function prototypes |
+| `cgit.c` | Entry point — `prepare_context()`, `config_cb()`, `querystring_cb()`, `process_request()`, `main()` |
+| `shared.c` | Global variables (`cgit_repolist`, `ctx`); repo management (`cgit_add_repo`, `cgit_get_repoinfo`); diff helpers; parsing helpers |
+| `parsing.c` | Commit/tag parsing (`cgit_parse_commit`, `cgit_parse_tag`, `cgit_parse_url`) |
+| `cmd.c` | Command dispatch table — maps URL page names to handler functions |
+| `cmd.h` | `struct cgit_cmd` definition; `cgit_get_cmd()` prototype |
+| `configfile.c` | Generic `name=value` config parser (`parse_configfile`) |
+| `configfile.h` | `configfile_value_fn` typedef; `parse_configfile` prototype |
+
+### Infrastructure files
+
+| File | Purpose |
+|------|---------|
+| `cache.c` / `cache.h` | File-based response cache — FNV-1 hashing, slot open/lock/fill/unlock cycle |
+| `filter.c` | Filter framework — exec filters (fork/exec), Lua filters (`luaL_newstate`) |
+| `html.c` / `html.h` | HTML output primitives — entity escaping, URL encoding, form helpers |
+| `scan-tree.c` / `scan-tree.h` | Filesystem repository scanning — `scan_tree()`, `scan_projects()` |
+
+### UI modules (`ui-*.c` / `ui-*.h`)
+
+| Module | Page | Handler function |
+|--------|------|-----------------|
+| `ui-repolist` | `repolist` | `cgit_print_repolist()` |
+| `ui-summary` | `summary` | `cgit_print_summary()` |
+| `ui-log` | `log` | `cgit_print_log()` |
+| `ui-commit` | `commit` | `cgit_print_commit()` |
+| `ui-diff` | `diff` | `cgit_print_diff()` |
+| `ui-tree` | `tree` | `cgit_print_tree()` |
+| `ui-blob` | `blob` | `cgit_print_blob()` |
+| `ui-refs` | `refs` | `cgit_print_refs()` |
+| `ui-tag` | `tag` | `cgit_print_tag()` |
+| `ui-snapshot` | `snapshot` | `cgit_print_snapshot()` |
+| `ui-plain` | `plain` | `cgit_print_plain()` |
+| `ui-blame` | `blame` | `cgit_print_blame()` |
+| `ui-patch` | `patch` | `cgit_print_patch()` |
+| `ui-atom` | `atom` | `cgit_print_atom()` |
+| `ui-clone` | `HEAD` / `info` / `objects` | `cgit_clone_head()`, `cgit_clone_info()`, `cgit_clone_objects()` |
+| `ui-stats` | `stats` | `cgit_show_stats()` |
+| `ui-ssdiff` | (helper) | Side-by-side diff rendering via LCS algorithm |
+| `ui-shared` | (helper) | HTTP headers, HTML page skeleton, link generation |
+
+### Static assets
+
+| File | Description |
+|------|-------------|
+| `cgit.css` | Default stylesheet |
+| `cgit.js` | Client-side JavaScript (e.g. tree filtering) |
+| `cgit.png` | Default logo |
+| `favicon.ico` | Default favicon |
+| `robots.txt` | Default robots file |
+
+## Core Data Structures
+
+All major types are defined in `cgit.h`. The single global
+`struct cgit_context ctx` (declared in `shared.c`) holds the entire request
+state:
+
+```c
+struct cgit_context {
+ struct cgit_environment env; /* CGI environment variables */
+ struct cgit_query qry; /* Parsed query/URL parameters */
+ struct cgit_config cfg; /* Global configuration */
+ struct cgit_repo *repo; /* Currently selected repository (or NULL) */
+ struct cgit_page page; /* HTTP response metadata */
+};
+```
+
+### `struct cgit_repo`
+
+Represents a single Git repository. Key fields:
+
+```c
+struct cgit_repo {
+ char *url; /* URL-visible name (e.g. "myproject") */
+ char *name; /* Display name */
+ char *basename; /* Last path component */
+ char *path; /* Filesystem path to .git directory */
+ char *desc; /* Description string */
+ char *owner; /* Repository owner */
+ char *defbranch; /* Default branch (NULL → guess from HEAD) */
+ char *section; /* Section for grouped display */
+ char *clone_url; /* Clone URL override */
+ char *homepage; /* Project homepage URL */
+ struct string_list readme; /* README file references */
+ struct string_list badges; /* Badge image URLs */
+ int snapshots; /* Bitmask of enabled snapshot formats */
+ int enable_blame; /* Whether blame view is enabled */
+ int enable_commit_graph;/* Whether commit graph is shown in log */
+ int enable_subtree; /* Whether subtree detection is enabled */
+ int max_stats; /* Stats period index (0=disabled) */
+ int hide; /* 1 = hidden from listing */
+ int ignore; /* 1 = completely ignored */
+ struct cgit_filter *about_filter; /* Per-repo about filter */
+ struct cgit_filter *source_filter; /* Per-repo source highlighting */
+ struct cgit_filter *email_filter; /* Per-repo email filter */
+ struct cgit_filter *commit_filter; /* Per-repo commit message filter */
+ struct cgit_filter *owner_filter; /* Per-repo owner filter */
+ /* ... */
+};
+```
+
+### `struct cgit_query`
+
+Holds all parsed URL/query-string parameters:
+
+```c
+struct cgit_query {
+ int has_symref, has_oid, has_difftype;
+ char *raw; /* Raw query string */
+ char *repo; /* Repository URL */
+ char *page; /* Page name (log, commit, diff, ...) */
+ char *search; /* Search query (q=) */
+ char *grep; /* Search type (qt=) */
+ char *head; /* Branch/ref (h=) */
+ char *oid, *oid2; /* Object IDs (id=, id2=) */
+ char *path; /* Path within repository */
+ char *name; /* Snapshot filename */
+ int ofs; /* Pagination offset */
+ int showmsg; /* Show full commit messages in log */
+ diff_type difftype; /* DIFF_UNIFIED / DIFF_SSDIFF / DIFF_STATONLY */
+ int context; /* Diff context lines */
+ int ignorews; /* Ignore whitespace in diffs */
+ int follow; /* Follow renames in log */
+ char *vpath; /* Virtual path (set by cmd dispatch) */
+ /* ... */
+};
+```
+
+## Request Lifecycle
+
+1. **Environment setup** — The `constructor_environment()` function runs before
+ `main()` (via `__attribute__((constructor))`). It sets
+ `GIT_CONFIG_NOSYSTEM=1` and `GIT_ATTR_NOSYSTEM=1`, then unsets `HOME` and
+ `XDG_CONFIG_HOME` to prevent Git from reading user/system configurations.
+
+2. **Context initialization** — `prepare_context()` zeroes out `ctx` and sets
+ all configuration defaults (cache sizes, TTLs, feature flags, etc.). CGI
+ environment variables are read from `getenv()`.
+
+3. **Configuration parsing** — `parse_configfile()` reads the cgitrc file
+ (default `/etc/cgitrc`, overridable via `$CGIT_CONFIG`) and calls
+ `config_cb()` for each `name=value` pair. Repository definitions begin with
+ `repo.url=` and subsequent `repo.*` directives configure that repository.
+
+4. **Query parsing** — If running in CGI mode (no `$NO_HTTP`),
+ `http_parse_querystring()` breaks the query string into name/value pairs and
+ passes them to `querystring_cb()`. The `url=` parameter is further parsed by
+ `cgit_parse_url()` which splits it into repo, page, and path components.
+
+5. **Authentication** — `authenticate_cookie()` checks whether an `auth-filter`
+ is configured. If so, it invokes the filter with function
+ `"authenticate-cookie"` and sets `ctx.env.authenticated` from the filter's
+ exit code. POST requests to `/?p=login` route through
+ `authenticate_post()` instead.
+
+6. **Cache lookup** — If caching is enabled (`cache-size > 0`), a cache key is
+ constructed from the URL and passed to `cache_process()`. On a cache hit the
+ stored response is sent directly via `sendfile()`. On a miss, stdout is
+ redirected to a lock file and the request proceeds through normal processing.
+
+7. **Command dispatch** — `cgit_get_cmd()` looks up `ctx.qry.page` in the
+ static `cmds[]` table (defined in `cmd.c`). If the command requires a
+ repository (`want_repo == 1`), the repository is initialized via
+ `prepare_repo_env()` and `prepare_repo_cmd()`.
+
+8. **Page rendering** — The matched command's handler function is called. Each
+ handler uses `cgit_print_http_headers()`, `cgit_print_docstart()`,
+ `cgit_print_pageheader()`, and `cgit_print_docend()` (from `ui-shared.c`)
+ to frame their output inside a proper HTML document.
+
+9. **Cleanup** — `cgit_cleanup_filters()` reaps all filter resources (closing
+ Lua states, freeing argv arrays).
+
+## Version String
+
+The version is compiled into the binary via:
+
+```makefile
+CGIT_VERSION = 0.0.5-1-Project-Tick
+```
+
+and exposed as the global:
+
+```c
+const char *cgit_version = CGIT_VERSION;
+```
+
+This string appears in the HTML footer (rendered by `ui-shared.c`) and in patch
+output trailers.
+
+## Relationship to Git
+
+cgit is built *inside* the Git source tree. The `Makefile` downloads
+Git 2.46.0, extracts it as a `git/` subdirectory, then calls `make -C git -f
+../cgit.mk` which includes Git's own `Makefile` to inherit all build variables,
+object files, and linker flags. The resulting `cgit` binary is a statically
+linked combination of cgit's own object files and libgit.
+
+## Time Constants
+
+`cgit.h` defines convenience macros used for relative date display:
+
+```c
+#define TM_MIN 60
+#define TM_HOUR (TM_MIN * 60)
+#define TM_DAY (TM_HOUR * 24)
+#define TM_WEEK (TM_DAY * 7)
+#define TM_YEAR (TM_DAY * 365)
+#define TM_MONTH (TM_YEAR / 12.0)
+```
+
+These are used by `cgit_print_age()` in `ui-shared.c` to render "2 hours ago"
+style timestamps.
+
+## Default Encoding
+
+```c
+#define PAGE_ENCODING "UTF-8"
+```
+
+All commit messages are re-encoded to UTF-8 before display (see
+`cgit_parse_commit()` in `parsing.c`).
+
+## License
+
+cgit is licensed under the GNU General Public License v2. The `COPYING` file
+in the cgit directory contains the full text.
diff --git a/docs/handbook/cgit/repository-discovery.md b/docs/handbook/cgit/repository-discovery.md
new file mode 100644
index 0000000000..9b961e74cf
--- /dev/null
+++ b/docs/handbook/cgit/repository-discovery.md
@@ -0,0 +1,355 @@
+# cgit — Repository Discovery
+
+## Overview
+
+cgit discovers repositories through two mechanisms: explicit `repo.url=`
+entries in the configuration file, and automatic filesystem scanning via
+`scan-path`. The scan-tree subsystem recursively searches directories for
+git repositories and auto-configures them.
+
+Source files: `scan-tree.c`, `scan-tree.h`, `shared.c` (repository list management).
+
+## Manual Repository Configuration
+
+Repositories can be explicitly defined in the cgitrc file:
+
+```ini
+repo.url=myproject
+repo.path=/srv/git/myproject.git
+repo.desc=My project description
+repo.owner=Alice
+```
+
+Each `repo.url=` triggers `cgit_add_repo()` in `shared.c`, which creates a
+new `cgit_repo` entry in the global repository list.
+
+### `cgit_add_repo()`
+
+```c
+struct cgit_repo *cgit_add_repo(const char *url)
+{
+ struct cgit_repo *ret;
+ /* grow the repo array if needed */
+ if (cgit_repolist.count >= cgit_repolist.length) {
+ /* realloc with doubled capacity */
+ }
+ ret = &cgit_repolist.repos[cgit_repolist.count++];
+ /* initialize with defaults from ctx.cfg */
+ ret->url = xstrdup(url);
+ ret->name = ret->url;
+ ret->path = NULL;
+ ret->desc = cgit_default_repo_desc;
+ ret->owner = NULL;
+ ret->section = ctx.cfg.section;
+ ret->snapshots = ctx.cfg.snapshots;
+ /* ... inherit all global defaults ... */
+ return ret;
+}
+```
+
+## Repository Lookup
+
+```c
+struct cgit_repo *cgit_get_repoinfo(const char *url)
+{
+ int i;
+ for (i = 0; i < cgit_repolist.count; i++) {
+ if (!strcmp(cgit_repolist.repos[i].url, url))
+ return &cgit_repolist.repos[i];
+ }
+ return NULL;
+}
+```
+
+This is a linear scan — adequate for typical installations with dozens to
+hundreds of repositories.
+
+## Filesystem Scanning: `scan-path`
+
+The `scan-path` configuration directive triggers automatic repository
+discovery. When encountered in the config file, `scan_tree()` or
+`scan_projects()` is called immediately.
+
+### `scan_tree()`
+
+```c
+void scan_tree(const char *path, repo_config_fn fn)
+```
+
+Recursively scans `path` for git repositories:
+
+```c
+static void scan_path(const char *base, const char *path, repo_config_fn fn)
+{
+ DIR *dir;
+ struct dirent *ent;
+
+ dir = opendir(path);
+ if (!dir) return;
+
+ while ((ent = readdir(dir)) != NULL) {
+ /* skip "." and ".." */
+ /* skip hidden directories unless scan-hidden-path=1 */
+
+ if (is_git_dir(fullpath)) {
+ /* found a bare repository */
+ add_repo(base, fullpath, fn);
+ } else if (is_git_dir(fullpath + "/.git")) {
+ /* found a non-bare repository */
+ add_repo(base, fullpath + "/.git", fn);
+ } else {
+ /* recurse into subdirectory */
+ scan_path(base, fullpath, fn);
+ }
+ }
+ closedir(dir);
+}
+```
+
+### Git Directory Detection: `is_git_dir()`
+
+```c
+static int is_git_dir(const char *path)
+{
+ struct stat st;
+ struct strbuf pathbuf = STRBUF_INIT;
+
+ /* check for path/HEAD */
+ strbuf_addf(&pathbuf, "%s/HEAD", path);
+ if (stat(pathbuf.buf, &st)) {
+ strbuf_release(&pathbuf);
+ return 0;
+ }
+
+ /* check for path/objects */
+ strbuf_reset(&pathbuf);
+ strbuf_addf(&pathbuf, "%s/objects", path);
+ if (stat(pathbuf.buf, &st) || !S_ISDIR(st.st_mode)) {
+ strbuf_release(&pathbuf);
+ return 0;
+ }
+
+ /* check for path/refs */
+ strbuf_reset(&pathbuf);
+ strbuf_addf(&pathbuf, "%s/refs", path);
+ if (stat(pathbuf.buf, &st) || !S_ISDIR(st.st_mode)) {
+ strbuf_release(&pathbuf);
+ return 0;
+ }
+
+ strbuf_release(&pathbuf);
+ return 1;
+}
+```
+
+A directory is considered a git repository if it contains `HEAD`, `objects/`,
+and `refs/` subdirectories.
+
+### Repository Registration: `add_repo()`
+
+When a git directory is found, `add_repo()` creates a repository entry:
+
+```c
+static void add_repo(const char *base, const char *path, repo_config_fn fn)
+{
+ /* derive URL from path relative to base */
+ /* strip .git suffix if remove-suffix is set */
+ struct cgit_repo *repo = cgit_add_repo(url);
+ repo->path = xstrdup(path);
+
+ /* read gitweb config from the repo */
+ if (ctx.cfg.enable_git_config) {
+ char *gitconfig = fmt("%s/config", path);
+ parse_configfile(gitconfig, gitconfig_config);
+ }
+
+ /* read owner from filesystem */
+ if (!repo->owner) {
+ /* stat the repo dir and lookup uid owner */
+ struct stat st;
+ if (!stat(path, &st)) {
+ struct passwd *pw = getpwuid(st.st_uid);
+ if (pw)
+ repo->owner = xstrdup(pw->pw_name);
+ }
+ }
+
+ /* read description from description file */
+ if (!repo->desc) {
+ char *descfile = fmt("%s/description", path);
+ /* read first line */
+ }
+}
+```
+
+### Git Config Integration: `gitconfig_config()`
+
+When `enable-git-config=1`, each discovered repository's `.git/config` is
+parsed for metadata:
+
+```c
+static int gitconfig_config(const char *key, const char *value)
+{
+ if (!strcmp(key, "gitweb.owner"))
+ repo_config(repo, "owner", value);
+ else if (!strcmp(key, "gitweb.description"))
+ repo_config(repo, "desc", value);
+ else if (!strcmp(key, "gitweb.category"))
+ repo_config(repo, "section", value);
+ else if (!strcmp(key, "gitweb.homepage"))
+ repo_config(repo, "homepage", value);
+ else if (skip_prefix(key, "cgit.", &name))
+ repo_config(repo, name, value);
+ return 0;
+}
+```
+
+This is compatible with gitweb's configuration keys and also supports
+cgit-specific `cgit.*` keys.
+
+## Project List Scanning: `scan_projects()`
+
+```c
+void scan_projects(const char *path, const char *projectsfile,
+ repo_config_fn fn)
+```
+
+Instead of recursively scanning a directory, reads a text file listing
+project paths (one per line). Each path is appended to the base path and
+checked with `is_git_dir()`.
+
+This is useful for large installations where full recursive scanning is too
+slow.
+
+```ini
+project-list=/etc/cgit/projects.list
+scan-path=/srv/git
+```
+
+The `projects.list` file contains relative paths:
+
+```
+myproject.git
+team/frontend.git
+team/backend.git
+```
+
+## Section Derivation
+
+When `section-from-path` is set, repository sections are automatically
+derived from the directory structure:
+
+| Value | Behavior |
+|-------|----------|
+| `0` | No auto-sectioning |
+| `1` | First path component becomes section |
+| `2` | First two components become section |
+| `-1` | Last component becomes section |
+
+Example with `section-from-path=1` and `scan-path=/srv/git`:
+
+```
+/srv/git/team/project.git → section="team"
+/srv/git/personal/test.git → section="personal"
+```
+
+## Age File
+
+The modification time of a repository is determined by:
+
+1. The `agefile` (default: `info/web/last-modified`) — if this file exists
+ in the repository, its contents (a date string) or modification time is
+ used
+2. Otherwise, the mtime of the loose `refs/` directory
+3. As a fallback, the repository directory's own mtime
+
+```c
+static time_t read_agefile(const char *path)
+{
+ FILE *f;
+ static char buf[64];
+
+ f = fopen(path, "r");
+ if (!f)
+ return -1;
+ if (fgets(buf, sizeof(buf), f)) {
+ fclose(f);
+ return parse_date(buf, NULL);
+ }
+ fclose(f);
+ /* fallback to file mtime */
+ struct stat st;
+ if (!stat(path, &st))
+ return st.st_mtime;
+ return 0;
+}
+```
+
+## Repository List Management
+
+The global repository list is a dynamically-sized array:
+
+```c
+struct cgit_repolist {
+ int count;
+ int length; /* allocated capacity */
+ struct cgit_repo *repos;
+};
+
+struct cgit_repolist cgit_repolist;
+```
+
+### Sorting
+
+The repository list can be sorted by different criteria:
+
+```c
+static int cmp_name(const void *a, const void *b); /* by name */
+static int cmp_section(const void *a, const void *b); /* by section */
+static int cmp_idle(const void *a, const void *b); /* by age */
+```
+
+Sorting is controlled by the `repository-sort` directive and the `s` query
+parameter.
+
+## Repository Visibility
+
+Two directives control repository visibility:
+
+| Directive | Effect |
+|-----------|--------|
+| `repo.hide=1` | Repository is hidden from the index but accessible by URL |
+| `repo.ignore=1` | Repository is completely ignored |
+
+Additionally, `strict-export` restricts export to repositories containing a
+specific file (e.g., `git-daemon-export-ok`):
+
+```ini
+strict-export=git-daemon-export-ok
+```
+
+## Scan Path Caching
+
+Scanning large directory trees can be slow. The `cache-scanrc-ttl` directive
+controls how long scan results are cached:
+
+```ini
+cache-scanrc-ttl=15 # cache scan results for 15 minutes
+```
+
+When caching is enabled, the scan is performed only when the cached result
+expires.
+
+## Configuration Reference
+
+| Directive | Default | Description |
+|-----------|---------|-------------|
+| `scan-path` | (none) | Directory to scan for repos |
+| `project-list` | (none) | File listing project paths |
+| `enable-git-config` | 0 | Read repo metadata from git config |
+| `scan-hidden-path` | 0 | Include hidden directories in scan |
+| `remove-suffix` | 0 | Strip `.git` suffix from URLs |
+| `section-from-path` | 0 | Auto-derive section from path |
+| `strict-export` | (none) | Required file for repo visibility |
+| `agefile` | `info/web/last-modified` | File checked for repo age |
+| `cache-scanrc-ttl` | 15 | TTL for cached scan results (minutes) |
diff --git a/docs/handbook/cgit/snapshot-system.md b/docs/handbook/cgit/snapshot-system.md
new file mode 100644
index 0000000000..bb39047f48
--- /dev/null
+++ b/docs/handbook/cgit/snapshot-system.md
@@ -0,0 +1,246 @@
+# cgit — Snapshot System
+
+## Overview
+
+cgit can generate downloadable source archives (snapshots) from any git
+reference. Supported formats include tar, compressed tar variants, and zip.
+The snapshot system validates requests against a configured format mask and
+delegates archive generation to the git archive API.
+
+Source file: `ui-snapshot.c`, `ui-snapshot.h`.
+
+## Snapshot Format Table
+
+All supported formats are defined in `cgit_snapshot_formats[]`:
+
+```c
+const struct cgit_snapshot_format cgit_snapshot_formats[] = {
+ { ".zip", "application/x-zip", write_zip_archive, 0x01 },
+ { ".tar.gz", "application/x-gzip", write_tar_gzip_archive, 0x02 },
+ { ".tar.bz2", "application/x-bzip2", write_tar_bzip2_archive, 0x04 },
+ { ".tar", "application/x-tar", write_tar_archive, 0x08 },
+ { ".tar.xz", "application/x-xz", write_tar_xz_archive, 0x10 },
+ { ".tar.zst", "application/x-zstd", write_tar_zstd_archive, 0x20 },
+ { ".tar.lz", "application/x-lzip", write_tar_lzip_archive, 0x40 },
+ { NULL }
+};
+```
+
+### Format Structure
+
+```c
+struct cgit_snapshot_format {
+ const char *suffix; /* file extension */
+ const char *mimetype; /* HTTP Content-Type */
+ write_archive_fn_t fn; /* archive writer function */
+ int bit; /* bitmask flag */
+};
+```
+
+### Format Bitmask
+
+Each format has a power-of-two bit value. The `snapshots` configuration
+directive sets a bitmask by OR-ing the bits of enabled formats:
+
+| Suffix | Bit | Hex |
+|--------|-----|-----|
+| `.zip` | 0x01 | 1 |
+| `.tar.gz` | 0x02 | 2 |
+| `.tar.bz2` | 0x04 | 4 |
+| `.tar` | 0x08 | 8 |
+| `.tar.xz` | 0x10 | 16 |
+| `.tar.zst` | 0x20 | 32 |
+| `.tar.lz` | 0x40 | 64 |
+| all | 0x7F | 127 |
+
+### Parsing Snapshot Configuration
+
+`cgit_parse_snapshots_mask()` in `shared.c` converts the configuration
+string to a bitmask:
+
+```c
+int cgit_parse_snapshots_mask(const char *str)
+{
+ int mask = 0;
+ /* for each word in str */
+ /* compare against cgit_snapshot_formats[].suffix */
+ /* if match, mask |= format->bit */
+ /* "all" enables all formats */
+ return mask;
+}
+```
+
+## Snapshot Request Processing
+
+### Entry Point: `cgit_print_snapshot()`
+
+```c
+void cgit_print_snapshot(const char *head, const char *hex,
+ const char *prefix, const char *filename,
+ int snapshots)
+```
+
+Parameters:
+- `head` — Branch/tag reference
+- `hex` — Commit SHA
+- `prefix` — Archive prefix (directory name within archive)
+- `filename` — Requested filename (e.g., `myrepo-v1.0.tar.gz`)
+- `snapshots` — Enabled format bitmask
+
+### Reference Resolution: `get_ref_from_filename()`
+
+Decomposes the requested filename into a reference and format:
+
+```c
+static const struct cgit_snapshot_format *get_ref_from_filename(
+ const char *filename, char **ref)
+{
+ /* for each format suffix */
+ /* if filename ends with suffix */
+ /* extract the part before the suffix as the ref */
+ /* return the matching format */
+ /* strip repo prefix if present */
+}
+```
+
+Example decomposition:
+- `myrepo-v1.0.tar.gz` → ref=`v1.0`, format=`.tar.gz`
+- `myrepo-main.zip` → ref=`main`, format=`.zip`
+- `myrepo-abc1234.tar.xz` → ref=`abc1234`, format=`.tar.xz`
+
+The prefix `myrepo-` is the `snapshot-prefix` (defaults to the repo basename).
+
+### Validation
+
+Before generating an archive, the function validates:
+
+1. **Format enabled**: The format's bit must be set in the snapshot mask
+2. **Reference exists**: The ref must resolve to a valid git object
+3. **Object type**: Must be a commit, tag, or tree
+
+### Archive Generation: `write_archive_type()`
+
+```c
+static int write_archive_type(const char *format, const char *hex,
+ const char *prefix)
+{
+ struct archiver_args args;
+ memset(&args, 0, sizeof(args));
+ args.base = prefix; /* directory prefix in archive */
+ /* resolve hex to tree object */
+ /* call write_archive() from libgit */
+}
+```
+
+The actual archive creation is delegated to Git's `write_archive()` API,
+which handles tar and zip generation natively.
+
+### Compression Pipeline
+
+For compressed formats, the archive data is piped through compression:
+
+```c
+static int write_tar_gzip_archive(/* ... */)
+{
+ /* pipe tar output through gzip compression */
+}
+
+static int write_tar_bzip2_archive(/* ... */)
+{
+ /* pipe tar output through bzip2 compression */
+}
+
+static int write_tar_xz_archive(/* ... */)
+{
+ /* pipe tar output through xz compression */
+}
+
+static int write_tar_zstd_archive(/* ... */)
+{
+ /* pipe tar output through zstd compression */
+}
+
+static int write_tar_lzip_archive(/* ... */)
+{
+ /* pipe tar output through lzip compression */
+}
+```
+
+## HTTP Response
+
+Snapshot responses include:
+
+```
+Content-Type: application/x-gzip
+Content-Disposition: inline; filename="myrepo-v1.0.tar.gz"
+```
+
+The `Content-Disposition` header triggers a file download in browsers with
+the correct filename.
+
+## Snapshot Links
+
+Snapshot links on repository pages are generated by `ui-shared.c`:
+
+```c
+void cgit_print_snapshot_links(const char *repo, const char *head,
+ const char *hex, int snapshots)
+{
+ for (f = cgit_snapshot_formats; f->suffix; f++) {
+ if (!(snapshots & f->bit))
+ continue;
+ /* generate link: repo/snapshot/prefix-ref.suffix */
+ }
+}
+```
+
+These links appear on the summary page and optionally in the log view.
+
+## Snapshot Prefix
+
+The archive prefix (directory name inside the archive) is determined by:
+
+1. `repo.snapshot-prefix` if set
+2. Otherwise, the repository basename
+
+For a request like `myrepo-v1.0.tar.gz`, the archive contains files under
+`myrepo-v1.0/`.
+
+## Signature Detection
+
+cgit can detect and display signature files alongside snapshots. When a
+file matching `<snapshot-name>.asc` or `<snapshot-name>.sig` exists in the
+repository, a signature link is shown next to the snapshot download.
+
+## Configuration
+
+| Directive | Default | Description |
+|-----------|---------|-------------|
+| `snapshots` | (none) | Space-separated list of enabled suffixes |
+| `repo.snapshots` | (inherited) | Per-repo override |
+| `repo.snapshot-prefix` | (basename) | Per-repo archive prefix |
+| `cache-snapshot-ttl` | 5 min | Cache TTL for snapshot pages |
+
+### Enabling Snapshots
+
+```ini
+# Global: enable tar.gz and zip for all repos
+snapshots=tar.gz zip
+
+# Per-repo: enable all formats
+repo.url=myrepo
+repo.snapshots=all
+
+# Per-repo: disable snapshots
+repo.url=internal-tools
+repo.snapshots=
+```
+
+## Security Considerations
+
+- Snapshots are generated on-the-fly from git objects, so they always reflect
+ the repository's current state
+- Large repositories can produce large archives — consider enabling caching
+ and setting appropriate `max-blob-size` limits
+- Snapshot requests for non-existent refs return a 404 error page
+- The snapshot filename is sanitized to prevent path traversal
diff --git a/docs/handbook/cgit/testing.md b/docs/handbook/cgit/testing.md
new file mode 100644
index 0000000000..ee7b5979f9
--- /dev/null
+++ b/docs/handbook/cgit/testing.md
@@ -0,0 +1,335 @@
+# cgit — Testing
+
+## Overview
+
+cgit has a shell-based test suite in the `tests/` directory. Tests use
+Git's own test framework (`test-lib.sh`) and exercise cgit by invoking the
+CGI binary with simulated HTTP requests.
+
+Source location: `cgit/tests/`.
+
+## Test Framework
+
+The test harness is built on Git's `test-lib.sh`, sourced from the vendored
+Git tree at `git/t/test-lib.sh`. This provides:
+
+- TAP-compatible output
+- Test assertions (`test_expect_success`, `test_expect_failure`)
+- Temporary directory management (`trash` directories)
+- Color-coded pass/fail reporting
+
+### `setup.sh`
+
+All test scripts source `tests/setup.sh`, which provides:
+
+```bash
+# Core test helpers
+prepare_tests() # Create repos and config file
+run_test() # Execute a single test case
+cgit_query() # Invoke cgit with a query string
+cgit_url() # Invoke cgit with a virtual URL
+strip_headers() # Remove HTTP headers from CGI output
+```
+
+### Invoking cgit
+
+Tests invoke cgit as a CGI binary by setting environment variables:
+
+```bash
+cgit_query()
+{
+ CGIT_CONFIG="$PWD/cgitrc" QUERY_STRING="$1" cgit
+}
+
+cgit_url()
+{
+ CGIT_CONFIG="$PWD/cgitrc" QUERY_STRING="url=$1" cgit
+}
+```
+
+The `cgit` binary is on PATH (prepended by setup.sh). The response includes
+HTTP headers followed by HTML content. `strip_headers()` removes the
+headers for content-only assertions.
+
+## Test Repository Setup
+
+`setup_repos()` creates test repositories:
+
+```bash
+setup_repos()
+{
+ rm -rf cache
+ mkdir -p cache
+ mkrepo repos/foo 5 # 5 commits
+ mkrepo repos/bar 50 commit-graph # 50 commits with commit-graph
+ mkrepo repos/foo+bar 10 testplus # 10 commits + special chars
+ mkrepo "repos/with space" 2 # repo with spaces in name
+ mkrepo repos/filter 5 testplus # for filter tests
+}
+```
+
+### `mkrepo()`
+
+```bash
+mkrepo() {
+ name=$1
+ count=$2
+ test_create_repo "$name"
+ (
+ cd "$name"
+ n=1
+ while test $n -le $count; do
+ echo $n >file-$n
+ git add file-$n
+ git commit -m "commit $n"
+ n=$(expr $n + 1)
+ done
+ case "$3" in
+ testplus)
+ echo "hello" >a+b
+ git add a+b
+ git commit -m "add a+b"
+ git branch "1+2"
+ ;;
+ commit-graph)
+ git commit-graph write
+ ;;
+ esac
+ )
+}
+```
+
+### Test Configuration
+
+A `cgitrc` file is generated in the test directory with:
+
+```ini
+virtual-root=/
+cache-root=$PWD/cache
+cache-size=1021
+snapshots=tar.gz tar.bz tar.lz tar.xz tar.zst zip
+enable-log-filecount=1
+enable-log-linecount=1
+summary-log=5
+summary-branches=5
+summary-tags=5
+clone-url=git://example.org/$CGIT_REPO_URL.git
+enable-filter-overrides=1
+root-coc=$PWD/site-coc.txt
+root-cla=$PWD/site-cla.txt
+root-homepage=https://projecttick.org
+root-homepage-title=Project Tick
+root-link=GitHub|https://github.com/example
+root-link=GitLab|https://gitlab.com/example
+root-link=Codeberg|https://codeberg.org/example
+
+repo.url=foo
+repo.path=$PWD/repos/foo/.git
+
+repo.url=bar
+repo.path=$PWD/repos/bar/.git
+repo.desc=the bar repo
+
+repo.url=foo+bar
+repo.path=$PWD/repos/foo+bar/.git
+repo.desc=the foo+bar repo
+# ...
+```
+
+## Test Scripts
+
+### Test File Naming
+
+Tests follow the convention `tNNNN-description.sh`:
+
+| Test | Description |
+|------|-------------|
+| `t0001-validate-git-versions.sh` | Verify Git version compatibility |
+| `t0010-validate-html.sh` | Validate HTML output |
+| `t0020-validate-cache.sh` | Test cache system |
+| `t0101-index.sh` | Repository index page |
+| `t0102-summary.sh` | Repository summary page |
+| `t0103-log.sh` | Log view |
+| `t0104-tree.sh` | Tree view |
+| `t0105-commit.sh` | Commit view |
+| `t0106-diff.sh` | Diff view |
+| `t0107-snapshot.sh` | Snapshot downloads |
+| `t0108-patch.sh` | Patch view |
+| `t0109-gitconfig.sh` | Git config integration |
+| `t0110-rawdiff.sh` | Raw diff output |
+| `t0111-filter.sh` | Filter system |
+| `t0112-coc.sh` | Code of Conduct page |
+| `t0113-cla.sh` | CLA page |
+| `t0114-root-homepage.sh` | Root homepage links |
+
+### Number Ranges
+
+| Range | Category |
+|-------|----------|
+| `t0001-t0099` | Infrastructure/validation tests |
+| `t0100-t0199` | Feature tests |
+
+## Running Tests
+
+### All Tests
+
+```bash
+cd cgit/tests
+make
+```
+
+The Makefile discovers all `t*.sh` files and runs them:
+
+```makefile
+T = $(wildcard t[0-9][0-9][0-9][0-9]-*.sh)
+
+all: $(T)
+
+$(T):
+ @'$(SHELL_PATH_SQ)' $@ $(CGIT_TEST_OPTS)
+```
+
+### Individual Tests
+
+```bash
+# Run a single test
+./t0101-index.sh
+
+# With verbose output
+./t0101-index.sh -v
+
+# With Valgrind
+./t0101-index.sh --valgrind
+```
+
+### Test Options
+
+Options are passed via `CGIT_TEST_OPTS` or command-line arguments:
+
+| Option | Description |
+|--------|-------------|
+| `-v`, `--verbose` | Show test details |
+| `--valgrind` | Run cgit under Valgrind |
+| `--debug` | Show shell trace |
+
+### Valgrind Support
+
+`setup.sh` intercepts the `--valgrind` flag and configures Valgrind
+instrumentation via a wrapper script in `tests/valgrind/`:
+
+```bash
+if test -n "$cgit_valgrind"; then
+ GIT_VALGRIND="$TEST_DIRECTORY/valgrind"
+ CGIT_VALGRIND=$(cd ../valgrind && pwd)
+ PATH="$CGIT_VALGRIND/bin:$PATH"
+fi
+```
+
+## Test Patterns
+
+### HTML Content Assertion
+
+```bash
+run_test 'repo index contains foo' '
+ cgit_url "/" | strip_headers | grep -q "foo"
+'
+```
+
+### HTTP Header Assertion
+
+```bash
+run_test 'content type is text/html' '
+ cgit_url "/" | head -1 | grep -q "Content-Type: text/html"
+'
+```
+
+### Snapshot Download
+
+```bash
+run_test 'snapshot is valid tar.gz' '
+ cgit_url "/foo/snapshot/foo-master.tar.gz" | strip_headers | \
+ gunzip | tar tf - >/dev/null
+'
+```
+
+### Negative Assertion
+
+```bash
+run_test 'no 404 on valid repo' '
+ ! cgit_url "/foo" | grep -q "404"
+'
+```
+
+### Lua Filter Conditional
+
+```bash
+if [ $CGIT_HAS_LUA -eq 1 ]; then
+ run_test 'lua filter works' '
+ cgit_url "/filter-lua/about/" | strip_headers | grep -q "filtered"
+ '
+fi
+```
+
+## Test Filter Scripts
+
+The `tests/filters/` directory contains simple filter scripts for testing:
+
+### `dump.sh`
+
+A passthrough filter that copies stdin to stdout, used to verify filter
+invocation:
+
+```bash
+#!/bin/sh
+cat
+```
+
+### `dump.lua`
+
+Lua equivalent of the dump filter:
+
+```lua
+function filter_open(...)
+end
+
+function write(str)
+ html(str)
+end
+
+function filter_close()
+ return 0
+end
+```
+
+## Cleanup
+
+```bash
+cd cgit/tests
+make clean
+```
+
+Removes the `trash` directories created by tests.
+
+## Writing New Tests
+
+1. Create a new file `tNNNN-description.sh`
+2. Source `setup.sh` and call `prepare_tests`:
+
+```bash
+#!/bin/sh
+. ./setup.sh
+prepare_tests "my new feature"
+
+run_test 'description of test case' '
+ cgit_url "/foo/my-page/" | strip_headers | grep -q "expected"
+'
+```
+
+3. Make it executable: `chmod +x tNNNN-description.sh`
+4. Run: `./tNNNN-description.sh -v`
+
+## CI Integration
+
+Tests are run as part of the CI pipeline. The `ci/` directory contains
+Nix-based CI configuration that builds cgit and runs the test suite in a
+reproducible environment.
diff --git a/docs/handbook/cgit/ui-modules.md b/docs/handbook/cgit/ui-modules.md
new file mode 100644
index 0000000000..b03a437a35
--- /dev/null
+++ b/docs/handbook/cgit/ui-modules.md
@@ -0,0 +1,544 @@
+# cgit — UI Modules
+
+## Overview
+
+cgit's user interface is implemented as a collection of `ui-*.c` modules,
+each responsible for rendering a specific page type. All modules share
+common infrastructure from `ui-shared.c` and `html.c`.
+
+## Module Map
+
+| Module | Page | Entry Function |
+|--------|------|---------------|
+| `ui-repolist.c` | Repository index | `cgit_print_repolist()` |
+| `ui-summary.c` | Repository summary | `cgit_print_summary()` |
+| `ui-log.c` | Commit log | `cgit_print_log()` |
+| `ui-tree.c` | File/directory tree | `cgit_print_tree()` |
+| `ui-blob.c` | File content | `cgit_print_blob()` |
+| `ui-commit.c` | Commit details | `cgit_print_commit()` |
+| `ui-diff.c` | Diff view | `cgit_print_diff()` |
+| `ui-ssdiff.c` | Side-by-side diff | `cgit_ssdiff_*()` |
+| `ui-patch.c` | Patch output | `cgit_print_patch()` |
+| `ui-refs.c` | Branch/tag listing | `cgit_print_refs()` |
+| `ui-tag.c` | Tag details | `cgit_print_tag()` |
+| `ui-stats.c` | Statistics | `cgit_print_stats()` |
+| `ui-atom.c` | Atom feed | `cgit_print_atom()` |
+| `ui-plain.c` | Raw file serving | `cgit_print_plain()` |
+| `ui-blame.c` | Blame view | `cgit_print_blame()` |
+| `ui-clone.c` | HTTP clone | `cgit_clone_info/objects/head()` |
+| `ui-snapshot.c` | Archive download | `cgit_print_snapshot()` |
+| `ui-shared.c` | Common layout | (shared functions) |
+
+## `ui-repolist.c` — Repository Index
+
+Renders the main page listing all configured repositories.
+
+### Functions
+
+```c
+void cgit_print_repolist(void)
+```
+
+### Features
+
+- Sortable columns: Name, Description, Owner, Idle (age)
+- Section grouping (based on `repo.section` or `section-from-path`)
+- Pagination with configurable `max-repo-count`
+- Age calculation via `read_agefile()` or ref modification time
+- Optional filter by search query
+
+### Sorting
+
+```c
+static int cmp_name(const void *a, const void *b);
+static int cmp_section(const void *a, const void *b);
+static int cmp_idle(const void *a, const void *b);
+```
+
+Sort field is selected by the `s` query parameter or `repository-sort`
+directive.
+
+### Age File Resolution
+
+```c
+static time_t read_agefile(const char *path)
+{
+ /* Try reading date from agefile content */
+ /* Fall back to file mtime */
+ /* Fall back to refs/ dir mtime */
+}
+```
+
+### Pagination
+
+```c
+static void print_pager(int items, int pagelen, char *search, char *sort)
+{
+ /* Render page navigation links */
+ /* [prev] 1 2 3 4 5 [next] */
+}
+```
+
+## `ui-summary.c` — Repository Summary
+
+Renders the overview page for a single repository.
+
+### Functions
+
+```c
+void cgit_print_summary(void)
+```
+
+### Content
+
+- Repository metadata table (description, owner, homepage, clone URLs)
+- SPDX license detection from `LICENSES/` directory
+- CODEOWNERS and MAINTAINERS file detection
+- Badges display
+- Branch listing (limited by `summary-branches`)
+- Tag listing (limited by `summary-tags`)
+- Recent commits (limited by `summary-log`)
+- Snapshot download links
+- README rendering (via about-filter)
+
+### License Detection
+
+```c
+/* Scan for SPDX license identifiers */
+/* Check LICENSES/ directory for .txt files */
+/* Extract license names from filenames */
+```
+
+### README Priority
+
+README files are tried in order of `repo.readme` entries:
+
+1. `ref:README.md` — tracked file in a specific ref
+2. `:README.md` — tracked file in HEAD
+3. `/path/to/README.md` — file on disk
+
+## `ui-log.c` — Commit Log
+
+Renders a paginated list of commits.
+
+### Functions
+
+```c
+void cgit_print_log(const char *tip, int ofs, int cnt,
+ char *grep, char *pattern, char *path,
+ int pager, int commit_graph, int commit_sort)
+```
+
+### Features
+
+- Commit graph visualization (ASCII art)
+- File change count per commit (when `enable-log-filecount=1`)
+- Line count per commit (when `enable-log-linecount=1`)
+- Grep/search within commit messages
+- Path filtering (show commits affecting a specific path)
+- Follow renames (when `enable-follow-links=1`)
+- Pagination with next/prev links
+
+### Commit Graph Colors
+
+```c
+static const char *column_colors_html[] = {
+ "<span class='column1'>",
+ "<span class='column2'>",
+ "<span class='column3'>",
+ "<span class='column4'>",
+ "<span class='column5'>",
+ "<span class='column6'>",
+};
+```
+
+### Decorations
+
+```c
+static void show_commit_decorations(struct commit *commit)
+{
+ /* Display branch/tag labels next to commits */
+ /* Uses git's decoration API */
+}
+```
+
+## `ui-tree.c` — Tree View
+
+Renders directory listings and file contents.
+
+### Functions
+
+```c
+void cgit_print_tree(const char *rev, char *path)
+```
+
+### Directory Listing
+
+For each entry in a tree object:
+
+```c
+/* For each tree entry */
+switch (entry->mode) {
+ case S_IFDIR: /* directory → link to subtree */
+ case S_IFREG: /* regular file → link to blob */
+ case S_IFLNK: /* symlink → show target */
+ case S_IFGITLINK: /* submodule → link to submodule */
+}
+```
+
+### File Display
+
+```c
+static void print_text_buffer(const char *name, char *buf,
+ unsigned long size)
+{
+ /* Show file content with line numbers */
+ /* Apply source filter if configured */
+}
+
+static void print_binary_buffer(char *buf, unsigned long size)
+{
+ /* Show "Binary file (N bytes)" message */
+}
+```
+
+### Walk Tree Context
+
+```c
+struct walk_tree_context {
+ char *curr_rev;
+ char *match_path;
+ int state; /* 0=searching, 1=found, 2=printed */
+};
+```
+
+The tree walker recursively descends into subdirectories to find the
+requested path.
+
+## `ui-blob.c` — Blob View
+
+Displays individual file content or serves raw file data.
+
+### Functions
+
+```c
+void cgit_print_blob(const char *hex, char *path,
+ const char *head, int file_only)
+int cgit_ref_path_exists(const char *path, const char *ref, int file_only)
+char *cgit_ref_read_file(const char *path, const char *ref,
+ unsigned long *size)
+```
+
+### MIME Detection
+
+When serving raw content, MIME types are detected from:
+1. The `mimetype.<ext>` configuration directives
+2. The `mimetype-file` (Apache-style mime.types)
+3. Default: `application/octet-stream`
+
+## `ui-commit.c` — Commit View
+
+Displays full commit details.
+
+### Functions
+
+```c
+void cgit_print_commit(const char *rev, const char *prefix)
+```
+
+### Content
+
+- Author and committer info (name, email, date)
+- Commit subject and full message
+- Parent commit links
+- Git notes
+- Commit decorations (branches, tags)
+- Diffstat
+- Full diff (unified or side-by-side)
+
+### Notes Display
+
+```c
+/* Check for git notes */
+struct strbuf notes = STRBUF_INIT;
+format_display_notes(&commit->object.oid, &notes, ...);
+if (notes.len) {
+ html("<div class='notes-header'>Notes</div>");
+ html("<div class='notes'>");
+ html_txt(notes.buf);
+ html("</div>");
+}
+```
+
+## `ui-diff.c` — Diff View
+
+Renders diffs between commits or trees.
+
+### Functions
+
+```c
+void cgit_print_diff(const char *new_rev, const char *old_rev,
+ const char *prefix, int show_ctrls, int raw)
+void cgit_print_diffstat(const struct object_id *old,
+ const struct object_id *new,
+ const char *prefix)
+```
+
+See [diff-engine.md](diff-engine.md) for detailed documentation.
+
+## `ui-ssdiff.c` — Side-by-Side Diff
+
+Renders two-column diff view with character-level highlighting.
+
+### Functions
+
+```c
+void cgit_ssdiff_header_begin(void)
+void cgit_ssdiff_header_end(void)
+void cgit_ssdiff_footer(void)
+```
+
+See [diff-engine.md](diff-engine.md) for LCS algorithm details.
+
+## `ui-patch.c` — Patch Output
+
+Generates a downloadable patch file.
+
+### Functions
+
+```c
+void cgit_print_patch(const char *new_rev, const char *old_rev,
+ const char *prefix)
+```
+
+Output is `text/plain` content suitable for `git apply`. Uses Git's
+`rev_info` and `log_tree_commit` to generate the patch.
+
+## `ui-refs.c` — References View
+
+Displays branches and tags with sorting.
+
+### Functions
+
+```c
+void cgit_print_refs(void)
+void cgit_print_branches(int max)
+void cgit_print_tags(int max)
+```
+
+### Branch Display
+
+Each branch row shows:
+- Branch name (link to log)
+- Idle time
+- Author of last commit
+
+### Tag Display
+
+Each tag row shows:
+- Tag name (link to tag)
+- Idle time
+- Author/tagger
+- Download links (if snapshots enabled)
+
+### Sorting
+
+```c
+static int cmp_branch_age(const void *a, const void *b);
+static int cmp_tag_age(const void *a, const void *b);
+static int cmp_branch_name(const void *a, const void *b);
+static int cmp_tag_name(const void *a, const void *b);
+```
+
+Sort order is controlled by `branch-sort` (0=name, 1=age).
+
+## `ui-tag.c` — Tag View
+
+Displays details of a specific tag.
+
+### Functions
+
+```c
+void cgit_print_tag(const char *revname)
+```
+
+### Content
+
+For annotated tags:
+- Tagger name and date
+- Tag message
+- Tagged object link
+
+For lightweight tags:
+- Redirects to the tagged object (commit, tree, or blob)
+
+## `ui-stats.c` — Statistics View
+
+Displays contributor statistics by period.
+
+### Functions
+
+```c
+void cgit_print_stats(void)
+```
+
+### Periods
+
+```c
+struct cgit_period {
+ const char *name; /* "week", "month", "quarter", "year" */
+ int max_periods;
+ int count;
+ /* accessor functions for period boundaries */
+};
+```
+
+### Data Collection
+
+```c
+static void collect_stats(struct cgit_period *period)
+{
+ /* Walk commit log */
+ /* Group commits by author and period */
+ /* Count additions/deletions per period */
+}
+```
+
+### Output
+
+- Bar chart showing commits per period
+- Author ranking table
+- Sortable by commit count
+
+## `ui-atom.c` — Atom Feed
+
+Generates an Atom XML feed.
+
+### Functions
+
+```c
+void cgit_print_atom(char *tip, char *path, int max)
+```
+
+### Output
+
+```xml
+<?xml version='1.0' encoding='utf-8'?>
+<feed xmlns='http://www.w3.org/2005/Atom'>
+ <title>repo - log</title>
+ <updated>2024-01-01T00:00:00Z</updated>
+ <entry>
+ <title>commit subject</title>
+ <updated>2024-01-01T00:00:00Z</updated>
+ <author><name>Alice</name><email>alice@example.com</email></author>
+ <id>urn:sha1:abc123</id>
+ <link href='commit URL'/>
+ <content type='text'>commit message</content>
+ </entry>
+</feed>
+```
+
+Limited by `max-atom-items` (default 10).
+
+## `ui-plain.c` — Raw File Serving
+
+Serves file content with proper MIME types.
+
+### Functions
+
+```c
+void cgit_print_plain(void)
+```
+
+### Features
+
+- MIME type detection by file extension
+- Directory listing (HTML) when path is a tree
+- Binary file serving with correct Content-Type
+- Security: HTML serving gated by `enable-html-serving`
+
+### Security
+
+When `enable-html-serving=0` (default), HTML files are served as
+`text/plain` to prevent XSS.
+
+## `ui-blame.c` — Blame View
+
+Displays line-by-line blame information.
+
+### Functions
+
+```c
+void cgit_print_blame(void)
+```
+
+### Implementation
+
+Uses Git's `blame_scoreboard` API:
+
+```c
+/* Set up blame scoreboard */
+/* Walk file history */
+/* For each line, emit: commit hash, author, line content */
+```
+
+### Output
+
+Each line shows:
+- Abbreviated commit hash (linked to commit view)
+- Line number
+- File content
+
+Requires `enable-blame=1`.
+
+## `ui-clone.c` — HTTP Clone Endpoints
+
+Serves the smart HTTP clone protocol.
+
+### Functions
+
+```c
+void cgit_clone_info(void) /* GET info/refs */
+void cgit_clone_objects(void) /* GET objects/* */
+void cgit_clone_head(void) /* GET HEAD */
+```
+
+### `cgit_clone_info()`
+
+Enumerates all refs and their SHA-1 hashes:
+
+```c
+static void print_ref_info(const char *refname,
+ const struct object_id *oid, ...)
+{
+ /* Output: sha1\trefname\n */
+}
+```
+
+### `cgit_clone_objects()`
+
+Serves loose objects and pack files from the object store.
+
+### `cgit_clone_head()`
+
+Returns the symbolic HEAD reference.
+
+Requires `enable-http-clone=1` (default).
+
+## `ui-snapshot.c` — Archive Downloads
+
+See [snapshot-system.md](snapshot-system.md) for detailed documentation.
+
+## `ui-shared.c` — Common Infrastructure
+
+Provides shared layout and link generation used by all modules.
+
+See [html-rendering.md](html-rendering.md) for detailed documentation.
+
+### Key Functions
+
+- Page skeleton: `cgit_print_docstart()`, `cgit_print_pageheader()`,
+ `cgit_print_docend()`
+- Links: `cgit_commit_link()`, `cgit_tree_link()`, `cgit_log_link()`, etc.
+- URLs: `cgit_repourl()`, `cgit_fileurl()`, `cgit_pageurl()`
+- Errors: `cgit_print_error_page()`
diff --git a/docs/handbook/cgit/url-routing.md b/docs/handbook/cgit/url-routing.md
new file mode 100644
index 0000000000..0adb3b7fc5
--- /dev/null
+++ b/docs/handbook/cgit/url-routing.md
@@ -0,0 +1,331 @@
+# cgit — URL Routing and Request Dispatch
+
+## Overview
+
+cgit supports two URL schemes: virtual-root (path-based) and query-string.
+Incoming requests are parsed into a `cgit_query` structure and dispatched to
+one of 23 command handlers via a function pointer table.
+
+Source files: `cgit.c` (querystring parsing, routing), `parsing.c`
+(`cgit_parse_url`), `cmd.c` (command table).
+
+## URL Schemes
+
+### Virtual Root (Path-Based)
+
+When `virtual-root` is configured, URLs use clean paths:
+
+```
+/cgit/ → repository list
+/cgit/repo.git/ → summary
+/cgit/repo.git/log/ → log (default branch)
+/cgit/repo.git/log/main/path → log for path on branch main
+/cgit/repo.git/tree/v1.0/src/ → tree view at tag v1.0
+/cgit/repo.git/commit/?id=abc → commit view
+```
+
+The path after the virtual root is passed in `PATH_INFO` and parsed by
+`cgit_parse_url()`.
+
+### Query-String (CGI)
+
+Without virtual root, all parameters are passed in the query string:
+
+```
+/cgit.cgi?url=repo.git/log/main/path&ofs=50
+```
+
+## Query Structure
+
+All parsed parameters are stored in `ctx.qry`:
+
+```c
+struct cgit_query {
+ char *raw; /* raw URL / PATH_INFO */
+ char *repo; /* repository URL */
+ char *page; /* page/command name */
+ char *search; /* search string */
+ char *grep; /* grep pattern */
+ char *head; /* branch reference */
+ char *sha1; /* object SHA-1 */
+ char *sha2; /* second SHA-1 (for diffs) */
+ char *path; /* file/dir path within repo */
+ char *name; /* snapshot name / ref name */
+ char *url; /* combined URL path */
+ char *mimetype; /* requested MIME type */
+ char *etag; /* ETag from client */
+ int nohead; /* suppress header */
+ int ofs; /* pagination offset */
+ int has_symref; /* path contains a symbolic ref */
+ int has_sha1; /* explicit SHA was given */
+ int has_dot; /* path contains '..' */
+ int ignored; /* request should be ignored */
+ char *sort; /* sort field */
+ int showmsg; /* show full commit message */
+ int ssdiff; /* side-by-side diff */
+ int show_all; /* show all items */
+ int context; /* diff context lines */
+ int follow; /* follow renames */
+ int log_hierarchical_threading;
+};
+```
+
+## URL Parsing: `cgit_parse_url()`
+
+In `parsing.c`, the URL is decomposed into repo, page, and path:
+
+```c
+void cgit_parse_url(const char *url)
+{
+ /* Step 1: try progressively longer prefixes as repo URLs */
+ /* For each '/' in the URL, check if the prefix matches a repo */
+
+ for (p = strchr(url, '/'); p; p = strchr(p + 1, '/')) {
+ *p = '\0';
+ repo = cgit_get_repoinfo(url);
+ *p = '/';
+ if (repo) {
+ ctx.qry.repo = xstrdup(url_prefix);
+ ctx.repo = repo;
+ url = p + 1; /* remaining part */
+ break;
+ }
+ }
+ /* if no '/' found, try the whole URL as a repo name */
+
+ /* Step 2: parse the remaining path as page/ref/path */
+ /* e.g., "log/main/src/file.c" → page="log", path="main/src/file.c" */
+ p = strchr(url, '/');
+ if (p) {
+ ctx.qry.page = xstrndup(url, p - url);
+ ctx.qry.path = trim_end(p + 1, '/');
+ } else if (*url) {
+ ctx.qry.page = xstrdup(url);
+ }
+}
+```
+
+## Query String Parsing: `querystring_cb()`
+
+HTTP query parameters and POST form data are decoded by `querystring_cb()`
+in `cgit.c`. The function maps URL parameter names to `ctx.qry` fields:
+
+```c
+static void querystring_cb(const char *name, const char *value)
+{
+ if (!strcmp(name, "url")) ctx.qry.url = xstrdup(value);
+ else if (!strcmp(name, "p")) ctx.qry.page = xstrdup(value);
+ else if (!strcmp(name, "q")) ctx.qry.search = xstrdup(value);
+ else if (!strcmp(name, "h")) ctx.qry.head = xstrdup(value);
+ else if (!strcmp(name, "id")) ctx.qry.sha1 = xstrdup(value);
+ else if (!strcmp(name, "id2")) ctx.qry.sha2 = xstrdup(value);
+ else if (!strcmp(name, "ofs")) ctx.qry.ofs = atoi(value);
+ else if (!strcmp(name, "path")) ctx.qry.path = xstrdup(value);
+ else if (!strcmp(name, "name")) ctx.qry.name = xstrdup(value);
+ else if (!strcmp(name, "mimetype")) ctx.qry.mimetype = xstrdup(value);
+ else if (!strcmp(name, "s")) ctx.qry.sort = xstrdup(value);
+ else if (!strcmp(name, "showmsg")) ctx.qry.showmsg = atoi(value);
+ else if (!strcmp(name, "ss")) ctx.qry.ssdiff = atoi(value);
+ else if (!strcmp(name, "all")) ctx.qry.show_all = atoi(value);
+ else if (!strcmp(name, "context")) ctx.qry.context = atoi(value);
+ else if (!strcmp(name, "follow")) ctx.qry.follow = atoi(value);
+ else if (!strcmp(name, "dt")) ctx.qry.dt = atoi(value);
+ else if (!strcmp(name, "grep")) ctx.qry.grep = xstrdup(value);
+ else if (!strcmp(name, "etag")) ctx.qry.etag = xstrdup(value);
+}
+```
+
+### URL Parameter Reference
+
+| Parameter | Query Field | Type | Description |
+|-----------|------------|------|-------------|
+| `url` | `qry.url` | string | Full URL path (repo/page/path) |
+| `p` | `qry.page` | string | Page/command name |
+| `q` | `qry.search` | string | Search string |
+| `h` | `qry.head` | string | Branch/ref name |
+| `id` | `qry.sha1` | string | Object SHA-1 |
+| `id2` | `qry.sha2` | string | Second SHA-1 (diffs) |
+| `ofs` | `qry.ofs` | int | Pagination offset |
+| `path` | `qry.path` | string | File path in repo |
+| `name` | `qry.name` | string | Reference/snapshot name |
+| `mimetype` | `qry.mimetype` | string | MIME type override |
+| `s` | `qry.sort` | string | Sort field |
+| `showmsg` | `qry.showmsg` | int | Show full commit message |
+| `ss` | `qry.ssdiff` | int | Side-by-side diff toggle |
+| `all` | `qry.show_all` | int | Show all entries |
+| `context` | `qry.context` | int | Diff context lines |
+| `follow` | `qry.follow` | int | Follow renames in log |
+| `dt` | `qry.dt` | int | Diff type |
+| `grep` | `qry.grep` | string | Grep pattern for log search |
+| `etag` | `qry.etag` | string | ETag for conditional requests |
+
+## Command Dispatch Table
+
+The command table in `cmd.c` maps page names to handler functions:
+
+```c
+#define def_cmd(name, want_hierarchical, want_repo, want_layout, want_vpath, is_clone) \
+ {#name, cmd_##name, want_hierarchical, want_repo, want_layout, want_vpath, is_clone}
+
+static struct cgit_cmd cmds[] = {
+ def_cmd(atom, 1, 1, 0, 0, 0),
+ def_cmd(about, 0, 1, 1, 0, 0),
+ def_cmd(blame, 1, 1, 1, 1, 0),
+ def_cmd(blob, 1, 1, 0, 0, 0),
+ def_cmd(commit, 1, 1, 1, 1, 0),
+ def_cmd(diff, 1, 1, 1, 1, 0),
+ def_cmd(head, 1, 1, 0, 0, 1),
+ def_cmd(info, 1, 1, 0, 0, 1),
+ def_cmd(log, 1, 1, 1, 1, 0),
+ def_cmd(ls_cache,0, 0, 0, 0, 0),
+ def_cmd(objects, 1, 1, 0, 0, 1),
+ def_cmd(patch, 1, 1, 1, 1, 0),
+ def_cmd(plain, 1, 1, 0, 1, 0),
+ def_cmd(rawdiff, 1, 1, 0, 1, 0),
+ def_cmd(refs, 1, 1, 1, 0, 0),
+ def_cmd(repolist,0, 0, 1, 0, 0),
+ def_cmd(snapshot, 1, 1, 0, 0, 0),
+ def_cmd(stats, 1, 1, 1, 1, 0),
+ def_cmd(summary, 1, 1, 1, 0, 0),
+ def_cmd(tag, 1, 1, 1, 0, 0),
+ def_cmd(tree, 1, 1, 1, 1, 0),
+};
+```
+
+### Command Flags
+
+| Flag | Meaning |
+|------|---------|
+| `want_hierarchical` | Parse hierarchical path from URL |
+| `want_repo` | Requires a repository context |
+| `want_layout` | Render within HTML page layout |
+| `want_vpath` | Accept a virtual path (file path in repo) |
+| `is_clone` | HTTP clone protocol endpoint |
+
+### Lookup: `cgit_get_cmd()`
+
+```c
+struct cgit_cmd *cgit_get_cmd(const char *name)
+{
+ for (int i = 0; i < ARRAY_SIZE(cmds); i++)
+ if (!strcmp(cmds[i].name, name))
+ return &cmds[i];
+ return NULL;
+}
+```
+
+The function performs a linear search. With 21 entries, this is fast enough.
+
+## Request Processing Flow
+
+In `process_request()` (`cgit.c`):
+
+```
+1. Parse PATH_INFO via cgit_parse_url()
+2. Parse QUERY_STRING via http_parse_querystring(querystring_cb)
+3. Parse POST body (for authentication forms)
+4. Resolve repository: cgit_get_repoinfo(ctx.qry.repo)
+5. Determine command: cgit_get_cmd(ctx.qry.page)
+6. If no page specified:
+ - With repo → default to "summary"
+ - Without repo → default to "repolist"
+7. Check command flags:
+ - want_repo but no repo → "Repository not found" error
+ - is_clone and HTTP clone disabled → 404
+8. Handle authentication if auth-filter is configured
+9. Execute: cmd->fn(&ctx)
+```
+
+### Hierarchical Path Resolution
+
+When `want_hierarchical=1`, cgit splits `ctx.qry.path` into a reference
+(branch/tag/SHA) and a file path. It tries progressively longer prefixes
+of the path as git references until one resolves:
+
+```
+path = "main/src/lib/file.c"
+try: "main" → found branch "main"
+ qry.head = "main"
+ qry.path = "src/lib/file.c"
+```
+
+If no prefix resolves, the entire path is treated as a file path within the
+default branch.
+
+## Clone Protocol Endpoints
+
+Three commands serve the Git HTTP clone protocol:
+
+| Endpoint | Path | Function |
+|----------|------|----------|
+| `info` | `repo/info/refs` | `cgit_clone_info()` — advertise refs |
+| `objects` | `repo/objects/*` | `cgit_clone_objects()` — serve packfiles |
+| `head` | `repo/HEAD` | `cgit_clone_head()` — serve HEAD ref |
+
+These are only active when `enable-http-clone=1` (default).
+
+## URL Generation
+
+`ui-shared.c` provides URL construction helpers:
+
+```c
+const char *cgit_repourl(const char *reponame);
+const char *cgit_fileurl(const char *reponame, const char *pagename,
+ const char *filename, const char *query);
+const char *cgit_pageurl(const char *reponame, const char *pagename,
+ const char *query);
+const char *cgit_currurl(void);
+```
+
+When `virtual-root` is set, these produce clean paths. Otherwise, they
+produce query-string URLs.
+
+### Example URL generation:
+
+```c
+/* With virtual-root=/cgit/ */
+cgit_repourl("myrepo")
+ → "/cgit/myrepo/"
+
+cgit_fileurl("myrepo", "tree", "src/main.c", "h=dev")
+ → "/cgit/myrepo/tree/src/main.c?h=dev"
+
+cgit_pageurl("myrepo", "log", "ofs=50")
+ → "/cgit/myrepo/log/?ofs=50"
+```
+
+## Content-Type and HTTP Headers
+
+The response content type is set by the command handler before generating
+output. Common types:
+
+| Page | Content-Type |
+|------|-------------|
+| HTML pages | `text/html` |
+| atom | `text/xml` |
+| blob | auto-detected from content |
+| plain | MIME type from extension or `application/octet-stream` |
+| snapshot | `application/x-gzip`, etc. |
+| patch | `text/plain` |
+| clone endpoints | `text/plain`, `application/x-git-packed-objects` |
+
+Headers are emitted by `cgit_print_http_headers()` in `ui-shared.c` before
+any page content.
+
+## Error Handling
+
+If a requested repository or page is not found, cgit renders an error page
+within the standard layout. HTTP status codes:
+
+| Condition | Status |
+|-----------|--------|
+| Normal page | 200 OK |
+| Auth redirect | 302 Found |
+| Not modified | 304 Not Modified |
+| Bad request | 400 Bad Request |
+| Auth required | 401 Unauthorized |
+| Repo not found | 404 Not Found |
+| Page not found | 404 Not Found |
+
+The status code is set in `ctx.page.status` and emitted by the HTTP header
+function.