aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat
-rw-r--r--.dockerignore2+2 −0
-rw-r--r--.editorconfig26+0 −26
-rw-r--r--.github/workflows/build.yml30+12 −18
-rw-r--r--.gitignore5+2 −3
-rw-r--r--.gitmodules6+6 −0
-rw-r--r--CODEOWNERS2+1 −1
-rw-r--r--Cargo.lock185+0 −185
-rw-r--r--Cargo.toml9+3 −6
-rw-r--r--Dockerfile26+14 −12
-rw-r--r--README.md28+9 −19
-rw-r--r--REUSE.toml4+2 −2
-rw-r--r--cgit/COPYING339+339 −0
-rw-r--r--cgit/Makefile29+29 −0
-rw-r--r--cgit/cgit.c830+830 −0
-rw-r--r--cgit/cgit.css (renamed from crates/gilti/assets/gilti.css)374+187 −187
-rw-r--r--cgit/cgit.h364+364 −0
-rw-r--r--cgit/cgit.js (renamed from crates/gilti/assets/gilti.js)6+3 −3
-rw-r--r--cgit/cgit.png (renamed from crates/gilti/assets/gilti.png)bin1366 -> 1366 bytes
-rw-r--r--cgit/cmd.c203+203 −0
-rw-r--r--cgit/cmd.h20+20 −0
-rw-r--r--cgit/configfile.c94+94 −0
-rw-r--r--cgit/configfile.h14+14 −0
-rw-r--r--cgit/favicon.ico (renamed from crates/gilti/assets/favicon.ico)bin1078 -> 1078 bytes
-rw-r--r--cgit/filter.c57+57 −0
-rw-r--r--cgit/html.c365+365 −0
-rw-r--r--cgit/html.h42+42 −0
-rw-r--r--cgit/json.c118+118 −0
-rw-r--r--cgit/json.h16+16 −0
-rw-r--r--cgit/parsing.c231+231 −0
-rw-r--r--cgit/scan-tree.c273+273 −0
-rw-r--r--cgit/scan-tree.h6+6 −0
-rw-r--r--cgit/shared.c594+594 −0
-rw-r--r--cgit/ui-atom.c163+163 −0
-rw-r--r--cgit/ui-atom.h10+10 −0
-rw-r--r--cgit/ui-blame.c312+312 −0
-rw-r--r--cgit/ui-blame.h10+10 −0
-rw-r--r--cgit/ui-blob.c188+188 −0
-rw-r--r--cgit/ui-blob.h12+12 −0
-rw-r--r--cgit/ui-clone.c142+142 −0
-rw-r--r--cgit/ui-clone.h12+12 −0
-rw-r--r--cgit/ui-commit.c144+144 −0
-rw-r--r--cgit/ui-commit.h10+10 −0
-rw-r--r--cgit/ui-diff.c507+507 −0
-rw-r--r--cgit/ui-diff.h19+19 −0
-rw-r--r--cgit/ui-log.c555+555 −0
-rw-r--r--cgit/ui-log.h13+13 −0
-rw-r--r--cgit/ui-patch.c104+104 −0
-rw-r--r--cgit/ui-patch.h11+11 −0
-rw-r--r--cgit/ui-plain.c213+213 −0
-rw-r--r--cgit/ui-plain.h10+10 −0
-rw-r--r--cgit/ui-refs.c221+221 −0
-rw-r--r--cgit/ui-refs.h12+12 −0
-rw-r--r--cgit/ui-repolist.c491+491 −0
-rw-r--r--cgit/ui-repolist.h11+11 −0
-rw-r--r--cgit/ui-shared.c1250+1250 −0
-rw-r--r--cgit/ui-shared.h91+91 −0
-rw-r--r--cgit/ui-snapshot.c322+322 −0
-rw-r--r--cgit/ui-snapshot.h11+11 −0
-rw-r--r--cgit/ui-ssdiff.c424+424 −0
-rw-r--r--cgit/ui-ssdiff.h29+29 −0
-rw-r--r--cgit/ui-stats.c439+439 −0
-rw-r--r--cgit/ui-stats.h32+32 −0
-rw-r--r--cgit/ui-summary.c148+148 −0
-rw-r--r--cgit/ui-summary.h11+11 −0
-rw-r--r--cgit/ui-tag.c124+124 −0
-rw-r--r--cgit/ui-tag.h10+10 −0
-rw-r--r--cgit/ui-tree.c400+400 −0
-rw-r--r--cgit/ui-tree.h10+10 −0
-rw-r--r--charts/gilti/Chart.yaml4+2 −2
-rw-r--r--charts/gilti/README.md20+10 −10
-rw-r--r--charts/gilti/templates/configmap.yaml26+25 −1
-rw-r--r--charts/gilti/templates/deployment.yaml21+8 −13
-rw-r--r--charts/gilti/values.schema.json11+3 −8
-rw-r--r--charts/gilti/values.yaml8+3 −5
-rw-r--r--config/cgitrc27+27 −0
-rw-r--r--crates/gilti-ssh/Cargo.toml1+0 −1
-rw-r--r--crates/gilti/Cargo.toml6+1 −5
-rw-r--r--crates/gilti/src/cgi.rs283+270 −13
-rw-r--r--crates/gilti/src/lfs.rs389+0 −389
-rw-r--r--crates/gilti/src/main.rs722+227 −495
-rw-r--r--crates/gilti/src/models/about.rs31+0 −31
-rw-r--r--crates/gilti/src/models/archive.rs41+0 −41
-rw-r--r--crates/gilti/src/models/archive_signature.rs31+0 −31
-rw-r--r--crates/gilti/src/models/blame.rs114+0 −114
-rw-r--r--crates/gilti/src/models/commit.rs151+0 −151
-rw-r--r--crates/gilti/src/models/diff.rs159+0 −159
-rw-r--r--crates/gilti/src/models/history.rs556+0 −556
-rw-r--r--crates/gilti/src/models/mod.rs143+0 −143
-rw-r--r--crates/gilti/src/models/object.rs87+0 −87
-rw-r--r--crates/gilti/src/models/overview.rs122+0 −122
-rw-r--r--crates/gilti/src/models/patch.rs28+0 −28
-rw-r--r--crates/gilti/src/models/refs.rs196+0 −196
-rw-r--r--crates/gilti/src/models/repositories.rs181+0 −181
-rw-r--r--crates/gilti/src/models/repository.rs93+0 −93
-rw-r--r--crates/gilti/src/models/revision.rs34+0 −34
-rw-r--r--crates/gilti/src/models/stats.rs187+0 −187
-rw-r--r--crates/gilti/src/models/tag.rs112+0 −112
-rw-r--r--crates/gilti/src/models/time.rs10+0 −10
-rw-r--r--crates/gilti/src/models/tree.rs250+0 −250
-rw-r--r--crates/gilti/src/router.rs867+0 −867
-rw-r--r--crates/gilti/src/ui.rs296+296 −0
-rw-r--r--crates/gilti/src/views/about.rs36+0 −36
-rw-r--r--crates/gilti/src/views/archive.rs188+0 −188
-rw-r--r--crates/gilti/src/views/archive_signature.rs53+0 −53
-rw-r--r--crates/gilti/src/views/atom.rs247+0 −247
-rw-r--r--crates/gilti/src/views/blame.rs112+0 −112
-rw-r--r--crates/gilti/src/views/diff.rs456+0 −456
-rw-r--r--crates/gilti/src/views/log.rs298+0 −298
-rw-r--r--crates/gilti/src/views/mod.rs80+0 −80
-rw-r--r--crates/gilti/src/views/object.rs52+0 −52
-rw-r--r--crates/gilti/src/views/overview.rs155+0 −155
-rw-r--r--crates/gilti/src/views/patch.rs63+0 −63
-rw-r--r--crates/gilti/src/views/refs.rs87+0 −87
-rw-r--r--crates/gilti/src/views/repositories.rs170+0 −170
-rw-r--r--crates/gilti/src/views/revision.rs118+0 −118
-rw-r--r--crates/gilti/src/views/shared.rs148+0 −148
-rw-r--r--crates/gilti/src/views/stats.rs122+0 −122
-rw-r--r--crates/gilti/src/views/tag.rs76+0 −76
-rw-r--r--crates/gilti/src/views/tree.rs293+0 −293
m---------git1+1 −0
-rw-r--r--mise.toml8+6 −2
-rwxr-xr-xscripts/build-artifacts.sh26+22 −4
-rwxr-xr-xscripts/entrypoint.sh5+4 −1
-rwxr-xr-xtests/chart.sh14+12 −2
-rwxr-xr-xtests/smoke.sh114+40 −74
125 files changed, 11264 insertions, 7634 deletions
diff --git a/.dockerignore b/.dockerignore
index 351a45c..e7c5618 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -4,5 +4,7 @@
.git
.github
.gilti-state
+cgit
dist
+git
target
diff --git a/.editorconfig b/.editorconfig
deleted file mode 100644
--- a/.editorconfig
+++ /dev/null
@@ -1,26 +0,0 @@
-# SPDX-FileCopyrightText: 2026 Nikolay Govorov
-# SPDX-License-Identifier: AGPL-3.0-or-later
-
-root = true
-
-[*]
-charset = utf-8
-end_of_line = lf
-insert_final_newline = true
-trim_trailing_whitespace = true
-indent_style = space
-indent_size = 4
-
-[*.md]
-# double whitespace at end of line
-# denotes a line break in Markdown
-trim_trailing_whitespace = false
-
-[*.rs]
-max_line_length = 100
-
-[*.yml]
-indent_size = 2
-
-[{Makefile,*.mk}]
-indent_style = tab
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 09866d3..60a0f53 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -17,12 +17,8 @@ concurrency:
group: gilti-build-${{ github.ref }}
cancel-in-progress: true
-env:
- CARGO_TERM_COLOR: always
- RELEASE_TAG: nightly
-
jobs:
- lint:
+ check:
name: Static checks
runs-on: ubuntu-latest
steps:
@@ -37,16 +33,15 @@ jobs:
run: mise bootstrap --locked --yes --update
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
- - name: formatting
+ - name: Check formatting
run: cargo fmt --all --check
- - name: shellcheck
- run: shellcheck scripts/*.sh tests/*.sh
+ - run: shellcheck scripts/*.sh tests/*.sh
build:
name: Build (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
- needs: [lint]
+ needs: [check]
strategy:
fail-fast: false
matrix:
@@ -57,6 +52,8 @@ jobs:
arch: arm64
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ submodules: true
- uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3
with:
@@ -67,28 +64,25 @@ jobs:
run: mise bootstrap --locked --yes --update
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
- - run: cargo clippy --workspace --locked --all-targets --all-features --release -- -D warnings
+ - run: cargo clippy --locked --all-targets --all-features --release -- -D warnings
- run: cargo test --all-features --release --locked
- run: tests/chart.sh
- name: Build container artifacts
env:
- RUST_IMAGE: docker.io/library/rust:1.97.1-alpine3.22@sha256:df4efa4e0cdfb5245fa06e3f431387b2bcc96782ce5681b7fb6b0297d745bc29
+ ALPINE_IMAGE: docker.io/library/alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
run: |
docker run --rm \
--volume "$PWD:/src" \
--workdir /src \
--env "HOST_UID=$(id -u)" \
--env "HOST_GID=$(id -g)" \
- "$RUST_IMAGE" sh -euxc '
- rustc --version | grep -qx "rustc 1.97.1 (8bab26f4f 2026-07-14)"
- cargo --version | grep -q "^cargo 1.97.1 "
+ "$ALPINE_IMAGE" sh -euxc '
apk add --no-cache \
build-base=0.5-r3 \
- cmake \
- python3 \
- zlib-dev=1.3.2-r0 \
- zlib-static=1.3.2-r0
+ cargo=1.87.0-r1 \
+ rust=1.87.0-r1 \
+ zlib-dev=1.3.2-r0
scripts/build-artifacts.sh "${{ matrix.arch }}"
chown -R "$HOST_UID:$HOST_GID" .container
'
diff --git a/.gitignore b/.gitignore
index bc1d763..2127774 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,9 @@
# SPDX-FileCopyrightText: 2026 Nikolay Govorov
# SPDX-License-Identifier: AGPL-3.0-or-later
+/.container
/dist
/target
+/.gilti-state
mise.local.toml
mise.*.local.toml
-
-/.container
-/.gilti-state
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,6 @@
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+[submodule "git"]
+ path = git
+ url = https://git.kernel.org/pub/scm/git/git.git
diff --git a/CODEOWNERS b/CODEOWNERS
index faf8e2e..7aeb094 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2026 Nikolay Govorov
# SPDX-License-Identifier: AGPL-3.0-or-later
-* @mrdimidium
+* @mrdimidium
diff --git a/Cargo.lock b/Cargo.lock
index 0e053dd..ba66bad 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -55,74 +55,12 @@ dependencies = [
]
[[package]]
-name = "bitflags"
-version = "2.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
-
-[[package]]
-name = "block-buffer"
-version = "0.10.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
-dependencies = [
- "generic-array",
-]
-
-[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
-name = "cc"
-version = "1.4.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
-dependencies = [
- "find-msvc-tools",
- "jobserver",
- "libc",
- "shlex",
-]
-
-[[package]]
-name = "cfg-if"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
-
-[[package]]
-name = "cpufeatures"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "crypto-common"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
-dependencies = [
- "generic-array",
- "typenum",
-]
-
-[[package]]
-name = "digest"
-version = "0.10.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
-dependencies = [
- "block-buffer",
- "crypto-common",
-]
-
-[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -133,12 +71,6 @@ dependencies = [
]
[[package]]
-name = "find-msvc-tools"
-version = "0.1.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
-
-[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -172,38 +104,14 @@ dependencies = [
]
[[package]]
-name = "generic-array"
-version = "0.14.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
-dependencies = [
- "typenum",
- "version_check",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
-dependencies = [
- "cfg-if",
- "libc",
- "r-efi",
-]
-
-[[package]]
name = "gilti"
version = "0.1.0"
dependencies = [
"axum",
- "git2",
- "libc",
"maud",
"percent-encoding",
"serde",
"serde_json",
- "sha2",
"tokio",
"tower",
]
@@ -216,18 +124,6 @@ dependencies = [
]
[[package]]
-name = "git2"
-version = "0.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e"
-dependencies = [
- "bitflags",
- "libc",
- "libgit2-sys",
- "log",
-]
-
-[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -314,52 +210,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
-name = "jobserver"
-version = "0.1.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
-dependencies = [
- "getrandom",
- "libc",
-]
-
-[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
-name = "libgit2-sys"
-version = "0.18.8+1.9.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50"
-dependencies = [
- "cc",
- "libc",
- "libz-sys",
- "pkg-config",
-]
-
-[[package]]
-name = "libz-sys"
-version = "1.1.29"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
-dependencies = [
- "cc",
- "libc",
- "pkg-config",
- "vcpkg",
-]
-
-[[package]]
-name = "log"
-version = "0.4.34"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
-
-[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -423,12 +279,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
-name = "pkg-config"
-version = "0.3.34"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
-
-[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -459,12 +309,6 @@ dependencies = [
]
[[package]]
-name = "r-efi"
-version = "6.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
-
-[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -508,23 +352,6 @@ dependencies = [
]
[[package]]
-name = "sha2"
-version = "0.10.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
-dependencies = [
- "cfg-if",
- "cpufeatures",
- "digest",
-]
-
-[[package]]
-name = "shlex"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
-
-[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -639,24 +466,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
-name = "typenum"
-version = "1.20.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
-
-[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
-name = "vcpkg"
-version = "0.2.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
-
-[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index fc8833e..6fb0143 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,24 +8,21 @@ resolver = "3"
[workspace.package]
publish = false
edition = "2024"
-rust-version = "1.97"
version = "0.1.0"
license = "AGPL-3.0-or-later"
authors = ["Nikolay Govorov <me@govorov.online>"]
-homepage = "https://git.dimidiumlabs.io/gilti"
-repository = "https://git.dimidiumlabs.io/gilti"
+homepage = "https://github.com/dimidiumlabs/gilti"
+repository = "https://github.com/dimidiumlabs/gilti"
[workspace.dependencies]
axum = { version = "0.8.4", default-features = false, features = ["http1", "tokio"] }
-git2 = { version = "0.21.0", default-features = false, features = ["unstable-sha256", "vendored-libgit2"] }
libc = "0.2.172"
percent-encoding = "2.3.2"
-tokio = { version = "1.44.2", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal"] }
+tokio = { version = "1.44.2", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal"] }
tower = { version = "0.5.2", features = ["util"] }
maud = "0.27.0"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
-sha2 = "0.10.9"
[profile.release]
codegen-units = 1
diff --git a/Dockerfile b/Dockerfile
index a625eb1..8c9c369 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,23 +13,18 @@ ARG REVISION=unknown
LABEL org.opencontainers.image.title="Gilti" \
org.opencontainers.image.description="Tiny Git server in a box" \
- org.opencontainers.image.source="https://git.dimidiumlabs.io/gilti" \
+ org.opencontainers.image.source="https://github.com/dimidiumlabs/gilti" \
org.opencontainers.image.version="$VERSION" \
org.opencontainers.image.revision="$REVISION" \
org.opencontainers.image.licenses="AGPL-3.0-or-later"
RUN apk add --no-cache \
- bzip2=1.0.8-r6 \
git=2.49.1-r0 \
- git-daemon=2.49.1-r0 \
libgcc=14.2.0-r6 \
- lzip=1.25-r0 \
openssh-keygen=10.0_p1-r10 \
openssh-server=10.0_p1-r10 \
su-exec=0.2-r3 \
- tini=0.19.0-r3 \
- xz=5.8.3-r0 \
- zstd=1.5.7-r0 && \
+ tini=0.19.0-r3 && \
addgroup -S -g 10000 git && \
install -d -m 0755 -o root -g root /var/lib/gilti && \
adduser -S -D -u 10000 -G git -h /var/lib/gilti/git -s /bin/sh git && \
@@ -38,23 +33,30 @@ RUN apk add --no-cache \
/var/lib/gilti/git /var/lib/gilti/git/repositories && \
install -d -m 0700 -o root -g root /var/lib/gilti/ssh && \
install -d -m 0755 -o root -g root /run/gilti && \
+ install -d -m 0750 -o git -g git /run/gilti/http && \
install -d -m 0750 -o root -g git /run/gilti/ssh && \
install -d -m 0755 /etc/gilti && \
rm -rf /var/cache/apk/*
COPY --chown=root:root --chmod=0755 \
.container/binary-${TARGETARCH}/gilti \
+ .container/binary-${TARGETARCH}/gilti-cgit \
.container/binary-${TARGETARCH}/gilti-ssh \
/usr/local/bin/
COPY --chown=root:root \
- .container/binary-${TARGETARCH}/gilti.css \
- .container/binary-${TARGETARCH}/gilti.js \
- .container/binary-${TARGETARCH}/gilti.png \
+ .container/binary-${TARGETARCH}/cgit.css \
+ .container/binary-${TARGETARCH}/cgit.js \
+ .container/binary-${TARGETARCH}/cgit.png \
.container/binary-${TARGETARCH}/favicon.ico \
- /usr/share/gilti/
+ /usr/share/webapps/cgit/
+COPY --chown=root:root \
+ .container/binary-${TARGETARCH}/COPYING.cgit.txt \
+ .container/binary-${TARGETARCH}/COPYING.git.txt \
+ /usr/share/doc/gilti-cgit/
+COPY --chown=root:root config/cgitrc /etc/cgitrc
COPY --chown=root:root config/sshd_config /etc/ssh/sshd_config
COPY --chown=root:root --chmod=0755 scripts/entrypoint.sh /usr/local/bin/gilti-entrypoint
-COPY --chown=root:root LICENSE README.md LICENSES/ /usr/share/doc/gilti/
+COPY --chown=root:root LICENSE README.md /usr/share/doc/gilti/
RUN /usr/local/bin/gilti --check && \
/usr/local/bin/gilti-ssh --check
diff --git a/README.md b/README.md
index 2db136f..50892bf 100644
--- a/README.md
+++ b/README.md
@@ -9,15 +9,12 @@ platforms but aren't ready to host complex services like Forgejo.
## Security boundary
-- Git fetch is available anonymously over smart HTTP; authenticated fetch and
- push use SSH public-key authentication through `gilti-ssh`.
+- Git fetch and push use SSH public-key authentication through `gilti-ssh`.
- Every configured key has read/write access to every repository and may create
a repository by pushing to its name for the first time.
-- Repository browsing, archives, LFS downloads, and smart HTTP fetches are
- anonymous and read-only; every repository is publicly visible.
-- Password authentication, shells, forwarding, and tunnels are disabled; content
- filters are not supported. Optional unauthenticated HTTP writes must be
- enabled explicitly.
+- cgit is anonymous and read-only; every repository is publicly visible.
+- Smart HTTP, password authentication, shells, forwarding, and tunnels are
+ disabled; cgit filters are not supported.
- Gilti is a single-replica service backed by one POSIX persistent volume. It is
not an HA system.
@@ -42,16 +39,8 @@ docker run --rm \
ghcr.io/dimidiumlabs/gilti:nightly
```
-The HTTP configuration is read from the environment at startup:
-
-- `GILTI_ROOT_TITLE` (default: `Gilti`);
-- `GILTI_ROOT_DESCRIPTION` (default: `A tiny Git server`);
-- `GILTI_CLONE_PREFIX` (empty by default);
-- `GILTI_HTTP_WRITE` (`0` by default; `1` enables unauthenticated HTTP pushes and LFS uploads).
-
-Gilti snapshots the authorized keys file at process startup; changing it takes
-effect after a restart. Repositories and the persistent SSH host key live on
-the state volume.
+Gilti snapshots this file at process startup; changing it takes effect after a
+restart. Repositories and the persistent SSH host key live on the state volume.
## Helm
@@ -67,7 +56,7 @@ ssh:
helm upgrade --install gilti ./charts/gilti \
--namespace gilti --create-namespace \
--values values.yaml \
- --set web.clonePrefix='ssh://git@git.dimidiumlabs.io/'
+ --set cgit.clonePrefix='ssh://git@vcs.dimidiumlabs.io/'
```
See [`charts/gilti/README.md`](charts/gilti/README.md) for persistence, routing,
@@ -75,9 +64,10 @@ and public-repository configuration.
## Development
-Provision tools and run static checks:
+Initialize the Git source used to build cgit, provision tools, and run static checks:
```console
+git submodule update --init
mise bootstrap
cargo fmt -- --check
cargo test --locked
diff --git a/REUSE.toml b/REUSE.toml
index 901485f..aa786bb 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -30,8 +30,8 @@ SPDX-License-Identifier = "CC-BY-3.0"
[[annotations]]
path = [
- "crates/gilti/assets/gilti.png",
- "crates/gilti/assets/favicon.ico",
+ "cgit/cgit.png",
+ "cgit/favicon.ico",
]
SPDX-FileCopyrightText = "cgit Development Team <cgit@lists.zx2c4.com>"
SPDX-License-Identifier = "GPL-2.0-only"
diff --git a/cgit/COPYING b/cgit/COPYING
new file mode 100644
--- /dev/null
+++ b/cgit/COPYING
@@ -0,0 +1,339 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, write to the Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Ty Coon>, 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/cgit/Makefile b/cgit/Makefile
new file mode 100644
--- /dev/null
+++ b/cgit/Makefile
@@ -0,0 +1,29 @@
+# SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov
+# SPDX-License-Identifier: GPL-2.0-only
+
+# Run from Git's source directory so cgit can reuse Git's build variables,
+# compatibility layer, and static libraries.
+include Makefile
+
+CGIT_PREFIX = ../
+CGIT_VERSION = v1.3.1
+CGIT_SCRIPT_NAME = gilti-cgit
+CGIT_CONFIG = /etc/cgitrc
+
+CGIT_CFLAGS += -DCGIT_CONFIG='"$(CGIT_CONFIG)"'
+CGIT_CFLAGS += -DCGIT_SCRIPT_NAME='"$(CGIT_SCRIPT_NAME)"'
+CGIT_CFLAGS += -DCGIT_VERSION='"$(CGIT_VERSION)"'
+
+CGIT_OBJS := $(patsubst %.c,%.o,$(wildcard $(CGIT_PREFIX)*.c))
+cgit_dep_files := $(foreach f,$(CGIT_OBJS),$(dir $(f)).depend/$(notdir $(f)).d)
+-include $(wildcard $(cgit_dep_files))
+
+$(CGIT_PREFIX).depend:
+ @mkdir -p $@
+
+$(CGIT_OBJS): %.o: %.c GIT-CFLAGS $(CGIT_PREFIX)Makefile | $(CGIT_PREFIX).depend
+ $(QUIET_CC)$(CC) -o $*.o -c $(dep_args) $(ALL_CFLAGS) $(EXTRA_CPPFLAGS) $(CGIT_CFLAGS) $<
+
+$(CGIT_PREFIX)cgit: $(CGIT_OBJS) GIT-LDFLAGS $(GITLIBS)
+ $(QUIET_LINK)$(CC) $(ALL_CFLAGS) -o $@ $(ALL_LDFLAGS) $(filter %.o,$^) $(LIBS) $(CGIT_LIBS)
diff --git a/cgit/cgit.c b/cgit/cgit.c
new file mode 100644
--- /dev/null
+++ b/cgit/cgit.c
@@ -0,0 +1,830 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-FileCopyrightText: 2026 Nikolay Govorov
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* cgit.c: cgi for the git scm
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "cmd.h"
+#include "configfile.h"
+#include "html.h"
+#include "ui-shared.h"
+#include "ui-stats.h"
+#include "ui-blob.h"
+#include "ui-summary.h"
+#include "scan-tree.h"
+
+const char *cgit_version = CGIT_VERSION;
+
+__attribute__((constructor))
+static void constructor_environment()
+{
+ /* Do not look in /etc/ for gitconfig and gitattributes. */
+ setenv("GIT_CONFIG_NOSYSTEM", "1", 1);
+ setenv("GIT_ATTR_NOSYSTEM", "1", 1);
+ unsetenv("HOME");
+ unsetenv("XDG_CONFIG_HOME");
+}
+
+static void add_mimetype(const char *name, const char *value)
+{
+ struct string_list_item *item;
+
+ item = string_list_insert(&ctx.cfg.mimetypes, name);
+ item->util = xstrdup(value);
+}
+
+void cgit_repo_config(struct cgit_repo *repo, const char *name, const char *value)
+{
+ const char *path;
+ struct string_list_item *item;
+
+ if (!strcmp(name, "name"))
+ repo->name = strdup_first_line(value);
+ else if (!strcmp(name, "clone-url"))
+ repo->clone_url = strdup_first_line(value);
+ else if (!strcmp(name, "desc"))
+ repo->desc = strdup_first_line(value);
+ else if (!strcmp(name, "owner"))
+ repo->owner = strdup_first_line(value);
+ else if (!strcmp(name, "homepage"))
+ repo->homepage = strdup_first_line(value);
+ else if (!strcmp(name, "defbranch"))
+ repo->defbranch = strdup_first_line(value);
+ else if (!strcmp(name, "extra-head-content"))
+ repo->extra_head_content = strdup_first_line(value);
+ else if (!strcmp(name, "snapshots"))
+ repo->snapshots = ctx.cfg.snapshots & cgit_parse_snapshots_mask(value);
+ else if (!strcmp(name, "enable-blame"))
+ repo->enable_blame = atoi(value);
+ else if (!strcmp(name, "enable-commit-graph"))
+ repo->enable_commit_graph = atoi(value);
+ else if (!strcmp(name, "enable-follow-links"))
+ repo->enable_follow_links = atoi(value);
+ else if (!strcmp(name, "enable-log-filecount"))
+ repo->enable_log_filecount = atoi(value);
+ else if (!strcmp(name, "enable-log-linecount"))
+ repo->enable_log_linecount = atoi(value);
+ else if (!strcmp(name, "enable-remote-branches"))
+ repo->enable_remote_branches = atoi(value);
+ else if (!strcmp(name, "enable-subject-links"))
+ repo->enable_subject_links = atoi(value);
+ else if (!strcmp(name, "enable-html-serving"))
+ repo->enable_html_serving = atoi(value);
+ else if (!strcmp(name, "branch-sort")) {
+ if (!strcmp(value, "age"))
+ repo->branch_sort = 1;
+ if (!strcmp(value, "name"))
+ repo->branch_sort = 0;
+ } else if (!strcmp(name, "commit-sort")) {
+ if (!strcmp(value, "date"))
+ repo->commit_sort = 1;
+ if (!strcmp(value, "topo"))
+ repo->commit_sort = 2;
+ } else if (!strcmp(name, "max-stats"))
+ repo->max_stats = cgit_find_stats_period(value, NULL);
+ else if (!strcmp(name, "module-link"))
+ repo->module_link= strdup_first_line(value);
+ else if (skip_prefix(name, "module-link.", &path)) {
+ item = string_list_append(&repo->submodules, strdup_first_line(path));
+ item->util = strdup_first_line(value);
+ } else if (!strcmp(name, "section"))
+ repo->section = strdup_first_line(value);
+ else if (!strcmp(name, "snapshot-prefix"))
+ repo->snapshot_prefix = strdup_first_line(value);
+ else if (!strcmp(name, "readme") && value != NULL) {
+ if (repo->readme.items == ctx.cfg.readme.items)
+ memset(&repo->readme, 0, sizeof(repo->readme));
+ string_list_append(&repo->readme, strdup_first_line(value));
+ } else if (!strcmp(name, "logo") && value != NULL)
+ repo->logo = strdup_first_line(value);
+ else if (!strcmp(name, "logo-link") && value != NULL)
+ repo->logo_link = strdup_first_line(value);
+ else if (!strcmp(name, "hide"))
+ repo->hide = atoi(value);
+ else if (!strcmp(name, "ignore"))
+ repo->ignore = atoi(value);
+}
+
+static void config_cb(const char *name, const char *value)
+{
+ const char *arg;
+
+ if (!strcmp(name, "section"))
+ ctx.cfg.section = strdup_first_line(value);
+ else if (!strcmp(name, "repo.url"))
+ ctx.repo = cgit_add_repo(value);
+ else if (ctx.repo && !strcmp(name, "repo.path"))
+ ctx.repo->path = trim_end(value, '/');
+ else if (ctx.repo && skip_prefix(name, "repo.", &arg))
+ cgit_repo_config(ctx.repo, arg, value);
+ else if (!strcmp(name, "readme"))
+ string_list_append(&ctx.cfg.readme, strdup_first_line(value));
+ else if (!strcmp(name, "root-title"))
+ ctx.cfg.root_title = strdup_first_line(value);
+ else if (!strcmp(name, "root-desc"))
+ ctx.cfg.root_desc = strdup_first_line(value);
+ else if (!strcmp(name, "root-readme"))
+ ctx.cfg.root_readme = strdup_first_line(value);
+ else if (!strcmp(name, "css"))
+ string_list_append(&ctx.cfg.css, strdup_first_line(value));
+ else if (!strcmp(name, "js"))
+ string_list_append(&ctx.cfg.js, strdup_first_line(value));
+ else if (!strcmp(name, "favicon"))
+ ctx.cfg.favicon = strdup_first_line(value);
+ else if (!strcmp(name, "footer"))
+ ctx.cfg.footer = strdup_first_line(value);
+ else if (!strcmp(name, "head-include"))
+ ctx.cfg.head_include = strdup_first_line(value);
+ else if (!strcmp(name, "header"))
+ ctx.cfg.header = strdup_first_line(value);
+ else if (!strcmp(name, "logo"))
+ ctx.cfg.logo = strdup_first_line(value);
+ else if (!strcmp(name, "logo-link"))
+ ctx.cfg.logo_link = strdup_first_line(value);
+ else if (!strcmp(name, "module-link"))
+ ctx.cfg.module_link = strdup_first_line(value);
+ else if (!strcmp(name, "strict-export"))
+ ctx.cfg.strict_export = strdup_first_line(value);
+ else if (!strcmp(name, "virtual-root"))
+ ctx.cfg.virtual_root = ensure_end(value, '/');
+ else if (!strcmp(name, "noplainemail"))
+ ctx.cfg.noplainemail = atoi(value);
+ else if (!strcmp(name, "noheader"))
+ ctx.cfg.noheader = atoi(value);
+ else if (!strcmp(name, "snapshots"))
+ ctx.cfg.snapshots = cgit_parse_snapshots_mask(value);
+ else if (!strcmp(name, "enable-follow-links"))
+ ctx.cfg.enable_follow_links = atoi(value);
+ else if (!strcmp(name, "enable-http-clone"))
+ ctx.cfg.enable_http_clone = atoi(value);
+ else if (!strcmp(name, "enable-index-links"))
+ ctx.cfg.enable_index_links = atoi(value);
+ else if (!strcmp(name, "enable-index-owner"))
+ ctx.cfg.enable_index_owner = atoi(value);
+ else if (!strcmp(name, "enable-blame"))
+ ctx.cfg.enable_blame = atoi(value);
+ else if (!strcmp(name, "enable-commit-graph"))
+ ctx.cfg.enable_commit_graph = atoi(value);
+ else if (!strcmp(name, "enable-log-filecount"))
+ ctx.cfg.enable_log_filecount = atoi(value);
+ else if (!strcmp(name, "enable-log-linecount"))
+ ctx.cfg.enable_log_linecount = atoi(value);
+ else if (!strcmp(name, "enable-remote-branches"))
+ ctx.cfg.enable_remote_branches = atoi(value);
+ else if (!strcmp(name, "enable-subject-links"))
+ ctx.cfg.enable_subject_links = atoi(value);
+ else if (!strcmp(name, "enable-html-serving"))
+ ctx.cfg.enable_html_serving = atoi(value);
+ else if (!strcmp(name, "enable-tree-linenumbers"))
+ ctx.cfg.enable_tree_linenumbers = atoi(value);
+ else if (!strcmp(name, "enable-git-config"))
+ ctx.cfg.enable_git_config = atoi(value);
+ else if (!strcmp(name, "max-stats"))
+ ctx.cfg.max_stats = cgit_find_stats_period(value, NULL);
+ else if (!strcmp(name, "case-sensitive-sort"))
+ ctx.cfg.case_sensitive_sort = atoi(value);
+ else if (!strcmp(name, "embedded"))
+ ctx.cfg.embedded = atoi(value);
+ else if (!strcmp(name, "max-atom-items"))
+ ctx.cfg.max_atom_items = atoi(value);
+ else if (!strcmp(name, "max-message-length"))
+ ctx.cfg.max_msg_len = atoi(value);
+ else if (!strcmp(name, "max-repodesc-length"))
+ ctx.cfg.max_repodesc_len = atoi(value);
+ else if (!strcmp(name, "max-blob-size"))
+ ctx.cfg.max_blob_size = atoi(value);
+ else if (!strcmp(name, "max-repo-count")) {
+ ctx.cfg.max_repo_count = atoi(value);
+ if (ctx.cfg.max_repo_count <= 0)
+ ctx.cfg.max_repo_count = INT_MAX;
+ } else if (!strcmp(name, "max-commit-count"))
+ ctx.cfg.max_commit_count = atoi(value);
+ else if (!strcmp(name, "project-list"))
+ ctx.cfg.project_list = strdup_first_line(expand_macros(value));
+ else if (!strcmp(name, "scan-path"))
+ if (ctx.cfg.project_list)
+ scan_projects(expand_macros(value),
+ ctx.cfg.project_list);
+ else
+ scan_tree(expand_macros(value));
+ else if (!strcmp(name, "scan-hidden-path"))
+ ctx.cfg.scan_hidden_path = atoi(value);
+ else if (!strcmp(name, "section-from-path"))
+ ctx.cfg.section_from_path = atoi(value);
+ else if (!strcmp(name, "repository-sort"))
+ ctx.cfg.repository_sort = strdup_first_line(value);
+ else if (!strcmp(name, "section-sort"))
+ ctx.cfg.section_sort = atoi(value);
+ else if (!strcmp(name, "summary-log"))
+ ctx.cfg.summary_log = atoi(value);
+ else if (!strcmp(name, "summary-branches"))
+ ctx.cfg.summary_branches = atoi(value);
+ else if (!strcmp(name, "summary-tags"))
+ ctx.cfg.summary_tags = atoi(value);
+ else if (!strcmp(name, "side-by-side-diffs"))
+ ctx.cfg.difftype = atoi(value) ? DIFF_SSDIFF : DIFF_UNIFIED;
+ else if (!strcmp(name, "agefile"))
+ ctx.cfg.agefile = strdup_first_line(value);
+ else if (!strcmp(name, "mimetype-file"))
+ ctx.cfg.mimetype_file = strdup_first_line(value);
+ else if (!strcmp(name, "renamelimit"))
+ ctx.cfg.renamelimit = atoi(value);
+ else if (!strcmp(name, "remove-suffix"))
+ ctx.cfg.remove_suffix = atoi(value);
+ else if (!strcmp(name, "robots"))
+ ctx.cfg.robots = strdup_first_line(value);
+ else if (!strcmp(name, "clone-prefix"))
+ ctx.cfg.clone_prefix = strdup_first_line(value);
+ else if (!strcmp(name, "clone-url"))
+ ctx.cfg.clone_url = strdup_first_line(value);
+ else if (!strcmp(name, "local-time"))
+ ctx.cfg.local_time = atoi(value);
+ else if (!strcmp(name, "commit-sort")) {
+ if (!strcmp(value, "date"))
+ ctx.cfg.commit_sort = 1;
+ if (!strcmp(value, "topo"))
+ ctx.cfg.commit_sort = 2;
+ } else if (!strcmp(name, "branch-sort")) {
+ if (!strcmp(value, "age"))
+ ctx.cfg.branch_sort = 1;
+ if (!strcmp(value, "name"))
+ ctx.cfg.branch_sort = 0;
+ } else if (skip_prefix(name, "mimetype.", &arg))
+ add_mimetype(arg, value);
+ else if (!strcmp(name, "include"))
+ parse_configfile(expand_macros(value), config_cb);
+}
+
+static void querystring_cb(const char *name, const char *value)
+{
+ if (!value)
+ value = "";
+
+ if (!strcmp(name,"r")) {
+ ctx.qry.repo = xstrdup(value);
+ ctx.repo = cgit_get_repoinfo(value);
+ } else if (!strcmp(name, "p")) {
+ ctx.qry.page = xstrdup(value);
+ } else if (!strcmp(name, "url")) {
+ if (*value == '/')
+ value++;
+ ctx.qry.url = xstrdup(value);
+ cgit_parse_url(value);
+ } else if (!strcmp(name, "qt")) {
+ ctx.qry.grep = 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.oid = xstrdup(value);
+ ctx.qry.has_oid = 1;
+ } else if (!strcmp(name, "id2")) {
+ ctx.qry.oid2 = xstrdup(value);
+ ctx.qry.has_oid = 1;
+ } else if (!strcmp(name, "ofs")) {
+ ctx.qry.ofs = atoi(value);
+ } else if (!strcmp(name, "path")) {
+ ctx.qry.path = trim_end(value, '/');
+ } else if (!strcmp(name, "name")) {
+ ctx.qry.name = 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, "period")) {
+ ctx.qry.period = xstrdup(value);
+ } else if (!strcmp(name, "dt")) {
+ ctx.qry.difftype = atoi(value);
+ ctx.qry.has_difftype = 1;
+ } else if (!strcmp(name, "ss")) {
+ /* No longer generated, but there may be links out there. */
+ ctx.qry.difftype = atoi(value) ? DIFF_SSDIFF : DIFF_UNIFIED;
+ ctx.qry.has_difftype = 1;
+ } 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, "ignorews")) {
+ ctx.qry.ignorews = atoi(value);
+ } else if (!strcmp(name, "follow")) {
+ ctx.qry.follow = atoi(value);
+ }
+}
+
+static void prepare_context(void)
+{
+ memset(&ctx, 0, sizeof(ctx));
+ ctx.cfg.agefile = "info/web/last-modified";
+ ctx.cfg.case_sensitive_sort = 1;
+ ctx.cfg.branch_sort = 0;
+ ctx.cfg.commit_sort = 0;
+ ctx.cfg.logo = "/cgit.png";
+ ctx.cfg.favicon = "/favicon.ico";
+ ctx.cfg.local_time = 0;
+ ctx.cfg.enable_http_clone = 1;
+ ctx.cfg.enable_index_owner = 1;
+ ctx.cfg.enable_tree_linenumbers = 1;
+ ctx.cfg.enable_git_config = 0;
+ ctx.cfg.max_repo_count = 50;
+ ctx.cfg.max_commit_count = 50;
+ ctx.cfg.max_lock_attempts = 5;
+ ctx.cfg.max_msg_len = 80;
+ ctx.cfg.max_repodesc_len = 80;
+ ctx.cfg.max_blob_size = 0;
+ ctx.cfg.max_stats = 0;
+ ctx.cfg.project_list = NULL;
+ ctx.cfg.renamelimit = -1;
+ ctx.cfg.remove_suffix = 0;
+ ctx.cfg.robots = "index, nofollow";
+ ctx.cfg.root_title = "Git repository browser";
+ ctx.cfg.root_desc = "a fast webinterface for the git dscm";
+ ctx.cfg.scan_hidden_path = 0;
+ ctx.cfg.script_name = CGIT_SCRIPT_NAME;
+ ctx.cfg.section = "";
+ ctx.cfg.repository_sort = "name";
+ ctx.cfg.section_sort = 1;
+ ctx.cfg.summary_branches = 10;
+ ctx.cfg.summary_log = 10;
+ ctx.cfg.summary_tags = 10;
+ ctx.cfg.max_atom_items = 10;
+ ctx.cfg.difftype = DIFF_UNIFIED;
+ ctx.env.cgit_config = getenv("CGIT_CONFIG");
+ ctx.env.http_host = getenv("HTTP_HOST");
+ ctx.env.https = getenv("HTTPS");
+ ctx.env.no_http = getenv("NO_HTTP");
+ ctx.env.path_info = getenv("PATH_INFO");
+ ctx.env.query_string = getenv("QUERY_STRING");
+ ctx.env.request_method = getenv("REQUEST_METHOD");
+ ctx.env.script_name = getenv("SCRIPT_NAME");
+ ctx.env.server_name = getenv("SERVER_NAME");
+ ctx.env.server_port = getenv("SERVER_PORT");
+ ctx.page.mimetype = "text/html";
+ ctx.page.charset = PAGE_ENCODING;
+ ctx.page.filename = NULL;
+ ctx.page.size = 0;
+ ctx.page.modified = time(NULL);
+ ctx.page.etag = NULL;
+ string_list_init_dup(&ctx.cfg.mimetypes);
+ if (ctx.env.script_name)
+ ctx.cfg.script_name = xstrdup(ctx.env.script_name);
+ if (ctx.env.query_string)
+ ctx.qry.raw = xstrdup(ctx.env.query_string);
+ if (!ctx.env.cgit_config)
+ ctx.env.cgit_config = CGIT_CONFIG;
+}
+
+struct refmatch {
+ char *req_ref;
+ char *first_ref;
+ int match;
+};
+
+static int find_current_ref(const struct reference *ref, void *cb_data)
+{
+ struct refmatch *info;
+
+ info = (struct refmatch *)cb_data;
+ if (!strcmp(ref->name, info->req_ref))
+ info->match = 1;
+ if (!info->first_ref)
+ info->first_ref = xstrdup(ref->name);
+ return info->match;
+}
+
+static void free_refmatch_inner(struct refmatch *info)
+{
+ if (info->first_ref)
+ free(info->first_ref);
+}
+
+static char *find_default_branch(struct cgit_repo *repo)
+{
+ struct refmatch info;
+ char *ref;
+
+ info.req_ref = repo->defbranch;
+ info.first_ref = NULL;
+ info.match = 0;
+ refs_for_each_branch_ref(get_main_ref_store(the_repository),
+ find_current_ref, &info);
+ if (info.match)
+ ref = info.req_ref;
+ else
+ ref = info.first_ref;
+ if (ref)
+ ref = xstrdup(ref);
+ free_refmatch_inner(&info);
+
+ return ref;
+}
+
+static char *guess_defbranch(void)
+{
+ const char *ref, *refname;
+ struct object_id oid;
+
+ ref = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
+ "HEAD", 0, &oid, NULL);
+ if (!ref || !skip_prefix(ref, "refs/heads/", &refname))
+ return "master";
+ return xstrdup(refname);
+}
+
+/* The caller must free filename and ref after calling this. */
+static inline void parse_readme(const char *readme, char **filename, char **ref, struct cgit_repo *repo)
+{
+ const char *colon;
+
+ *filename = NULL;
+ *ref = NULL;
+
+ if (!readme || !readme[0])
+ return;
+
+ /* Check if the readme is tracked in the git repo. */
+ colon = strchr(readme, ':');
+ if (colon && strlen(colon) > 1) {
+ /* If it starts with a colon, we want to use head given
+ * from query or the default branch */
+ if (colon == readme && ctx.qry.head)
+ *ref = xstrdup(ctx.qry.head);
+ else if (colon == readme && repo->defbranch)
+ *ref = xstrdup(repo->defbranch);
+ else
+ *ref = xstrndup(readme, colon - readme);
+ readme = colon + 1;
+ }
+
+ /* Prepend repo path to relative readme path unless tracked. */
+ if (!(*ref) && readme[0] != '/')
+ *filename = fmtalloc("%s/%s", repo->path, readme);
+ else
+ *filename = xstrdup(readme);
+}
+static void choose_readme(struct cgit_repo *repo)
+{
+ int found;
+ char *filename, *ref;
+ struct string_list_item *entry;
+
+ if (!repo->readme.nr)
+ return;
+
+ found = 0;
+ for_each_string_list_item(entry, &repo->readme) {
+ parse_readme(entry->string, &filename, &ref, repo);
+ if (!filename) {
+ free(filename);
+ free(ref);
+ continue;
+ }
+ if (ref) {
+ if (cgit_ref_path_exists(filename, ref, 1)) {
+ found = 1;
+ break;
+ }
+ }
+ else if (!access(filename, R_OK)) {
+ found = 1;
+ break;
+ }
+ free(filename);
+ free(ref);
+ }
+ repo->readme.strdup_strings = 1;
+ string_list_clear(&repo->readme, 0);
+ repo->readme.strdup_strings = 0;
+ if (found)
+ string_list_append(&repo->readme, filename)->util = ref;
+}
+
+static void print_no_repo_clone_urls(const char *url)
+{
+ html("<tr><td><a rel='vcs-git' href='");
+ html_url_path(url);
+ html("' title='");
+ html_attr(ctx.repo->name);
+ html(" Git repository'>");
+ html_txt(url);
+ html("</a></td></tr>\n");
+}
+
+static void prepare_repo_env(int *nongit)
+{
+ /* The path to the git repository. */
+ setenv("GIT_DIR", ctx.repo->path, 1);
+
+ /* Setup the git directory and initialize the notes system. Both of these
+ * load local configuration from the git repository, so we do them both while
+ * the HOME variables are unset. */
+ setup_git_directory_gently(nongit);
+ load_display_notes(NULL);
+}
+
+static int prepare_repo_cmd(int nongit)
+{
+ struct object_id oid;
+ int rc;
+
+ if (nongit) {
+ const char *name = ctx.repo->name;
+ rc = errno;
+ ctx.page.title = fmtalloc("%s - %s", ctx.cfg.root_title,
+ "config error");
+ ctx.repo = NULL;
+ cgit_print_http_headers();
+ cgit_print_docstart();
+ cgit_print_pageheader();
+ cgit_print_error("Failed to open %s: %s", name,
+ rc ? strerror(rc) : "Not a valid git repository");
+ cgit_print_docend();
+ return 1;
+ }
+ ctx.page.title = fmtalloc("%s - %s", ctx.repo->name, ctx.repo->desc);
+
+ if (!ctx.repo->defbranch)
+ ctx.repo->defbranch = guess_defbranch();
+
+ if (!ctx.qry.head) {
+ ctx.qry.nohead = 1;
+ ctx.qry.head = find_default_branch(ctx.repo);
+ }
+
+ if (!ctx.qry.head) {
+ cgit_print_http_headers();
+ cgit_print_docstart();
+ cgit_print_pageheader();
+ cgit_print_error("Repository seems to be empty");
+ if (!strcmp(ctx.qry.page, "summary")) {
+ html("<table class='list'><tr class='nohover'><td>&nbsp;</td></tr><tr class='nohover'><th class='left'>Clone</th></tr>\n");
+ cgit_prepare_repo_env(ctx.repo);
+ cgit_add_clone_urls(print_no_repo_clone_urls);
+ html("</table>\n");
+ }
+ cgit_print_docend();
+ return 1;
+ }
+
+ if (repo_get_oid(the_repository, ctx.qry.head, &oid)) {
+ char *old_head = ctx.qry.head;
+ ctx.qry.head = xstrdup(ctx.repo->defbranch);
+ cgit_print_error_page(404, "Not found",
+ "Invalid branch: %s", old_head);
+ free(old_head);
+ return 1;
+ }
+ string_list_sort(&ctx.repo->submodules);
+ cgit_prepare_repo_env(ctx.repo);
+ choose_readme(ctx.repo);
+ return 0;
+}
+
+static void process_request(void)
+{
+ struct cgit_cmd *cmd;
+ int nongit = 0;
+
+ if (ctx.repo)
+ prepare_repo_env(&nongit);
+
+ cmd = cgit_get_cmd();
+ if (!cmd) {
+ ctx.page.title = "cgit error";
+ cgit_print_error_page(404, "Not found", "Invalid request");
+ return;
+ }
+
+ if (!ctx.cfg.enable_http_clone && cmd->is_clone) {
+ ctx.page.title = "cgit error";
+ cgit_print_error_page(404, "Not found", "Invalid request");
+ return;
+ }
+
+ if (cmd->want_repo && !ctx.repo) {
+ cgit_print_error_page(400, "Bad request",
+ "No repository selected");
+ return;
+ }
+
+ /* If cmd->want_vpath is set, assume ctx.qry.path contains a "virtual"
+ * in-project path limit to be made available at ctx.qry.vpath.
+ * Otherwise, no path limit is in effect (ctx.qry.vpath = NULL).
+ */
+ ctx.qry.vpath = cmd->want_vpath ? ctx.qry.path : NULL;
+
+ if (ctx.repo && prepare_repo_cmd(nongit))
+ return;
+
+ cmd->fn();
+}
+
+static int cmp_repos(const void *a, const void *b)
+{
+ const struct cgit_repo *ra = a, *rb = b;
+ return strcmp(ra->url, rb->url);
+}
+
+static char *build_snapshot_setting(int bitmap)
+{
+ const struct cgit_snapshot_format *f;
+ struct strbuf result = STRBUF_INIT;
+
+ for (f = cgit_snapshot_formats; f->suffix; f++) {
+ if (cgit_snapshot_format_bit(f) & bitmap) {
+ if (result.len)
+ strbuf_addch(&result, ' ');
+ strbuf_addstr(&result, f->suffix);
+ }
+ }
+ return strbuf_detach(&result, NULL);
+}
+
+static void print_repo(FILE *f, struct cgit_repo *repo)
+{
+ struct string_list_item *item;
+ fprintf(f, "repo.url=%s\n", repo->url);
+ fprintf(f, "repo.name=%s\n", repo->name);
+ fprintf(f, "repo.path=%s\n", repo->path);
+ if (repo->owner)
+ fprintf(f, "repo.owner=%s\n", repo->owner);
+ if (repo->desc)
+ fprintf(f, "repo.desc=%s\n", repo->desc);
+ for_each_string_list_item(item, &repo->readme) {
+ if (item->util)
+ fprintf(f, "repo.readme=%s:%s\n", (char *)item->util, item->string);
+ else
+ fprintf(f, "repo.readme=%s\n", item->string);
+ }
+ if (repo->defbranch)
+ fprintf(f, "repo.defbranch=%s\n", repo->defbranch);
+ if (repo->extra_head_content)
+ fprintf(f, "repo.extra-head-content=%s\n", repo->extra_head_content);
+ if (repo->module_link)
+ fprintf(f, "repo.module-link=%s\n", repo->module_link);
+ if (repo->section)
+ fprintf(f, "repo.section=%s\n", repo->section);
+ if (repo->homepage)
+ fprintf(f, "repo.homepage=%s\n", repo->homepage);
+ if (repo->clone_url)
+ fprintf(f, "repo.clone-url=%s\n", repo->clone_url);
+ fprintf(f, "repo.enable-blame=%d\n",
+ repo->enable_blame);
+ fprintf(f, "repo.enable-commit-graph=%d\n",
+ repo->enable_commit_graph);
+ fprintf(f, "repo.enable-follow-links=%d\n",
+ repo->enable_follow_links);
+ fprintf(f, "repo.enable-log-filecount=%d\n",
+ repo->enable_log_filecount);
+ fprintf(f, "repo.enable-log-linecount=%d\n",
+ repo->enable_log_linecount);
+ if (repo->snapshots != ctx.cfg.snapshots) {
+ char *tmp = build_snapshot_setting(repo->snapshots);
+ fprintf(f, "repo.snapshots=%s\n", tmp ? tmp : "");
+ free(tmp);
+ }
+ if (repo->snapshot_prefix)
+ fprintf(f, "repo.snapshot-prefix=%s\n", repo->snapshot_prefix);
+ if (repo->max_stats != ctx.cfg.max_stats)
+ fprintf(f, "repo.max-stats=%s\n",
+ cgit_find_stats_periodname(repo->max_stats));
+ if (repo->logo)
+ fprintf(f, "repo.logo=%s\n", repo->logo);
+ if (repo->logo_link)
+ fprintf(f, "repo.logo-link=%s\n", repo->logo_link);
+ fprintf(f, "repo.enable-remote-branches=%d\n", repo->enable_remote_branches);
+ fprintf(f, "repo.enable-subject-links=%d\n", repo->enable_subject_links);
+ fprintf(f, "repo.enable-html-serving=%d\n", repo->enable_html_serving);
+ if (repo->branch_sort == 1)
+ fprintf(f, "repo.branch-sort=age\n");
+ if (repo->commit_sort) {
+ if (repo->commit_sort == 1)
+ fprintf(f, "repo.commit-sort=date\n");
+ else if (repo->commit_sort == 2)
+ fprintf(f, "repo.commit-sort=topo\n");
+ }
+ fprintf(f, "repo.hide=%d\n", repo->hide);
+ fprintf(f, "repo.ignore=%d\n", repo->ignore);
+ fprintf(f, "\n");
+}
+
+static void print_repolist(FILE *f, struct cgit_repolist *list, int start)
+{
+ int i;
+
+ for (i = start; i < list->count; i++)
+ print_repo(f, &list->repos[i]);
+}
+
+static void cgit_parse_args(int argc, const char **argv)
+{
+ int i;
+ const char *arg;
+ int scan = 0;
+
+ for (i = 1; i < argc; i++) {
+ if (!strcmp(argv[i], "--version")) {
+ printf("CGit %s | https://git.zx2c4.com/cgit/\n", CGIT_VERSION);
+ exit(0);
+ }
+ if (!strcmp(argv[i], "--nohttp")) {
+ ctx.env.no_http = "1";
+ } else if (skip_prefix(argv[i], "--query=", &arg)) {
+ ctx.qry.raw = xstrdup(arg);
+ } else if (skip_prefix(argv[i], "--repo=", &arg)) {
+ ctx.qry.repo = xstrdup(arg);
+ } else if (skip_prefix(argv[i], "--page=", &arg)) {
+ ctx.qry.page = xstrdup(arg);
+ } else if (skip_prefix(argv[i], "--head=", &arg)) {
+ ctx.qry.head = xstrdup(arg);
+ } else if (skip_prefix(argv[i], "--oid=", &arg)) {
+ ctx.qry.oid = xstrdup(arg);
+ ctx.qry.has_oid = 1;
+ } else if (skip_prefix(argv[i], "--ofs=", &arg)) {
+ ctx.qry.ofs = atoi(arg);
+ } else if (skip_prefix(argv[i], "--scan-tree=", &arg) ||
+ skip_prefix(argv[i], "--scan-path=", &arg)) {
+ /*
+ * HACK: The global snapshot bit mask defines the set
+ * of allowed snapshot formats, but the config file
+ * hasn't been parsed yet so the mask is currently 0.
+ * By setting all bits high before scanning we make
+ * sure that any in-repo cgitrc snapshot setting is
+ * respected by scan_tree().
+ *
+ * NOTE: We assume that there aren't more than 8
+ * different snapshot formats supported by cgit...
+ */
+ ctx.cfg.snapshots = 0xFF;
+ scan++;
+ scan_tree(arg);
+ }
+ }
+ if (scan) {
+ qsort(cgit_repolist.repos, cgit_repolist.count,
+ sizeof(struct cgit_repo), cmp_repos);
+ print_repolist(stdout, &cgit_repolist, 0);
+ exit(0);
+ }
+}
+
+static NORETURN void cgit_die_routine(const char *msg, va_list params)
+{
+ cgit_vprint_error_page(400, "Bad request", msg, params);
+ exit(0);
+}
+
+int cmd_main(int argc, const char **argv)
+{
+ const char *path;
+
+ set_die_routine(cgit_die_routine);
+
+ prepare_context();
+ cgit_repolist.length = 0;
+ cgit_repolist.count = 0;
+ cgit_repolist.repos = NULL;
+
+ cgit_parse_args(argc, argv);
+ parse_configfile(expand_macros(ctx.env.cgit_config), config_cb);
+ ctx.repo = NULL;
+ http_parse_querystring(ctx.qry.raw, querystring_cb);
+
+ /* If virtual-root isn't specified in cgitrc, lets pretend
+ * that virtual-root equals SCRIPT_NAME, minus any possibly
+ * trailing slashes.
+ */
+ if (!ctx.cfg.virtual_root && ctx.cfg.script_name)
+ ctx.cfg.virtual_root = ensure_end(ctx.cfg.script_name, '/');
+
+ /* If no url parameter is specified on the querystring, use PATH_INFO
+ * as url. This allows cgit to work with virtual urls without the need
+ * for rewriterules in the webserver.
+ */
+ path = ctx.env.path_info;
+ if (!ctx.qry.url && path) {
+ if (path[0] == '/')
+ path++;
+ ctx.qry.url = xstrdup(path);
+ if (ctx.qry.raw) {
+ char *newqry = fmtalloc("%s?%s", path, ctx.qry.raw);
+ free(ctx.qry.raw);
+ ctx.qry.raw = newqry;
+ } else
+ ctx.qry.raw = xstrdup(ctx.qry.url);
+ cgit_parse_url(ctx.qry.url);
+ }
+
+ process_request();
+ return 0;
+}
diff --git a/crates/gilti/assets/gilti.css b/cgit/cgit.css
index e9a423e..1153711 100644
--- a/crates/gilti/assets/gilti.css
+++ b/cgit/cgit.css
@@ -2,7 +2,7 @@
* SPDX-License-Identifier: GPL-2.0-only
*/
-div#gilti {
+div#cgit {
padding: 0em;
margin: 0em;
font-family: sans-serif;
@@ -12,40 +12,40 @@ div#gilti {
padding: 4px;
}
-div#gilti a {
+div#cgit a {
color: blue;
text-decoration: none;
}
-div#gilti a:hover {
+div#cgit a:hover {
text-decoration: underline;
}
-div#gilti table {
+div#cgit table {
border-collapse: collapse;
}
-div#gilti table#header {
+div#cgit table#header {
width: 100%;
margin-bottom: 1em;
}
-div#gilti table#header td.logo {
+div#cgit table#header td.logo {
width: 96px;
vertical-align: top;
}
-div#gilti table#header td.main {
+div#cgit table#header td.main {
font-size: 250%;
padding-left: 10px;
white-space: nowrap;
}
-div#gilti table#header td.main a {
+div#cgit table#header td.main a {
color: #000;
}
-div#gilti table#header td.form {
+div#cgit table#header td.form {
text-align: right;
vertical-align: bottom;
padding-right: 1em;
@@ -53,19 +53,19 @@ div#gilti table#header td.form {
white-space: nowrap;
}
-div#gilti table#header td.form form,
-div#gilti table#header td.form input,
-div#gilti table#header td.form select {
+div#cgit table#header td.form form,
+div#cgit table#header td.form input,
+div#cgit table#header td.form select {
font-size: 90%;
}
-div#gilti table#header td.sub {
+div#cgit table#header td.sub {
color: #777;
border-top: solid 1px #ccc;
padding-left: 10px;
}
-div#gilti table.tabs {
+div#cgit table.tabs {
border-bottom: solid 3px #ccc;
border-collapse: collapse;
margin-top: 2em;
@@ -73,100 +73,100 @@ div#gilti table.tabs {
width: 100%;
}
-div#gilti table.tabs td {
+div#cgit table.tabs td {
padding: 0px 1em;
vertical-align: bottom;
}
-div#gilti table.tabs td a {
+div#cgit table.tabs td a {
padding: 2px 0.75em;
color: #777;
font-size: 110%;
}
-div#gilti table.tabs td a.active {
+div#cgit table.tabs td a.active {
color: #000;
background-color: #ccc;
}
-div#gilti table.tabs a[href^="http://"]:after, div#gilti table.tabs a[href^="https://"]:after {
+div#cgit table.tabs a[href^="http://"]:after, div#cgit table.tabs a[href^="https://"]:after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAAmJLR0QA/4ePzL8AAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfgAhcJDQY+gm2TAAAAHWlUWHRDb21tZW50AAAAAABDcmVhdGVkIHdpdGggR0lNUGQuZQcAAABbSURBVAhbY2BABs4MU4CwhYHBh2Erww4wrGFQZHjI8B8IgUIscJWyDHcggltQhI4zGDCcRwhChPggHIggP1QoAVmQkSETrGoHsiAEsACtBYN0oDAMbgU6EBcAAL2eHUt4XUU4AAAAAElFTkSuQmCC);
opacity: 0.5;
margin: 0 0 0 5px;
}
-div#gilti table.tabs td.form {
+div#cgit table.tabs td.form {
text-align: right;
}
-div#gilti table.tabs td.form form {
+div#cgit table.tabs td.form form {
padding-bottom: 2px;
font-size: 90%;
white-space: nowrap;
}
-div#gilti table.tabs td.form input,
-div#gilti table.tabs td.form select {
+div#cgit table.tabs td.form input,
+div#cgit table.tabs td.form select {
font-size: 90%;
}
-div#gilti div.path {
+div#cgit div.path {
margin: 0px;
padding: 5px 2em 2px 2em;
color: #000;
background-color: #eee;
}
-div#gilti div.content {
+div#cgit div.content {
margin: 0px;
padding: 2em;
border-bottom: solid 3px #ccc;
}
-div#gilti table.list {
+div#cgit table.list {
width: 100%;
border: none;
border-collapse: collapse;
}
-div#gilti table.list tr {
+div#cgit table.list tr {
background: white;
}
-div#gilti table.list tr.logheader {
+div#cgit table.list tr.logheader {
background: #eee;
}
-div#gilti table.list tr:nth-child(even) {
+div#cgit table.list tr:nth-child(even) {
background: #f7f7f7;
}
-div#gilti table.list tr:nth-child(odd) {
+div#cgit table.list tr:nth-child(odd) {
background: white;
}
-div#gilti table.list tr:hover {
+div#cgit table.list tr:hover {
background: #eee;
}
-div#gilti table.list tr.nohover {
+div#cgit table.list tr.nohover {
background: white;
}
-div#gilti table.list tr.nohover:hover {
+div#cgit table.list tr.nohover:hover {
background: white;
}
-div#gilti table.list tr.nohover-highlight:hover:nth-child(even) {
+div#cgit table.list tr.nohover-highlight:hover:nth-child(even) {
background: #f7f7f7;
}
-div#gilti table.list tr.nohover-highlight:hover:nth-child(odd) {
+div#cgit table.list tr.nohover-highlight:hover:nth-child(odd) {
background: white;
}
-div#gilti table.list th {
+div#cgit table.list th {
font-weight: bold;
/* color: #888;
border-top: dashed 1px #888;
@@ -176,98 +176,98 @@ div#gilti table.list th {
vertical-align: baseline;
}
-div#gilti table.list td {
+div#cgit table.list td {
border: none;
padding: 0.1em 0.5em 0.1em 0.5em;
}
-div#gilti table.list td.commitgraph {
+div#cgit table.list td.commitgraph {
font-family: monospace;
white-space: pre;
}
-div#gilti table.list td.commitgraph .column1 {
+div#cgit table.list td.commitgraph .column1 {
color: #a00;
}
-div#gilti table.list td.commitgraph .column2 {
+div#cgit table.list td.commitgraph .column2 {
color: #0a0;
}
-div#gilti table.list td.commitgraph .column3 {
+div#cgit table.list td.commitgraph .column3 {
color: #aa0;
}
-div#gilti table.list td.commitgraph .column4 {
+div#cgit table.list td.commitgraph .column4 {
color: #00a;
}
-div#gilti table.list td.commitgraph .column5 {
+div#cgit table.list td.commitgraph .column5 {
color: #a0a;
}
-div#gilti table.list td.commitgraph .column6 {
+div#cgit table.list td.commitgraph .column6 {
color: #0aa;
}
-div#gilti table.list td.logsubject {
+div#cgit table.list td.logsubject {
font-family: monospace;
font-weight: bold;
}
-div#gilti table.list td.logmsg {
+div#cgit table.list td.logmsg {
font-family: monospace;
white-space: pre;
padding: 0 0.5em;
}
-div#gilti table.list td a {
+div#cgit table.list td a {
color: black;
}
-div#gilti table.list td a.ls-dir {
+div#cgit table.list td a.ls-dir {
font-weight: bold;
color: #00f;
}
-div#gilti table.list td a:hover {
+div#cgit table.list td a:hover {
color: #00f;
}
-div#gilti img {
+div#cgit img {
border: none;
}
-div#gilti input#switch-btn {
+div#cgit input#switch-btn {
margin: 2px 0px 0px 0px;
}
-div#gilti td#sidebar input.txt {
+div#cgit td#sidebar input.txt {
width: 100%;
margin: 2px 0px 0px 0px;
}
-div#gilti table#grid {
+div#cgit table#grid {
margin: 0px;
}
-div#gilti td#content {
+div#cgit td#content {
vertical-align: top;
padding: 1em 2em 1em 1em;
border: none;
}
-div#gilti div#summary {
+div#cgit div#summary {
vertical-align: top;
margin-bottom: 1em;
}
-div#gilti div#summary pre {
+div#cgit div#summary pre {
margin: 0;
white-space: pre-wrap;
}
-div#gilti table#downloads {
+div#cgit table#downloads {
float: right;
border-collapse: collapse;
border: solid 1px #777;
@@ -275,197 +275,197 @@ div#gilti table#downloads {
margin-bottom: 0.5em;
}
-div#gilti table#downloads th {
+div#cgit table#downloads th {
background-color: #ccc;
}
-div#gilti div#blob {
+div#cgit div#blob {
border: solid 1px black;
}
-div#gilti div.error {
+div#cgit div.error {
color: red;
font-weight: bold;
margin: 1em 2em;
}
-div#gilti a.ls-blob, div#gilti a.ls-dir, div#gilti .ls-mod {
+div#cgit a.ls-blob, div#cgit a.ls-dir, div#cgit .ls-mod {
font-family: monospace;
}
-div#gilti td.ls-size {
+div#cgit td.ls-size {
text-align: right;
font-family: monospace;
width: 10em;
}
-div#gilti td.ls-mode {
+div#cgit td.ls-mode {
font-family: monospace;
width: 10em;
}
-div#gilti table.blob {
+div#cgit table.blob {
margin-top: 0.5em;
border-top: solid 1px black;
}
-div#gilti table.blob td.hashes,
-div#gilti table.blob td.lines {
+div#cgit table.blob td.hashes,
+div#cgit table.blob td.lines {
margin: 0; padding: 0 0 0 0.5em;
vertical-align: top;
color: black;
}
-div#gilti table.blob td.linenumbers {
+div#cgit table.blob td.linenumbers {
margin: 0; padding: 0 0.5em 0 0.5em;
vertical-align: top;
text-align: right;
border-right: 1px solid gray;
}
-div#gilti table.blob pre {
+div#cgit table.blob pre {
padding: 0; margin: 0;
}
-div#gilti table.blob td.linenumbers a,
-div#gilti table.ssdiff td.lineno a {
+div#cgit table.blob td.linenumbers a,
+div#cgit table.ssdiff td.lineno a {
color: gray;
text-align: right;
text-decoration: none;
}
-div#gilti table.blob td.linenumbers a:hover,
-div#gilti table.ssdiff td.lineno a:hover {
+div#cgit table.blob td.linenumbers a:hover,
+div#cgit table.ssdiff td.lineno a:hover {
color: black;
}
-div#gilti table.blob td.linenumbers a:target:before {
+div#cgit table.blob td.linenumbers a:target:before {
color: red;
content: "\2BA9";
}
-div#gilti table.blame td.hashes,
-div#gilti table.blame td.lines,
-div#gilti table.blame td.linenumbers {
+div#cgit table.blame td.hashes,
+div#cgit table.blame td.lines,
+div#cgit table.blame td.linenumbers {
padding: 0;
}
-div#gilti table.blame td.hashes div.alt,
-div#gilti table.blame td.lines div.alt {
+div#cgit table.blame td.hashes div.alt,
+div#cgit table.blame td.lines div.alt {
padding: 0 0.5em 0 0.5em;
}
-div#gilti table.blame td.linenumbers div.alt {
+div#cgit table.blame td.linenumbers div.alt {
padding: 0 0.5em 0 0;
}
-div#gilti table.blame div.alt:nth-child(even) {
+div#cgit table.blame div.alt:nth-child(even) {
background: #eee;
}
-div#gilti table.blame div.alt:nth-child(odd) {
+div#cgit table.blame div.alt:nth-child(odd) {
background: white;
}
-div#gilti table.blame td.lines > div {
+div#cgit table.blame td.lines > div {
position: relative;
}
-div#gilti table.blame td.lines > div > pre {
+div#cgit table.blame td.lines > div > pre {
padding: 0 0 0 0.5em;
position: absolute;
top: 0;
}
-div#gilti table.blame .oid {
+div#cgit table.blame .oid {
font-size: 100%;
}
-div#gilti table.bin-blob {
+div#cgit table.bin-blob {
margin-top: 0.5em;
border: solid 1px black;
}
-div#gilti table.bin-blob th {
+div#cgit table.bin-blob th {
font-family: monospace;
white-space: pre;
border: solid 1px #777;
padding: 0.5em 1em;
}
-div#gilti table.bin-blob td {
+div#cgit table.bin-blob td {
font-family: monospace;
white-space: pre;
border-left: solid 1px #777;
padding: 0em 1em;
}
-div#gilti table.nowrap td {
+div#cgit table.nowrap td {
white-space: nowrap;
}
-div#gilti table.commit-info {
+div#cgit table.commit-info {
border-collapse: collapse;
margin-top: 1.5em;
}
-div#gilti div.gilti-panel {
+div#cgit div.cgit-panel {
float: right;
margin-top: 1.5em;
}
-div#gilti div.gilti-panel table {
+div#cgit div.cgit-panel table {
border-collapse: collapse;
border: solid 1px #aaa;
background-color: #eee;
}
-div#gilti div.gilti-panel th {
+div#cgit div.cgit-panel th {
text-align: center;
}
-div#gilti div.gilti-panel td {
+div#cgit div.cgit-panel td {
padding: 0.25em 0.5em;
}
-div#gilti div.gilti-panel td.label {
+div#cgit div.cgit-panel td.label {
padding-right: 0.5em;
}
-div#gilti div.gilti-panel td.ctrl {
+div#cgit div.cgit-panel td.ctrl {
padding-left: 0.5em;
}
-div#gilti table.commit-info th {
+div#cgit table.commit-info th {
text-align: left;
font-weight: normal;
padding: 0.1em 1em 0.1em 0.1em;
vertical-align: top;
}
-div#gilti table.commit-info td {
+div#cgit table.commit-info td {
font-weight: normal;
padding: 0.1em 1em 0.1em 0.1em;
}
-div#gilti div.commit-subject {
+div#cgit div.commit-subject {
font-weight: bold;
font-size: 125%;
margin: 1.5em 0em 0.5em 0em;
padding: 0em;
}
-div#gilti div.commit-msg {
+div#cgit div.commit-msg {
white-space: pre;
font-family: monospace;
}
-div#gilti div.notes-header {
+div#cgit div.notes-header {
font-weight: bold;
padding-top: 1.5em;
}
-div#gilti div.notes {
+div#cgit div.notes {
white-space: pre;
font-family: monospace;
border: solid 1px #ee9;
@@ -474,22 +474,22 @@ div#gilti div.notes {
float: left;
}
-div#gilti div.notes-footer {
+div#cgit div.notes-footer {
clear: left;
}
-div#gilti div.diffstat-header {
+div#cgit div.diffstat-header {
font-weight: bold;
padding-top: 1.5em;
}
-div#gilti table.diffstat {
+div#cgit table.diffstat {
border-collapse: collapse;
border: solid 1px #aaa;
background-color: #eee;
}
-div#gilti table.diffstat th {
+div#cgit table.diffstat th {
font-weight: normal;
text-align: left;
text-decoration: underline;
@@ -497,197 +497,197 @@ div#gilti table.diffstat th {
font-size: 100%;
}
-div#gilti table.diffstat td {
+div#cgit table.diffstat td {
padding: 0.2em 0.2em 0.1em 0.1em;
font-size: 100%;
border: none;
}
-div#gilti table.diffstat td.mode {
+div#cgit table.diffstat td.mode {
white-space: nowrap;
}
-div#gilti table.diffstat td span.modechange {
+div#cgit table.diffstat td span.modechange {
padding-left: 1em;
color: red;
}
-div#gilti table.diffstat td.add a {
+div#cgit table.diffstat td.add a {
color: green;
}
-div#gilti table.diffstat td.del a {
+div#cgit table.diffstat td.del a {
color: red;
}
-div#gilti table.diffstat td.upd a {
+div#cgit table.diffstat td.upd a {
color: blue;
}
-div#gilti table.diffstat td.graph {
+div#cgit table.diffstat td.graph {
width: 500px;
vertical-align: middle;
}
-div#gilti table.diffstat td.graph table {
+div#cgit table.diffstat td.graph table {
border: none;
}
-div#gilti table.diffstat td.graph td {
+div#cgit table.diffstat td.graph td {
padding: 0px;
border: 0px;
height: 7pt;
}
-div#gilti table.diffstat td.graph td.add {
+div#cgit table.diffstat td.graph td.add {
background-color: #5c5;
}
-div#gilti table.diffstat td.graph td.rem {
+div#cgit table.diffstat td.graph td.rem {
background-color: #c55;
}
-div#gilti div.diffstat-summary {
+div#cgit div.diffstat-summary {
color: #888;
padding-top: 0.5em;
}
-div#gilti table.diff {
+div#cgit table.diff {
width: 100%;
}
-div#gilti table.diff td {
+div#cgit table.diff td {
font-family: monospace;
white-space: pre;
}
-div#gilti table.diff td div.head {
+div#cgit table.diff td div.head {
font-weight: bold;
margin-top: 1em;
color: black;
}
-div#gilti table.diff td div.hunk {
+div#cgit table.diff td div.hunk {
color: #009;
}
-div#gilti table.diff td div.add {
+div#cgit table.diff td div.add {
color: green;
}
-div#gilti table.diff td div.del {
+div#cgit table.diff td div.del {
color: red;
}
-div#gilti .oid {
+div#cgit .oid {
font-family: monospace;
font-size: 90%;
}
-div#gilti .left {
+div#cgit .left {
text-align: left;
}
-div#gilti .right {
+div#cgit .right {
text-align: right;
}
-div#gilti table.list td.reposection {
+div#cgit table.list td.reposection {
font-style: italic;
color: #888;
}
-div#gilti a.button {
+div#cgit a.button {
font-size: 80%;
padding: 0em 0.5em;
}
-div#gilti a.primary {
+div#cgit a.primary {
font-size: 100%;
}
-div#gilti a.secondary {
+div#cgit a.secondary {
font-size: 90%;
}
-div#gilti td.toplevel-repo {
+div#cgit td.toplevel-repo {
}
-div#gilti table.list td.sublevel-repo {
+div#cgit table.list td.sublevel-repo {
padding-left: 1.5em;
}
-div#gilti ul.pager {
+div#cgit ul.pager {
list-style-type: none;
text-align: center;
margin: 1em 0em 0em 0em;
padding: 0;
}
-div#gilti ul.pager li {
+div#cgit ul.pager li {
display: inline-block;
margin: 0.25em 0.5em;
}
-div#gilti ul.pager a {
+div#cgit ul.pager a {
color: #777;
}
-div#gilti ul.pager .current {
+div#cgit ul.pager .current {
font-weight: bold;
}
-div#gilti span.age-mins {
+div#cgit span.age-mins {
font-weight: bold;
color: #080;
}
-div#gilti span.age-hours {
+div#cgit span.age-hours {
color: #080;
}
-div#gilti span.age-days {
+div#cgit span.age-days {
color: #040;
}
-div#gilti span.age-weeks {
+div#cgit span.age-weeks {
color: #444;
}
-div#gilti span.age-months {
+div#cgit span.age-months {
color: #888;
}
-div#gilti span.age-years {
+div#cgit span.age-years {
color: #bbb;
}
-div#gilti span.insertions {
+div#cgit span.insertions {
color: #080;
}
-div#gilti span.deletions {
+div#cgit span.deletions {
color: #800;
}
-div#gilti div.footer {
+div#cgit div.footer {
margin-top: 0.5em;
text-align: center;
font-size: 80%;
color: #ccc;
}
-div#gilti div.footer a {
+div#cgit div.footer a {
color: #ccc;
text-decoration: none;
}
-div#gilti div.footer a:hover {
+div#cgit div.footer a:hover {
text-decoration: underline;
}
-div#gilti a.branch-deco {
+div#cgit a.branch-deco {
color: #000;
margin: 0px 0.5em;
padding: 0px 0.25em;
@@ -696,7 +696,7 @@ div#gilti a.branch-deco {
border-radius: 2px;
}
-div#gilti a.tag-deco {
+div#cgit a.tag-deco {
color: #000;
margin: 0px 0.5em;
padding: 0px 0.25em;
@@ -705,7 +705,7 @@ div#gilti a.tag-deco {
border-radius: 2px;
}
-div#gilti a.tag-annotated-deco {
+div#cgit a.tag-annotated-deco {
color: #000;
margin: 0px 0.5em;
padding: 0px 0.25em;
@@ -714,7 +714,7 @@ div#gilti a.tag-annotated-deco {
border-radius: 2px;
}
-div#gilti a.remote-deco {
+div#cgit a.remote-deco {
color: #000;
margin: 0px 0.5em;
padding: 0px 0.25em;
@@ -723,7 +723,7 @@ div#gilti a.remote-deco {
border-radius: 2px;
}
-div#gilti a.deco {
+div#cgit a.deco {
color: #000;
margin: 0px 0.5em;
padding: 0px 0.25em;
@@ -732,97 +732,97 @@ div#gilti a.deco {
border-radius: 2px;
}
-div#gilti div.commit-subject a.branch-deco,
-div#gilti div.commit-subject a.tag-deco,
-div#gilti div.commit-subject a.tag-annotated-deco,
-div#gilti div.commit-subject a.remote-deco,
-div#gilti div.commit-subject a.deco {
+div#cgit div.commit-subject a.branch-deco,
+div#cgit div.commit-subject a.tag-deco,
+div#cgit div.commit-subject a.tag-annotated-deco,
+div#cgit div.commit-subject a.remote-deco,
+div#cgit div.commit-subject a.deco {
margin-left: 1em;
font-size: 75%;
}
-div#gilti table.stats {
+div#cgit table.stats {
border: solid 1px black;
border-collapse: collapse;
}
-div#gilti table.stats th {
+div#cgit table.stats th {
text-align: left;
padding: 1px 0.5em;
background-color: #eee;
border: solid 1px black;
}
-div#gilti table.stats td {
+div#cgit table.stats td {
text-align: right;
padding: 1px 0.5em;
border: solid 1px black;
}
-div#gilti table.stats td.total {
+div#cgit table.stats td.total {
font-weight: bold;
text-align: left;
}
-div#gilti table.stats td.sum {
+div#cgit table.stats td.sum {
color: #c00;
font-weight: bold;
/* background-color: #eee; */
}
-div#gilti table.stats td.left {
+div#cgit table.stats td.left {
text-align: left;
}
-div#gilti table.vgraph {
+div#cgit table.vgraph {
border-collapse: separate;
border: solid 1px black;
height: 200px;
}
-div#gilti table.vgraph th {
+div#cgit table.vgraph th {
background-color: #eee;
font-weight: bold;
border: solid 1px white;
padding: 1px 0.5em;
}
-div#gilti table.vgraph td {
+div#cgit table.vgraph td {
vertical-align: bottom;
padding: 0px 10px;
}
-div#gilti table.vgraph div.bar {
+div#cgit table.vgraph div.bar {
background-color: #eee;
}
-div#gilti table.hgraph {
+div#cgit table.hgraph {
border: solid 1px black;
width: 800px;
}
-div#gilti table.hgraph th {
+div#cgit table.hgraph th {
background-color: #eee;
font-weight: bold;
border: solid 1px black;
padding: 1px 0.5em;
}
-div#gilti table.hgraph td {
+div#cgit table.hgraph td {
vertical-align: middle;
padding: 2px 2px;
}
-div#gilti table.hgraph div.bar {
+div#cgit table.hgraph div.bar {
background-color: #eee;
height: 1em;
}
-div#gilti table.ssdiff {
+div#cgit table.ssdiff {
width: 100%;
}
-div#gilti table.ssdiff td {
+div#cgit table.ssdiff td {
font-size: 75%;
font-family: monospace;
white-space: pre;
@@ -831,53 +831,53 @@ div#gilti table.ssdiff td {
border-right: solid 1px #aaa;
}
-div#gilti table.ssdiff td.add {
+div#cgit table.ssdiff td.add {
color: black;
background: #cfc;
min-width: 50%;
}
-div#gilti table.ssdiff td.add_dark {
+div#cgit table.ssdiff td.add_dark {
color: black;
background: #aca;
min-width: 50%;
}
-div#gilti table.ssdiff span.add {
+div#cgit table.ssdiff span.add {
background: #cfc;
font-weight: bold;
}
-div#gilti table.ssdiff td.del {
+div#cgit table.ssdiff td.del {
color: black;
background: #fcc;
min-width: 50%;
}
-div#gilti table.ssdiff td.del_dark {
+div#cgit table.ssdiff td.del_dark {
color: black;
background: #caa;
min-width: 50%;
}
-div#gilti table.ssdiff span.del {
+div#cgit table.ssdiff span.del {
background: #fcc;
font-weight: bold;
}
-div#gilti table.ssdiff td.changed {
+div#cgit table.ssdiff td.changed {
color: black;
background: #ffc;
min-width: 50%;
}
-div#gilti table.ssdiff td.changed_dark {
+div#cgit table.ssdiff td.changed_dark {
color: black;
background: #cca;
min-width: 50%;
}
-div#gilti table.ssdiff td.lineno {
+div#cgit table.ssdiff td.lineno {
color: black;
background: #eee;
text-align: right;
@@ -885,34 +885,34 @@ div#gilti table.ssdiff td.lineno {
min-width: 3em;
}
-div#gilti table.ssdiff td.hunk {
+div#cgit table.ssdiff td.hunk {
color: black;
background: #ccf;
border-top: solid 1px #aaa;
border-bottom: solid 1px #aaa;
}
-div#gilti table.ssdiff td.head {
+div#cgit table.ssdiff td.head {
border-top: solid 1px #aaa;
border-bottom: solid 1px #aaa;
}
-div#gilti table.ssdiff td.head div.head {
+div#cgit table.ssdiff td.head div.head {
font-weight: bold;
color: black;
}
-div#gilti table.ssdiff td.foot {
+div#cgit table.ssdiff td.foot {
border-top: solid 1px #aaa;
border-left: none;
border-right: none;
border-bottom: none;
}
-div#gilti table.ssdiff td.space {
+div#cgit table.ssdiff td.space {
border: none;
}
-div#gilti table.ssdiff td.space div {
+div#cgit table.ssdiff td.space div {
min-height: 3em;
}
diff --git a/cgit/cgit.h b/cgit/cgit.h
new file mode 100644
--- /dev/null
+++ b/cgit/cgit.h
@@ -0,0 +1,364 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-FileCopyrightText: 2026 Nikolay Govorov
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef CGIT_H
+#define CGIT_H
+
+#include <stdbool.h>
+
+#include <git-compat-util.h>
+
+#include <archive.h>
+#include <commit.h>
+#include <diffcore.h>
+#include <diff.h>
+#include <environment.h>
+#include <graph.h>
+#include <grep.h>
+#include <hex.h>
+#include <log-tree.h>
+#include <notes.h>
+#include <object.h>
+#include <object-name.h>
+#include <odb.h>
+#include <path.h>
+#include <refs.h>
+#include <revision.h>
+#include <setup.h>
+#include <string-list.h>
+#include <strvec.h>
+#include <tag.h>
+#include <tree.h>
+#include <utf8.h>
+#include <wrapper.h>
+#include <xdiff-interface.h>
+#include <xdiff/xdiff.h>
+
+/* Add isgraph(x) to Git's sane ctype support (see git-compat-util.h) */
+#undef isgraph
+#define isgraph(x) (isprint((x)) && !isspace((x)))
+
+
+/*
+ * Limits used for relative dates
+ */
+#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)
+
+
+/*
+ * Default encoding
+ */
+#define PAGE_ENCODING "UTF-8"
+
+#define BIT(x) (1U << (x))
+
+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);
+
+typedef enum {
+ DIFF_UNIFIED, DIFF_SSDIFF, DIFF_STATONLY
+} diff_type;
+
+struct cgit_exec_filter {
+ char *cmd;
+ char **argv;
+ int old_stdout;
+ int pid;
+};
+
+struct cgit_repo {
+ char *url;
+ char *name;
+ char *path;
+ char *desc;
+ char *extra_head_content;
+ char *owner;
+ char *homepage;
+ char *defbranch;
+ char *module_link;
+ struct string_list readme;
+ char *section;
+ char *clone_url;
+ char *logo;
+ char *logo_link;
+ char *snapshot_prefix;
+ int snapshots;
+ int enable_blame;
+ int enable_commit_graph;
+ int enable_follow_links;
+ int enable_log_filecount;
+ int enable_log_linecount;
+ int enable_remote_branches;
+ int enable_subject_links;
+ int enable_html_serving;
+ int max_stats;
+ int branch_sort;
+ int commit_sort;
+ time_t mtime;
+ struct string_list submodules;
+ int hide;
+ int ignore;
+};
+
+struct cgit_repolist {
+ int length;
+ int count;
+ struct cgit_repo *repos;
+};
+
+struct commitinfo {
+ struct commit *commit;
+ char *author;
+ char *author_email;
+ unsigned long author_date;
+ int author_tz;
+ char *committer;
+ char *committer_email;
+ unsigned long committer_date;
+ int committer_tz;
+ char *subject;
+ char *msg;
+ char *msg_encoding;
+};
+
+struct taginfo {
+ char *tagger;
+ char *tagger_email;
+ unsigned long tagger_date;
+ int tagger_tz;
+ char *msg;
+};
+
+struct refinfo {
+ const char *refname;
+ struct object *object;
+ union {
+ struct taginfo *tag;
+ struct commitinfo *commit;
+ };
+};
+
+struct reflist {
+ struct refinfo **refs;
+ int alloc;
+ int count;
+};
+
+struct cgit_query {
+ int has_oid;
+ int has_difftype;
+ char *raw;
+ char *repo;
+ char *page;
+ char *search;
+ char *grep;
+ char *head;
+ char *oid;
+ char *oid2;
+ char *path;
+ char *name;
+ char *url;
+ char *period;
+ int ofs;
+ int nohead;
+ char *sort;
+ int showmsg;
+ diff_type difftype;
+ int show_all;
+ int context;
+ int ignorews;
+ int follow;
+ char *vpath;
+};
+
+struct cgit_config {
+ char *agefile;
+ char *clone_prefix;
+ char *clone_url;
+ char *favicon;
+ char *footer;
+ char *head_include;
+ char *header;
+ char *logo;
+ char *logo_link;
+ char *mimetype_file;
+ char *module_link;
+ char *project_list;
+ struct string_list readme;
+ struct string_list css;
+ char *robots;
+ char *root_title;
+ char *root_desc;
+ char *root_readme;
+ char *script_name;
+ char *section;
+ char *repository_sort;
+ char *virtual_root; /* Always ends with '/'. */
+ char *strict_export;
+ int case_sensitive_sort;
+ int embedded;
+ int enable_follow_links;
+ int enable_http_clone;
+ int enable_index_links;
+ int enable_index_owner;
+ int enable_blame;
+ int enable_commit_graph;
+ int enable_log_filecount;
+ int enable_log_linecount;
+ int enable_remote_branches;
+ int enable_subject_links;
+ int enable_html_serving;
+ int enable_tree_linenumbers;
+ int enable_git_config;
+ int local_time;
+ int max_atom_items;
+ int max_repo_count;
+ int max_commit_count;
+ int max_lock_attempts;
+ int max_msg_len;
+ int max_repodesc_len;
+ int max_blob_size;
+ int max_stats;
+ int noplainemail;
+ int noheader;
+ int renamelimit;
+ int remove_suffix;
+ int scan_hidden_path;
+ int section_from_path;
+ int snapshots;
+ int section_sort;
+ int summary_branches;
+ int summary_log;
+ int summary_tags;
+ diff_type difftype;
+ int branch_sort;
+ int commit_sort;
+ struct string_list mimetypes;
+ struct string_list js;
+};
+
+struct cgit_page {
+ time_t modified;
+ size_t size;
+ const char *mimetype;
+ const char *charset;
+ const char *filename;
+ const char *etag;
+ const char *title;
+ int status;
+ const char *statusmsg;
+};
+
+struct cgit_environment {
+ const char *cgit_config;
+ const char *http_host;
+ const char *https;
+ const char *no_http;
+ const char *path_info;
+ const char *query_string;
+ const char *request_method;
+ const char *script_name;
+ const char *server_name;
+ const char *server_port;
+};
+
+struct cgit_context {
+ struct cgit_environment env;
+ struct cgit_query qry;
+ struct cgit_config cfg;
+ struct cgit_repo *repo;
+ struct cgit_page page;
+};
+
+typedef int (*write_archive_fn_t)(const char *, const char *);
+
+struct cgit_snapshot_format {
+ const char *suffix;
+ const char *mimetype;
+ write_archive_fn_t write_func;
+};
+
+extern const char *cgit_version;
+
+extern struct cgit_repolist cgit_repolist;
+extern struct cgit_context ctx;
+extern const struct cgit_snapshot_format cgit_snapshot_formats[];
+
+extern char *cgit_default_repo_desc;
+extern struct cgit_repo *cgit_add_repo(const char *url);
+extern struct cgit_repo *cgit_get_repoinfo(const char *url);
+extern void cgit_repo_config(struct cgit_repo *repo, const char *name,
+ const char *value);
+
+extern int chk_zero(int result, char *msg);
+extern int chk_positive(int result, char *msg);
+extern int chk_non_negative(int result, char *msg);
+
+extern char *trim_end(const char *str, char c);
+extern char *ensure_end(const char *str, char c);
+
+extern void strbuf_ensure_end(struct strbuf *sb, char c);
+
+extern void cgit_add_ref(struct reflist *list, struct refinfo *ref);
+extern void cgit_free_reflist_inner(struct reflist *list);
+extern int cgit_refs_cb(const struct reference *ref, void *cb_data);
+
+extern void cgit_free_commitinfo(struct commitinfo *info);
+extern void cgit_free_taginfo(struct taginfo *info);
+
+void cgit_diff_tree_cb(struct diff_queue_struct *q,
+ struct diff_options *options, void *data);
+
+extern int 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);
+
+extern void cgit_diff_tree(const struct object_id *old_oid,
+ const struct object_id *new_oid,
+ filepair_fn fn, const char *prefix, int ignorews);
+
+extern void cgit_diff_commit(struct commit *commit, filepair_fn fn,
+ const char *prefix);
+
+__attribute__((format (printf,1,2)))
+extern char *fmt(const char *format,...);
+
+__attribute__((format (printf,1,2)))
+extern char *fmtalloc(const char *format,...);
+
+extern struct commitinfo *cgit_parse_commit(struct commit *commit);
+extern struct taginfo *cgit_parse_tag(struct tag *tag);
+extern void cgit_parse_url(const char *url);
+
+extern const char *cgit_repobasename(const char *reponame);
+
+extern int cgit_parse_snapshots_mask(const char *str);
+extern const struct object_id *cgit_snapshot_get_sig(const char *ref,
+ const struct cgit_snapshot_format *f);
+extern const unsigned cgit_snapshot_format_bit(const struct cgit_snapshot_format *f);
+
+extern void cgit_exec_filter_init(struct cgit_exec_filter *filter, char *cmd, char **argv);
+extern int cgit_open_exec_filter(struct cgit_exec_filter *filter);
+extern int cgit_close_exec_filter(struct cgit_exec_filter *filter);
+
+extern void cgit_prepare_repo_env(struct cgit_repo * repo);
+
+extern int read_first_line(const char *path, char **buf, size_t *size);
+
+extern char *strdup_first_line(const char *txt);
+
+extern char *expand_macros(const char *txt);
+
+extern char *get_mimetype_for_filename(const char *filename);
+
+#endif /* CGIT_H */
diff --git a/crates/gilti/assets/gilti.js b/cgit/cgit.js
index b4ade48..5702ed2 100644
--- a/crates/gilti/assets/gilti.js
+++ b/cgit/cgit.js
@@ -2,17 +2,17 @@
* SPDX-License-Identifier: GPL-2.0-only
*/
-/* gilti.js: JavaScript helpers for Gilti
+/* cgit.js: javacript functions for cgit
*
* Copyright (C) 2006-2018 cgit Development Team <cgit@lists.zx2c4.com>
*
* Licensed under GNU General Public License v2
- * (see LICENSES/GPL-2.0-only.txt for full license text)
+ * (see COPYING for full license text)
*/
(function () {
-/* Relative-age rendering shared by Gilti browser views. */
+/* This follows the logic and suffixes used in ui-shared.c */
var age_classes = [ "age-mins", "age-hours", "age-days", "age-weeks", "age-months", "age-years" ];
var age_suffix = [ "min.", "hours", "days", "weeks", "months", "years", "years" ];
diff --git a/crates/gilti/assets/gilti.png b/cgit/cgit.png
index 425528e..425528e 100644
--- a/crates/gilti/assets/gilti.png
+++ b/cgit/cgit.png
Binary files differ
diff --git a/cgit/cmd.c b/cgit/cmd.c
new file mode 100644
--- /dev/null
+++ b/cgit/cmd.c
@@ -0,0 +1,203 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-FileCopyrightText: 2026 Nikolay Govorov
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* cmd.c: the cgit command dispatcher
+ *
+ * Copyright (C) 2006-2017 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#include "cgit.h"
+#include "cmd.h"
+#include "ui-shared.h"
+#include "ui-atom.h"
+#include "ui-blame.h"
+#include "ui-blob.h"
+#include "ui-clone.h"
+#include "ui-commit.h"
+#include "ui-diff.h"
+#include "ui-log.h"
+#include "ui-patch.h"
+#include "ui-plain.h"
+#include "ui-refs.h"
+#include "ui-repolist.h"
+#include "ui-snapshot.h"
+#include "ui-stats.h"
+#include "ui-summary.h"
+#include "ui-tag.h"
+#include "ui-tree.h"
+
+static void HEAD_fn(void)
+{
+ cgit_clone_head();
+}
+
+static void atom_fn(void)
+{
+ cgit_print_atom(ctx.qry.head, ctx.qry.path, ctx.cfg.max_atom_items);
+}
+
+static void about_fn(void)
+{
+ if (ctx.repo) {
+ size_t path_info_len = ctx.env.path_info ? strlen(ctx.env.path_info) : 0;
+ if (!ctx.qry.path &&
+ ctx.qry.url[strlen(ctx.qry.url) - 1] != '/' &&
+ (!path_info_len || ctx.env.path_info[path_info_len - 1] != '/')) {
+ char *currenturl = cgit_currenturl();
+ char *redirect = fmtalloc("%s/", currenturl);
+ cgit_redirect(redirect, true);
+ free(currenturl);
+ free(redirect);
+ } else if (ctx.repo->readme.nr)
+ cgit_print_repo_readme(ctx.qry.path);
+ else if (ctx.repo->homepage)
+ cgit_redirect(ctx.repo->homepage, false);
+ else {
+ char *currenturl = cgit_currenturl();
+ char *redirect = fmtalloc("%s../", currenturl);
+ cgit_redirect(redirect, false);
+ free(currenturl);
+ free(redirect);
+ }
+ } else
+ cgit_print_site_readme();
+}
+
+static void blame_fn(void)
+{
+ if (ctx.repo->enable_blame)
+ cgit_print_blame();
+ else
+ cgit_print_error_page(403, "Forbidden", "Blame is disabled");
+}
+
+static void blob_fn(void)
+{
+ cgit_print_blob(ctx.qry.oid, ctx.qry.path, ctx.qry.head, 0);
+}
+
+static void commit_fn(void)
+{
+ cgit_print_commit(ctx.qry.oid, ctx.qry.path);
+}
+
+static void diff_fn(void)
+{
+ cgit_print_diff(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path, 1, 0);
+}
+
+static void rawdiff_fn(void)
+{
+ cgit_print_diff(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path, 1, 1);
+}
+
+static void info_fn(void)
+{
+ cgit_clone_info();
+}
+
+static void log_fn(void)
+{
+ cgit_print_log(ctx.qry.oid, ctx.qry.ofs, ctx.cfg.max_commit_count,
+ ctx.qry.grep, ctx.qry.search, ctx.qry.path, 1,
+ ctx.repo->enable_commit_graph,
+ ctx.repo->commit_sort);
+}
+
+static void objects_fn(void)
+{
+ cgit_clone_objects();
+}
+
+static void repolist_fn(void)
+{
+ cgit_print_repolist();
+}
+
+static void patch_fn(void)
+{
+ cgit_print_patch(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path);
+}
+
+static void plain_fn(void)
+{
+ cgit_print_plain();
+}
+
+static void refs_fn(void)
+{
+ cgit_print_refs();
+}
+
+static void snapshot_fn(void)
+{
+ cgit_print_snapshot(ctx.qry.head, ctx.qry.oid, ctx.qry.path,
+ ctx.qry.nohead);
+}
+
+static void stats_fn(void)
+{
+ cgit_show_stats();
+}
+
+static void summary_fn(void)
+{
+ cgit_print_summary();
+}
+
+static void tag_fn(void)
+{
+ cgit_print_tag(ctx.qry.oid);
+}
+
+static void tree_fn(void)
+{
+ cgit_print_tree(ctx.qry.oid, ctx.qry.path);
+}
+
+#define def_cmd(name, want_repo, want_vpath, is_clone) \
+ {#name, name##_fn, want_repo, want_vpath, is_clone}
+
+struct cgit_cmd *cgit_get_cmd(void)
+{
+ static struct cgit_cmd cmds[] = {
+ def_cmd(HEAD, 1, 0, 1),
+ def_cmd(atom, 1, 0, 0),
+ def_cmd(about, 0, 0, 0),
+ def_cmd(blame, 1, 1, 0),
+ def_cmd(blob, 1, 0, 0),
+ def_cmd(commit, 1, 1, 0),
+ def_cmd(diff, 1, 1, 0),
+ def_cmd(info, 1, 0, 1),
+ def_cmd(log, 1, 1, 0),
+ def_cmd(objects, 1, 0, 1),
+ def_cmd(patch, 1, 1, 0),
+ def_cmd(plain, 1, 0, 0),
+ def_cmd(rawdiff, 1, 1, 0),
+ def_cmd(refs, 1, 0, 0),
+ def_cmd(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),
+ };
+ int i;
+
+ if (ctx.qry.page == NULL) {
+ if (ctx.repo)
+ ctx.qry.page = "summary";
+ else
+ ctx.qry.page = "repolist";
+ }
+
+ for (i = 0; i < sizeof(cmds)/sizeof(*cmds); i++)
+ if (!strcmp(ctx.qry.page, cmds[i].name))
+ return &cmds[i];
+ return NULL;
+}
diff --git a/cgit/cmd.h b/cgit/cmd.h
new file mode 100644
--- /dev/null
+++ b/cgit/cmd.h
@@ -0,0 +1,20 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef CMD_H
+#define CMD_H
+
+typedef void (*cgit_cmd_fn)(void);
+
+struct cgit_cmd {
+ const char *name;
+ cgit_cmd_fn fn;
+ unsigned int want_repo:1,
+ want_vpath:1,
+ is_clone:1;
+};
+
+extern struct cgit_cmd *cgit_get_cmd(void);
+
+#endif /* CMD_H */
diff --git a/cgit/configfile.c b/cgit/configfile.c
new file mode 100644
--- /dev/null
+++ b/cgit/configfile.c
@@ -0,0 +1,94 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* configfile.c: parsing of config files
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#include <git-compat-util.h>
+#include "configfile.h"
+
+static int next_char(FILE *f)
+{
+ int c = fgetc(f);
+ if (c == '\r') {
+ c = fgetc(f);
+ if (c != '\n') {
+ ungetc(c, f);
+ c = '\r';
+ }
+ }
+ return c;
+}
+
+static void skip_line(FILE *f)
+{
+ int c;
+
+ while ((c = next_char(f)) && c != '\n' && c != EOF)
+ ;
+}
+
+static int read_config_line(FILE *f, struct strbuf *name, struct strbuf *value)
+{
+ int c = next_char(f);
+
+ strbuf_reset(name);
+ strbuf_reset(value);
+
+ /* Skip comments and preceding spaces. */
+ for(;;) {
+ if (c == EOF)
+ return 0;
+ else if (c == '#' || c == ';')
+ skip_line(f);
+ else if (!isspace(c))
+ break;
+ c = next_char(f);
+ }
+
+ /* Read variable name. */
+ while (c != '=') {
+ if (c == '\n' || c == EOF)
+ return 0;
+ strbuf_addch(name, c);
+ c = next_char(f);
+ }
+
+ /* Read variable value. */
+ c = next_char(f);
+ while (c != '\n' && c != EOF) {
+ strbuf_addch(value, c);
+ c = next_char(f);
+ }
+
+ return 1;
+}
+
+int parse_configfile(const char *filename, configfile_value_fn fn)
+{
+ static int nesting;
+ struct strbuf name = STRBUF_INIT;
+ struct strbuf value = STRBUF_INIT;
+ FILE *f;
+
+ /* cancel deeply nested include-commands */
+ if (nesting > 8)
+ return -1;
+ if (!(f = fopen(filename, "r")))
+ return -1;
+ nesting++;
+ while (read_config_line(f, &name, &value))
+ fn(name.buf, value.buf);
+ nesting--;
+ fclose(f);
+ strbuf_release(&name);
+ strbuf_release(&value);
+ return 0;
+}
+
diff --git a/cgit/configfile.h b/cgit/configfile.h
new file mode 100644
--- /dev/null
+++ b/cgit/configfile.h
@@ -0,0 +1,14 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef CONFIGFILE_H
+#define CONFIGFILE_H
+
+#include "cgit.h"
+
+typedef void (*configfile_value_fn)(const char *name, const char *value);
+
+extern int parse_configfile(const char *filename, configfile_value_fn fn);
+
+#endif /* CONFIGFILE_H */
diff --git a/crates/gilti/assets/favicon.ico b/cgit/favicon.ico
index 56ff593..56ff593 100644
--- a/crates/gilti/assets/favicon.ico
+++ b/cgit/favicon.ico
Binary files differ
diff --git a/cgit/filter.c b/cgit/filter.c
new file mode 100644
--- /dev/null
+++ b/cgit/filter.c
@@ -0,0 +1,57 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* filter.c: executable filter used by snapshot compression
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#include "cgit.h"
+
+void cgit_exec_filter_init(struct cgit_exec_filter *filter, char *cmd, char **argv)
+{
+ memset(filter, 0, sizeof(*filter));
+ filter->cmd = cmd;
+ filter->argv = argv;
+}
+
+int cgit_open_exec_filter(struct cgit_exec_filter *filter)
+{
+ int pipe_fh[2];
+
+ filter->old_stdout = chk_positive(dup(STDOUT_FILENO),
+ "Unable to duplicate STDOUT");
+ chk_zero(pipe(pipe_fh), "Unable to create pipe to subprocess");
+ filter->pid = chk_non_negative(fork(), "Unable to create subprocess");
+ if (filter->pid == 0) {
+ close(pipe_fh[1]);
+ chk_non_negative(dup2(pipe_fh[0], STDIN_FILENO),
+ "Unable to use pipe as STDIN");
+ execvp(filter->cmd, filter->argv);
+ die_errno("Unable to exec subprocess %s", filter->cmd);
+ }
+ close(pipe_fh[0]);
+ chk_non_negative(dup2(pipe_fh[1], STDOUT_FILENO),
+ "Unable to use pipe as STDOUT");
+ close(pipe_fh[1]);
+ return 0;
+}
+
+int cgit_close_exec_filter(struct cgit_exec_filter *filter)
+{
+ int exit_status = 0;
+
+ chk_non_negative(dup2(filter->old_stdout, STDOUT_FILENO),
+ "Unable to restore STDOUT");
+ close(filter->old_stdout);
+ if (filter->pid < 0)
+ return WEXITSTATUS(exit_status);
+ waitpid(filter->pid, &exit_status, 0);
+ if (!WIFEXITED(exit_status))
+ die("Subprocess %s exited abnormally", filter->cmd);
+ return WEXITSTATUS(exit_status);
+}
diff --git a/cgit/html.c b/cgit/html.c
new file mode 100644
--- /dev/null
+++ b/cgit/html.c
@@ -0,0 +1,365 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* html.c: helper functions for html output
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#include "cgit.h"
+#include "html.h"
+#include "url.h"
+
+/* Percent-encoding of each character, except: a-zA-Z0-9!$()*,./:;@- */
+static const char* url_escape_table[256] = {
+ "%00", "%01", "%02", "%03", "%04", "%05", "%06", "%07",
+ "%08", "%09", "%0a", "%0b", "%0c", "%0d", "%0e", "%0f",
+ "%10", "%11", "%12", "%13", "%14", "%15", "%16", "%17",
+ "%18", "%19", "%1a", "%1b", "%1c", "%1d", "%1e", "%1f",
+ "%20", NULL, "%22", "%23", NULL, "%25", "%26", "%27",
+ NULL, NULL, NULL, "%2b", NULL, NULL, NULL, NULL,
+ NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
+ NULL, NULL, NULL, NULL, "%3c", "%3d", "%3e", "%3f",
+ NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
+ NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
+ NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
+ NULL, NULL, NULL, NULL, "%5c", NULL, "%5e", NULL,
+ "%60", NULL, NULL, NULL, NULL, NULL, NULL, NULL,
+ NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
+ NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
+ NULL, NULL, NULL, "%7b", "%7c", "%7d", NULL, "%7f",
+ "%80", "%81", "%82", "%83", "%84", "%85", "%86", "%87",
+ "%88", "%89", "%8a", "%8b", "%8c", "%8d", "%8e", "%8f",
+ "%90", "%91", "%92", "%93", "%94", "%95", "%96", "%97",
+ "%98", "%99", "%9a", "%9b", "%9c", "%9d", "%9e", "%9f",
+ "%a0", "%a1", "%a2", "%a3", "%a4", "%a5", "%a6", "%a7",
+ "%a8", "%a9", "%aa", "%ab", "%ac", "%ad", "%ae", "%af",
+ "%b0", "%b1", "%b2", "%b3", "%b4", "%b5", "%b6", "%b7",
+ "%b8", "%b9", "%ba", "%bb", "%bc", "%bd", "%be", "%bf",
+ "%c0", "%c1", "%c2", "%c3", "%c4", "%c5", "%c6", "%c7",
+ "%c8", "%c9", "%ca", "%cb", "%cc", "%cd", "%ce", "%cf",
+ "%d0", "%d1", "%d2", "%d3", "%d4", "%d5", "%d6", "%d7",
+ "%d8", "%d9", "%da", "%db", "%dc", "%dd", "%de", "%df",
+ "%e0", "%e1", "%e2", "%e3", "%e4", "%e5", "%e6", "%e7",
+ "%e8", "%e9", "%ea", "%eb", "%ec", "%ed", "%ee", "%ef",
+ "%f0", "%f1", "%f2", "%f3", "%f4", "%f5", "%f6", "%f7",
+ "%f8", "%f9", "%fa", "%fb", "%fc", "%fd", "%fe", "%ff"
+};
+
+char *fmt(const char *format, ...)
+{
+ static char buf[8][1024];
+ static int bufidx;
+ int len;
+ va_list args;
+
+ bufidx++;
+ bufidx &= 7;
+
+ va_start(args, format);
+ len = vsnprintf(buf[bufidx], sizeof(buf[bufidx]), format, args);
+ va_end(args);
+ if (len >= sizeof(buf[bufidx])) {
+ fprintf(stderr, "[html.c] string truncated: %s\n", format);
+ exit(1);
+ }
+ return buf[bufidx];
+}
+
+char *fmtalloc(const char *format, ...)
+{
+ struct strbuf sb = STRBUF_INIT;
+ va_list args;
+
+ va_start(args, format);
+ strbuf_vaddf(&sb, format, args);
+ va_end(args);
+
+ return strbuf_detach(&sb, NULL);
+}
+
+void html_raw(const char *data, size_t size)
+{
+ if (write(STDOUT_FILENO, data, size) != size)
+ die_errno("write error on html output");
+}
+
+void html(const char *txt)
+{
+ html_raw(txt, strlen(txt));
+}
+
+void htmlf(const char *format, ...)
+{
+ va_list args;
+ struct strbuf buf = STRBUF_INIT;
+
+ va_start(args, format);
+ strbuf_vaddf(&buf, format, args);
+ va_end(args);
+ html(buf.buf);
+ strbuf_release(&buf);
+}
+
+void html_txtf(const char *format, ...)
+{
+ va_list args;
+
+ va_start(args, format);
+ html_vtxtf(format, args);
+ va_end(args);
+}
+
+void html_vtxtf(const char *format, va_list ap)
+{
+ va_list cp;
+ struct strbuf buf = STRBUF_INIT;
+
+ va_copy(cp, ap);
+ strbuf_vaddf(&buf, format, cp);
+ va_end(cp);
+ html_txt(buf.buf);
+ strbuf_release(&buf);
+}
+
+void html_txt(const char *txt)
+{
+ if (txt)
+ html_ntxt(txt, strlen(txt));
+}
+
+ssize_t html_ntxt(const char *txt, size_t len)
+{
+ const char *t = txt;
+ ssize_t slen;
+
+ if (len > SSIZE_MAX)
+ return -1;
+
+ slen = (ssize_t) len;
+ while (t && *t && slen--) {
+ int c = *t;
+ if (c == '<' || c == '>' || c == '&') {
+ html_raw(txt, t - txt);
+ if (c == '>')
+ html("&gt;");
+ else if (c == '<')
+ html("&lt;");
+ else if (c == '&')
+ html("&amp;");
+ txt = t + 1;
+ }
+ t++;
+ }
+ if (t != txt)
+ html_raw(txt, t - txt);
+ return slen;
+}
+
+void html_attrf(const char *fmt, ...)
+{
+ va_list ap;
+ struct strbuf sb = STRBUF_INIT;
+
+ va_start(ap, fmt);
+ strbuf_vaddf(&sb, fmt, ap);
+ va_end(ap);
+
+ html_attr(sb.buf);
+ strbuf_release(&sb);
+}
+
+void html_attr(const char *txt)
+{
+ const char *t = txt;
+ while (t && *t) {
+ int c = *t;
+ if (c == '<' || c == '>' || c == '\'' || c == '\"' || c == '&') {
+ html_raw(txt, t - txt);
+ if (c == '>')
+ html("&gt;");
+ else if (c == '<')
+ html("&lt;");
+ else if (c == '\'')
+ html("&#x27;");
+ else if (c == '"')
+ html("&quot;");
+ else if (c == '&')
+ html("&amp;");
+ txt = t + 1;
+ }
+ t++;
+ }
+ if (t != txt)
+ html(txt);
+}
+
+void html_url_path(const char *txt)
+{
+ const char *t = txt;
+ while (t && *t) {
+ unsigned char c = *t;
+ const char *e = url_escape_table[c];
+ if (e && c != '+' && c != '&') {
+ html_raw(txt, t - txt);
+ html(e);
+ txt = t + 1;
+ }
+ t++;
+ }
+ if (t != txt)
+ html(txt);
+}
+
+void html_url_arg(const char *txt)
+{
+ const char *t = txt;
+ while (t && *t) {
+ unsigned char c = *t;
+ const char *e = url_escape_table[c];
+ if (c == ' ')
+ e = "+";
+ if (e) {
+ html_raw(txt, t - txt);
+ html(e);
+ txt = t + 1;
+ }
+ t++;
+ }
+ if (t != txt)
+ html(txt);
+}
+
+void html_header_arg_in_quotes(const char *txt)
+{
+ const char *t = txt;
+ while (t && *t) {
+ unsigned char c = *t;
+ const char *e = NULL;
+ if (c == '\\')
+ e = "\\\\";
+ else if (c == '\r')
+ e = "\\r";
+ else if (c == '\n')
+ e = "\\n";
+ else if (c == '"')
+ e = "\\\"";
+ if (e) {
+ html_raw(txt, t - txt);
+ html(e);
+ txt = t + 1;
+ }
+ t++;
+ }
+ if (t != txt)
+ html(txt);
+
+}
+
+void html_hidden(const char *name, const char *value)
+{
+ html("<input type='hidden' name='");
+ html_attr(name);
+ html("' value='");
+ html_attr(value);
+ html("'/>");
+}
+
+void html_option(const char *value, const char *text, const char *selected_value)
+{
+ html("<option value='");
+ html_attr(value);
+ html("'");
+ if (selected_value && !strcmp(selected_value, value))
+ html(" selected='selected'");
+ html(">");
+ html_txt(text);
+ html("</option>\n");
+}
+
+void html_intoption(int value, const char *text, int selected_value)
+{
+ htmlf("<option value='%d'%s>", value,
+ value == selected_value ? " selected='selected'" : "");
+ html_txt(text);
+ html("</option>");
+}
+
+void html_link_open(const char *url, const char *title, const char *class)
+{
+ html("<a href='");
+ html_attr(url);
+ if (title) {
+ html("' title='");
+ html_attr(title);
+ }
+ if (class) {
+ html("' class='");
+ html_attr(class);
+ }
+ html("'>");
+}
+
+void html_link_close(void)
+{
+ html("</a>");
+}
+
+void html_fileperm(unsigned short mode)
+{
+ htmlf("%c%c%c", (mode & 4 ? 'r' : '-'),
+ (mode & 2 ? 'w' : '-'), (mode & 1 ? 'x' : '-'));
+}
+
+int html_include(const char *filename)
+{
+ FILE *f;
+ char buf[4096];
+ size_t len;
+
+ if (!(f = fopen(filename, "r"))) {
+ fprintf(stderr, "[cgit] Failed to include file %s: %s (%d).\n",
+ filename, strerror(errno), errno);
+ return -1;
+ }
+ while ((len = fread(buf, 1, 4096, f)) > 0)
+ html_raw(buf, len);
+ fclose(f);
+ return 0;
+}
+
+int html_include_text(const char *filename)
+{
+ FILE *f;
+ char buf[4096];
+ size_t len;
+
+ if (!(f = fopen(filename, "r"))) {
+ fprintf(stderr, "[cgit] Failed to include file %s: %s (%d).\n",
+ filename, strerror(errno), errno);
+ return -1;
+ }
+ while ((len = fread(buf, 1, sizeof(buf), f)) > 0)
+ html_ntxt(buf, len);
+ fclose(f);
+ return 0;
+}
+
+void http_parse_querystring(const char *txt, void (*fn)(const char *name, const char *value))
+{
+ const char *t = txt;
+
+ while (t && *t) {
+ char *name = url_decode_parameter_name(&t);
+ if (*name) {
+ char *value = url_decode_parameter_value(&t);
+ fn(name, value);
+ free(value);
+ }
+ free(name);
+ }
+}
diff --git a/cgit/html.h b/cgit/html.h
new file mode 100644
--- /dev/null
+++ b/cgit/html.h
@@ -0,0 +1,42 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef HTML_H
+#define HTML_H
+
+#include "cgit.h"
+
+extern void html_raw(const char *txt, size_t size);
+extern void html(const char *txt);
+
+__attribute__((format (printf,1,2)))
+extern void htmlf(const char *format,...);
+
+__attribute__((format (printf,1,2)))
+extern void html_txtf(const char *format,...);
+
+__attribute__((format (printf,1,0)))
+extern void html_vtxtf(const char *format, va_list ap);
+
+__attribute__((format (printf,1,2)))
+extern void html_attrf(const char *format,...);
+
+extern void html_txt(const char *txt);
+extern ssize_t html_ntxt(const char *txt, size_t 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_header_arg_in_quotes(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_intoption(int value, const char *text, int 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_fileperm(unsigned short mode);
+extern int html_include(const char *filename);
+extern int html_include_text(const char *filename);
+
+extern void http_parse_querystring(const char *txt, void (*fn)(const char *name, const char *value));
+
+#endif /* HTML_H */
diff --git a/cgit/json.c b/cgit/json.c
new file mode 100644
--- /dev/null
+++ b/cgit/json.c
@@ -0,0 +1,118 @@
+/* SPDX-FileCopyrightText: 2026 Nikolay Govorov */
+/* SPDX-License-Identifier: GPL-2.0-only */
+
+#include "json.h"
+#include "cgit.h"
+
+void json(const char *raw) {
+ if (write(STDOUT_FILENO, raw, strlen(raw)) != strlen(raw))
+ die_errno("write error on json output");
+}
+
+void jsonf(const char *format, ...) {
+ va_list args;
+ struct strbuf buf = STRBUF_INIT;
+
+ va_start(args, format);
+ strbuf_vaddf(&buf, format, args);
+ va_end(args);
+ json(buf.buf);
+ strbuf_release(&buf);
+}
+
+static int utf8_len(const unsigned char *p) {
+ int len;
+
+ if (p[0] < 0x80) {
+ return 1;
+ }
+
+ if (p[0] >= 0xc2 && p[0] <= 0xdf) {
+ len = 2;
+ } else if (p[0] >= 0xe0 && p[0] <= 0xef) {
+ len = 3;
+ } else if (p[0] >= 0xf0 && p[0] <= 0xf4) {
+ len = 4;
+ } else {
+ return 0;
+ }
+
+ if (!p[1] || (len > 2 && !p[2]) || (len > 3 && !p[3])) {
+ return 0;
+ }
+
+ if ((p[1] & 0xc0) != 0x80 || (len > 2 && (p[2] & 0xc0) != 0x80) ||
+ (len > 3 && (p[3] & 0xc0) != 0x80)) {
+ return 0;
+ }
+
+ if ((p[0] == 0xe0 && p[1] < 0xa0) || (p[0] == 0xed && p[1] > 0x9f) ||
+ (p[0] == 0xf0 && p[1] < 0x90) || (p[0] == 0xf4 && p[1] > 0x8f)) {
+ return 0;
+ }
+
+ return len;
+}
+
+void json_value(const char *value) {
+ const unsigned char *p = (const unsigned char *)(value ? value : "");
+
+ json("\"");
+ while (*p) {
+ int len;
+ switch (*p) {
+ case '\"':
+ json("\\\"");
+ p++;
+ break;
+ case '\\':
+ json("\\\\");
+ p++;
+ break;
+ case '\b':
+ json("\\b");
+ p++;
+ break;
+ case '\f':
+ json("\\f");
+ p++;
+ break;
+ case '\n':
+ json("\\n");
+ p++;
+ break;
+ case '\r':
+ json("\\r");
+ p++;
+ break;
+ case '\t':
+ json("\\t");
+ p++;
+ break;
+ default:
+ if (*p < 0x20) {
+ jsonf("\\u%04x", *p++);
+ } else if (*p < 0x80) {
+ char ch[] = {*p++, '\0'};
+ json(ch);
+ } else if ((len = utf8_len(p))) {
+ if (write(STDOUT_FILENO, p, len) != len)
+ die_errno("write error on json output");
+ p += len;
+ } else {
+ json("\xef\xbf\xbd");
+ p++;
+ }
+ }
+ }
+ json("\"");
+}
+
+void json_key(const char *key) {
+ json_value(key);
+ json(":");
+}
+
+void json_int(intmax_t value) { jsonf("%" PRIdMAX, value); }
+
+void json_bool(int value) { json(value ? "true" : "false"); }
diff --git a/cgit/json.h b/cgit/json.h
new file mode 100644
--- /dev/null
+++ b/cgit/json.h
@@ -0,0 +1,16 @@
+/* SPDX-FileCopyrightText: 2026 Nikolay Govorov */
+/* SPDX-License-Identifier: GPL-2.0-only */
+
+#ifndef JSON_H
+#define JSON_H
+
+#include "cgit.h"
+
+extern void json(const char *raw);
+extern void jsonf(const char *format, ...);
+extern void json_key(const char *key);
+extern void json_value(const char *value);
+extern void json_int(intmax_t value);
+extern void json_bool(int value);
+
+#endif
diff --git a/cgit/parsing.c b/cgit/parsing.c
new file mode 100644
--- /dev/null
+++ b/cgit/parsing.c
@@ -0,0 +1,231 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* parsing.c: parsing of config files
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+
+/*
+ * url syntax: [repo ['/' cmd [ '/' path]]]
+ * repo: any valid repo url, may contain '/'
+ * cmd: log | commit | diff | tree | view | blob | snapshot
+ * path: any valid path, may contain '/'
+ *
+ */
+void cgit_parse_url(const char *url)
+{
+ char *c, *cmd, *p, *buf;
+ struct cgit_repo *repo;
+
+ if (!url || url[0] == '\0')
+ return;
+
+ ctx.qry.page = NULL;
+ ctx.repo = cgit_get_repoinfo(url);
+ if (ctx.repo) {
+ ctx.qry.repo = ctx.repo->url;
+ return;
+ }
+
+ buf = xstrdup(url);
+ cmd = NULL;
+ c = strchr(buf, '/');
+ while (c) {
+ c[0] = '\0';
+ repo = cgit_get_repoinfo(buf);
+ if (repo) {
+ ctx.repo = repo;
+ cmd = c;
+ }
+ c[0] = '/';
+ c = strchr(c + 1, '/');
+ }
+
+ if (ctx.repo) {
+ ctx.qry.repo = ctx.repo->url;
+ p = strchr(cmd + 1, '/');
+ if (p) {
+ p[0] = '\0';
+ if (p[1])
+ ctx.qry.path = trim_end(p + 1, '/');
+ }
+ if (cmd[1])
+ ctx.qry.page = xstrdup(cmd + 1);
+ }
+ free(buf);
+}
+
+static char *substr(const char *head, const char *tail)
+{
+ char *buf;
+
+ if (tail < head)
+ return xstrdup("");
+ buf = xmalloc(tail - head + 1);
+ strlcpy(buf, head, tail - head + 1);
+ return buf;
+}
+
+static void parse_user(const char *t, char **name, char **email, unsigned long *date, int *tz)
+{
+ struct ident_split ident;
+ unsigned email_len;
+
+ if (!split_ident_line(&ident, t, strchrnul(t, '\n') - t)) {
+ *name = substr(ident.name_begin, ident.name_end);
+
+ email_len = ident.mail_end - ident.mail_begin;
+ *email = xmalloc(strlen("<") + email_len + strlen(">") + 1);
+ xsnprintf(*email, email_len + 3, "<%.*s>", email_len, ident.mail_begin);
+
+ if (ident.date_begin)
+ *date = strtoul(ident.date_begin, NULL, 10);
+ if (ident.tz_begin)
+ *tz = atoi(ident.tz_begin);
+ }
+}
+
+#ifdef NO_ICONV
+#define reencode(a, b, c)
+#else
+static const char *reencode(char **txt, const char *src_enc, const char *dst_enc)
+{
+ char *tmp;
+
+ if (!txt)
+ return NULL;
+
+ if (!*txt || !src_enc || !dst_enc)
+ return *txt;
+
+ /* no encoding needed if src_enc equals dst_enc */
+ if (!strcasecmp(src_enc, dst_enc))
+ return *txt;
+
+ tmp = reencode_string(*txt, dst_enc, src_enc);
+ if (tmp) {
+ free(*txt);
+ *txt = tmp;
+ }
+ return *txt;
+}
+#endif
+
+static const char *next_header_line(const char *p)
+{
+ p = strchr(p, '\n');
+ if (!p)
+ return NULL;
+ return p + 1;
+}
+
+static int end_of_header(const char *p)
+{
+ return !p || (*p == '\n');
+}
+
+struct commitinfo *cgit_parse_commit(struct commit *commit)
+{
+ struct commitinfo *ret;
+ const char *p = repo_get_commit_buffer(the_repository, commit, NULL);
+ const char *t;
+
+ ret = xcalloc(1, sizeof(struct commitinfo));
+ ret->commit = commit;
+
+ if (!p)
+ return ret;
+
+ if (!skip_prefix(p, "tree ", &p))
+ die("Bad commit: %s", oid_to_hex(&commit->object.oid));
+ p += the_hash_algo->hexsz + 1;
+
+ while (skip_prefix(p, "parent ", &p))
+ p += the_hash_algo->hexsz + 1;
+
+ if (p && skip_prefix(p, "author ", &p)) {
+ parse_user(p, &ret->author, &ret->author_email,
+ &ret->author_date, &ret->author_tz);
+ p = next_header_line(p);
+ }
+
+ if (p && skip_prefix(p, "committer ", &p)) {
+ parse_user(p, &ret->committer, &ret->committer_email,
+ &ret->committer_date, &ret->committer_tz);
+ p = next_header_line(p);
+ }
+
+ if (p && skip_prefix(p, "encoding ", &p)) {
+ t = strchr(p, '\n');
+ if (t) {
+ ret->msg_encoding = substr(p, t + 1);
+ p = t + 1;
+ }
+ }
+
+ if (!ret->msg_encoding)
+ ret->msg_encoding = xstrdup("UTF-8");
+
+ while (!end_of_header(p))
+ p = next_header_line(p);
+ while (p && *p == '\n')
+ p++;
+ if (!p)
+ return ret;
+
+ t = strchrnul(p, '\n');
+ ret->subject = substr(p, t);
+ while (*t == '\n')
+ t++;
+ ret->msg = xstrdup(t);
+
+ reencode(&ret->author, ret->msg_encoding, PAGE_ENCODING);
+ reencode(&ret->author_email, ret->msg_encoding, PAGE_ENCODING);
+ reencode(&ret->committer, ret->msg_encoding, PAGE_ENCODING);
+ reencode(&ret->committer_email, ret->msg_encoding, PAGE_ENCODING);
+ reencode(&ret->subject, ret->msg_encoding, PAGE_ENCODING);
+ reencode(&ret->msg, ret->msg_encoding, PAGE_ENCODING);
+
+ return ret;
+}
+
+struct taginfo *cgit_parse_tag(struct tag *tag)
+{
+ void *data;
+ enum object_type type;
+ unsigned long size;
+ const char *p;
+ struct taginfo *ret = NULL;
+
+ data = odb_read_object(the_repository->objects, &tag->object.oid, &type, &size);
+ if (!data || type != OBJ_TAG)
+ goto cleanup;
+
+ ret = xcalloc(1, sizeof(struct taginfo));
+
+ for (p = data; !end_of_header(p); p = next_header_line(p)) {
+ if (skip_prefix(p, "tagger ", &p)) {
+ parse_user(p, &ret->tagger, &ret->tagger_email,
+ &ret->tagger_date, &ret->tagger_tz);
+ }
+ }
+
+ while (p && *p == '\n')
+ p++;
+
+ if (p && *p)
+ ret->msg = xstrdup(p);
+
+cleanup:
+ free(data);
+ return ret;
+}
diff --git a/cgit/scan-tree.c b/cgit/scan-tree.c
new file mode 100644
--- /dev/null
+++ b/cgit/scan-tree.c
@@ -0,0 +1,273 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* scan-tree.c
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#include "cgit.h"
+#include "scan-tree.h"
+#include "configfile.h"
+#include "html.h"
+#include <config.h>
+
+/* return 1 if path contains a objects/ directory and a HEAD file */
+static int is_git_dir(const char *path)
+{
+ struct stat st;
+ struct strbuf pathbuf = STRBUF_INIT;
+ int result = 0;
+
+ strbuf_addf(&pathbuf, "%s/objects", path);
+ if (stat(pathbuf.buf, &st)) {
+ if (errno != ENOENT)
+ fprintf(stderr, "Error checking path %s: %s (%d)\n",
+ path, strerror(errno), errno);
+ goto out;
+ }
+ if (!S_ISDIR(st.st_mode))
+ goto out;
+
+ strbuf_reset(&pathbuf);
+ strbuf_addf(&pathbuf, "%s/HEAD", path);
+ if (stat(pathbuf.buf, &st)) {
+ if (errno != ENOENT)
+ fprintf(stderr, "Error checking path %s: %s (%d)\n",
+ path, strerror(errno), errno);
+ goto out;
+ }
+ if (!S_ISREG(st.st_mode))
+ goto out;
+
+ result = 1;
+out:
+ strbuf_release(&pathbuf);
+ return result;
+}
+
+static struct cgit_repo *repo;
+
+static void scan_tree_repo_config(const char *name, const char *value)
+{
+ cgit_repo_config(repo, name, value);
+}
+
+static int gitconfig_config(const char *key, const char *value,
+ const __attribute__((unused)) struct config_context *ctx, void *cb)
+{
+ const char *name;
+
+ if (!strcmp(key, "gitweb.owner"))
+ cgit_repo_config(repo, "owner", value);
+ else if (!strcmp(key, "gitweb.description"))
+ cgit_repo_config(repo, "desc", value);
+ else if (!strcmp(key, "gitweb.category"))
+ cgit_repo_config(repo, "section", value);
+ else if (!strcmp(key, "gitweb.homepage"))
+ cgit_repo_config(repo, "homepage", value);
+ else if (skip_prefix(key, "cgit.", &name))
+ cgit_repo_config(repo, name, value);
+
+ return 0;
+}
+
+static char *xstrrchr(char *s, char *from, int c)
+{
+ while (from >= s && *from != c)
+ from--;
+ return from < s ? NULL : from;
+}
+
+static void add_repo(const char *base, struct strbuf *path)
+{
+ struct stat st;
+ struct passwd *pwd;
+ size_t pathlen;
+ struct strbuf rel = STRBUF_INIT;
+ char *p, *slash;
+ int n;
+ size_t size;
+
+ if (stat(path->buf, &st)) {
+ fprintf(stderr, "Error accessing %s: %s (%d)\n",
+ path->buf, strerror(errno), errno);
+ return;
+ }
+
+ strbuf_addch(path, '/');
+ pathlen = path->len;
+
+ if (ctx.cfg.strict_export) {
+ strbuf_addstr(path, ctx.cfg.strict_export);
+ if(stat(path->buf, &st))
+ return;
+ strbuf_setlen(path, pathlen);
+ }
+
+ strbuf_addstr(path, "noweb");
+ if (!stat(path->buf, &st))
+ return;
+ strbuf_setlen(path, pathlen);
+
+ if (!starts_with(path->buf, base))
+ strbuf_addbuf(&rel, path);
+ else
+ strbuf_addstr(&rel, path->buf + strlen(base) + 1);
+
+ if (!strcmp(rel.buf + rel.len - 5, "/.git"))
+ strbuf_setlen(&rel, rel.len - 5);
+ else if (rel.len && rel.buf[rel.len - 1] == '/')
+ strbuf_setlen(&rel, rel.len - 1);
+
+ repo = cgit_add_repo(rel.buf);
+ if (ctx.cfg.enable_git_config) {
+ strbuf_addstr(path, "config");
+ git_config_from_file(gitconfig_config, path->buf, NULL);
+ strbuf_setlen(path, pathlen);
+ }
+
+ if (ctx.cfg.remove_suffix) {
+ size_t urllen;
+ strip_suffix(repo->url, ".git", &urllen);
+ strip_suffix_mem(repo->url, &urllen, "/");
+ repo->url[urllen] = '\0';
+ }
+ repo->path = strdup_first_line(path->buf);
+ while (!repo->owner) {
+ if ((pwd = getpwuid(st.st_uid)) == NULL) {
+ fprintf(stderr, "Error reading owner-info for %s: %s (%d)\n",
+ path->buf, strerror(errno), errno);
+ break;
+ }
+ if (pwd->pw_gecos)
+ if ((p = strchr(pwd->pw_gecos, ',')))
+ *p = '\0';
+ repo->owner = strdup_first_line(pwd->pw_gecos ? pwd->pw_gecos : pwd->pw_name);
+ }
+
+ if (repo->desc == cgit_default_repo_desc || !repo->desc) {
+ strbuf_addstr(path, "description");
+ if (!stat(path->buf, &st))
+ read_first_line(path->buf, &repo->desc, &size);
+ strbuf_setlen(path, pathlen);
+ }
+
+ if (ctx.cfg.section_from_path) {
+ n = ctx.cfg.section_from_path;
+ if (n > 0) {
+ slash = rel.buf - 1;
+ while (slash && n && (slash = strchr(slash + 1, '/')))
+ n--;
+ } else {
+ slash = rel.buf + rel.len;
+ while (slash && n && (slash = xstrrchr(rel.buf, slash - 1, '/')))
+ n++;
+ }
+ if (slash && !n) {
+ *slash = '\0';
+ repo->section = strdup_first_line(rel.buf);
+ *slash = '/';
+ if (starts_with(repo->name, repo->section)) {
+ repo->name += strlen(repo->section);
+ if (*repo->name == '/')
+ repo->name++;
+ }
+ }
+ }
+
+ strbuf_addstr(path, "cgitrc");
+ if (!stat(path->buf, &st))
+ parse_configfile(path->buf, &scan_tree_repo_config);
+
+ strbuf_release(&rel);
+}
+
+static void scan_path(const char *base, const char *path)
+{
+ DIR *dir = opendir(path);
+ struct dirent *ent;
+ struct strbuf pathbuf = STRBUF_INIT;
+ size_t pathlen = strlen(path);
+ struct stat st;
+
+ if (!dir) {
+ fprintf(stderr, "Error opening directory %s: %s (%d)\n",
+ path, strerror(errno), errno);
+ return;
+ }
+
+ strbuf_add(&pathbuf, path, strlen(path));
+ if (is_git_dir(pathbuf.buf)) {
+ add_repo(base, &pathbuf);
+ goto end;
+ }
+ strbuf_addstr(&pathbuf, "/.git");
+ if (is_git_dir(pathbuf.buf)) {
+ add_repo(base, &pathbuf);
+ goto end;
+ }
+ /*
+ * Add one because we don't want to lose the trailing '/' when we
+ * reset the length of pathbuf in the loop below.
+ */
+ pathlen++;
+ while ((ent = readdir(dir)) != NULL) {
+ if (ent->d_name[0] == '.') {
+ if (ent->d_name[1] == '\0')
+ continue;
+ if (ent->d_name[1] == '.' && ent->d_name[2] == '\0')
+ continue;
+ if (!ctx.cfg.scan_hidden_path)
+ continue;
+ }
+ strbuf_setlen(&pathbuf, pathlen);
+ strbuf_addstr(&pathbuf, ent->d_name);
+ if (stat(pathbuf.buf, &st)) {
+ fprintf(stderr, "Error checking path %s: %s (%d)\n",
+ pathbuf.buf, strerror(errno), errno);
+ continue;
+ }
+ if (S_ISDIR(st.st_mode))
+ scan_path(base, pathbuf.buf);
+ }
+end:
+ strbuf_release(&pathbuf);
+ closedir(dir);
+}
+
+void scan_projects(const char *path, const char *projectsfile)
+{
+ struct strbuf line = STRBUF_INIT;
+ FILE *projects;
+ int err;
+
+ projects = fopen(projectsfile, "r");
+ if (!projects) {
+ fprintf(stderr, "Error opening projectsfile %s: %s (%d)\n",
+ projectsfile, strerror(errno), errno);
+ return;
+ }
+ while (strbuf_getline(&line, projects) != EOF) {
+ if (!line.len)
+ continue;
+ strbuf_insert(&line, 0, "/", 1);
+ strbuf_insert(&line, 0, path, strlen(path));
+ scan_path(path, line.buf);
+ }
+ if ((err = ferror(projects))) {
+ fprintf(stderr, "Error reading from projectsfile %s: %s (%d)\n",
+ projectsfile, strerror(err), err);
+ }
+ fclose(projects);
+ strbuf_release(&line);
+}
+
+void scan_tree(const char *path)
+{
+ scan_path(path, path);
+}
diff --git a/cgit/scan-tree.h b/cgit/scan-tree.h
new file mode 100644
--- /dev/null
+++ b/cgit/scan-tree.h
@@ -0,0 +1,6 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+extern void scan_projects(const char *path, const char *projectsfile);
+extern void scan_tree(const char *path);
diff --git a/cgit/shared.c b/cgit/shared.c
new file mode 100644
--- /dev/null
+++ b/cgit/shared.c
@@ -0,0 +1,594 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* shared.c: global vars + some callback functions
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+
+struct cgit_repolist cgit_repolist;
+struct cgit_context ctx;
+
+int chk_zero(int result, char *msg)
+{
+ if (result != 0)
+ die_errno("%s", msg);
+ return result;
+}
+
+int chk_positive(int result, char *msg)
+{
+ if (result <= 0)
+ die_errno("%s", msg);
+ return result;
+}
+
+int chk_non_negative(int result, char *msg)
+{
+ if (result < 0)
+ die_errno("%s", msg);
+ return result;
+}
+
+char *cgit_default_repo_desc = "[no description]";
+struct cgit_repo *cgit_add_repo(const char *url)
+{
+ struct cgit_repo *ret;
+
+ if (++cgit_repolist.count > cgit_repolist.length) {
+ if (cgit_repolist.length == 0)
+ cgit_repolist.length = 8;
+ else
+ cgit_repolist.length *= 2;
+ cgit_repolist.repos = xrealloc(cgit_repolist.repos,
+ cgit_repolist.length *
+ sizeof(struct cgit_repo));
+ }
+
+ ret = &cgit_repolist.repos[cgit_repolist.count-1];
+ memset(ret, 0, sizeof(struct cgit_repo));
+ ret->url = trim_end(url, '/');
+ *strchrnul(ret->url, '\n') = '\0';
+ ret->name = ret->url;
+ ret->path = NULL;
+ ret->desc = cgit_default_repo_desc;
+ ret->extra_head_content = NULL;
+ ret->owner = NULL;
+ ret->homepage = NULL;
+ ret->section = ctx.cfg.section;
+ ret->snapshots = ctx.cfg.snapshots;
+ ret->enable_blame = ctx.cfg.enable_blame;
+ ret->enable_commit_graph = ctx.cfg.enable_commit_graph;
+ ret->enable_follow_links = ctx.cfg.enable_follow_links;
+ ret->enable_log_filecount = ctx.cfg.enable_log_filecount;
+ ret->enable_log_linecount = ctx.cfg.enable_log_linecount;
+ ret->enable_remote_branches = ctx.cfg.enable_remote_branches;
+ ret->enable_subject_links = ctx.cfg.enable_subject_links;
+ ret->enable_html_serving = ctx.cfg.enable_html_serving;
+ ret->max_stats = ctx.cfg.max_stats;
+ ret->branch_sort = ctx.cfg.branch_sort;
+ ret->commit_sort = ctx.cfg.commit_sort;
+ ret->module_link = ctx.cfg.module_link;
+ ret->readme = ctx.cfg.readme;
+ ret->mtime = -1;
+ ret->clone_url = ctx.cfg.clone_url;
+ ret->submodules.strdup_strings = 1;
+ ret->hide = ret->ignore = 0;
+ return ret;
+}
+
+struct cgit_repo *cgit_get_repoinfo(const char *url)
+{
+ int i;
+ struct cgit_repo *repo;
+
+ for (i = 0; i < cgit_repolist.count; i++) {
+ repo = &cgit_repolist.repos[i];
+ if (repo->ignore)
+ continue;
+ if (!strcmp(repo->url, url))
+ return repo;
+ }
+ return NULL;
+}
+
+void cgit_free_commitinfo(struct commitinfo *info)
+{
+ free(info->author);
+ free(info->author_email);
+ free(info->committer);
+ free(info->committer_email);
+ free(info->subject);
+ free(info->msg);
+ free(info->msg_encoding);
+ free(info);
+}
+
+char *trim_end(const char *str, char c)
+{
+ int len;
+
+ if (str == NULL)
+ return NULL;
+ len = strlen(str);
+ while (len > 0 && str[len - 1] == c)
+ len--;
+ if (len == 0)
+ return NULL;
+ return xstrndup(str, len);
+}
+
+char *ensure_end(const char *str, char c)
+{
+ size_t len = strlen(str);
+ char *result;
+
+ if (len && str[len - 1] == c)
+ return xstrndup(str, len);
+
+ result = xmalloc(len + 2);
+ memcpy(result, str, len);
+ result[len] = '/';
+ result[len + 1] = '\0';
+ return result;
+}
+
+void strbuf_ensure_end(struct strbuf *sb, char c)
+{
+ if (!sb->len || sb->buf[sb->len - 1] != c)
+ strbuf_addch(sb, c);
+}
+
+void cgit_add_ref(struct reflist *list, struct refinfo *ref)
+{
+ size_t size;
+
+ if (list->count >= list->alloc) {
+ list->alloc += (list->alloc ? list->alloc : 4);
+ size = list->alloc * sizeof(struct refinfo *);
+ list->refs = xrealloc(list->refs, size);
+ }
+ list->refs[list->count++] = ref;
+}
+
+static struct refinfo *cgit_mk_refinfo(const char *refname, const struct object_id *oid)
+{
+ struct refinfo *ref;
+
+ ref = xmalloc(sizeof (struct refinfo));
+ ref->refname = xstrdup(refname);
+ ref->object = parse_object(the_repository, oid);
+ switch (ref->object->type) {
+ case OBJ_TAG:
+ ref->tag = cgit_parse_tag((struct tag *)ref->object);
+ break;
+ case OBJ_COMMIT:
+ ref->commit = cgit_parse_commit((struct commit *)ref->object);
+ break;
+ }
+ return ref;
+}
+
+void cgit_free_taginfo(struct taginfo *tag)
+{
+ if (tag->tagger)
+ free(tag->tagger);
+ if (tag->tagger_email)
+ free(tag->tagger_email);
+ if (tag->msg)
+ free(tag->msg);
+ free(tag);
+}
+
+static void cgit_free_refinfo(struct refinfo *ref)
+{
+ if (ref->refname)
+ free((char *)ref->refname);
+ switch (ref->object->type) {
+ case OBJ_TAG:
+ cgit_free_taginfo(ref->tag);
+ break;
+ case OBJ_COMMIT:
+ cgit_free_commitinfo(ref->commit);
+ break;
+ }
+ free(ref);
+}
+
+void cgit_free_reflist_inner(struct reflist *list)
+{
+ int i;
+
+ for (i = 0; i < list->count; i++) {
+ cgit_free_refinfo(list->refs[i]);
+ }
+ free(list->refs);
+}
+
+int cgit_refs_cb(const struct reference *ref, void *cb_data)
+{
+ struct reflist *list = (struct reflist *)cb_data;
+ struct refinfo *info = cgit_mk_refinfo(ref->name, ref->oid);
+
+ if (info)
+ cgit_add_ref(list, info);
+ return 0;
+}
+
+void cgit_diff_tree_cb(struct diff_queue_struct *q,
+ struct diff_options *options, void *data)
+{
+ int i;
+
+ for (i = 0; i < q->nr; i++) {
+ if (q->queue[i]->status == 'U')
+ continue;
+ ((filepair_fn)data)(q->queue[i]);
+ }
+}
+
+static int load_mmfile(mmfile_t *file, const struct object_id *oid)
+{
+ enum object_type type;
+
+ if (is_null_oid(oid)) {
+ file->ptr = (char *)"";
+ file->size = 0;
+ } else {
+ file->ptr = odb_read_object(the_repository->objects, oid, &type,
+ (unsigned long *)&file->size);
+ }
+ return 1;
+}
+
+/*
+ * Receive diff-buffers from xdiff and concatenate them as
+ * needed across multiple callbacks.
+ *
+ * This is basically a copy of xdiff-interface.c/xdiff_outf(),
+ * ripped from git and modified to use globals instead of
+ * a special callback-struct.
+ */
+static char *diffbuf = NULL;
+static int buflen = 0;
+
+static int filediff_cb(void *priv, mmbuffer_t *mb, int nbuf)
+{
+ int i;
+
+ for (i = 0; i < nbuf; i++) {
+ if (mb[i].ptr[mb[i].size-1] != '\n') {
+ /* Incomplete line */
+ diffbuf = xrealloc(diffbuf, buflen + mb[i].size);
+ memcpy(diffbuf + buflen, mb[i].ptr, mb[i].size);
+ buflen += mb[i].size;
+ continue;
+ }
+
+ /* we have a complete line */
+ if (!diffbuf) {
+ ((linediff_fn)priv)(mb[i].ptr, mb[i].size);
+ continue;
+ }
+ diffbuf = xrealloc(diffbuf, buflen + mb[i].size);
+ memcpy(diffbuf + buflen, mb[i].ptr, mb[i].size);
+ ((linediff_fn)priv)(diffbuf, buflen + mb[i].size);
+ free(diffbuf);
+ diffbuf = NULL;
+ buflen = 0;
+ }
+ if (diffbuf) {
+ ((linediff_fn)priv)(diffbuf, buflen);
+ free(diffbuf);
+ diffbuf = NULL;
+ buflen = 0;
+ }
+ return 0;
+}
+
+int 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)
+{
+ mmfile_t file1, file2;
+ xpparam_t diff_params;
+ xdemitconf_t emit_params;
+ xdemitcb_t emit_cb;
+
+ if (!load_mmfile(&file1, old_oid) || !load_mmfile(&file2, new_oid))
+ return 1;
+
+ *old_size = file1.size;
+ *new_size = file2.size;
+
+ if ((file1.ptr && buffer_is_binary(file1.ptr, file1.size)) ||
+ (file2.ptr && buffer_is_binary(file2.ptr, file2.size))) {
+ *binary = 1;
+ if (file1.size)
+ free(file1.ptr);
+ if (file2.size)
+ free(file2.ptr);
+ return 0;
+ }
+
+ memset(&diff_params, 0, sizeof(diff_params));
+ memset(&emit_params, 0, sizeof(emit_params));
+ memset(&emit_cb, 0, sizeof(emit_cb));
+ diff_params.flags = XDF_NEED_MINIMAL;
+ if (ignorews)
+ diff_params.flags |= XDF_IGNORE_WHITESPACE;
+ emit_params.ctxlen = context > 0 ? context : 3;
+ emit_params.flags = XDL_EMIT_FUNCNAMES;
+ emit_cb.out_line = filediff_cb;
+ emit_cb.priv = fn;
+ xdl_diff(&file1, &file2, &diff_params, &emit_params, &emit_cb);
+ if (file1.size)
+ free(file1.ptr);
+ if (file2.size)
+ free(file2.ptr);
+ return 0;
+}
+
+void cgit_diff_tree(const struct object_id *old_oid,
+ const struct object_id *new_oid,
+ filepair_fn fn, const char *prefix, int ignorews)
+{
+ struct diff_options opt;
+ struct pathspec_item *item;
+
+ repo_diff_setup(the_repository, &opt);
+ opt.output_format = DIFF_FORMAT_CALLBACK;
+ opt.detect_rename = 1;
+ opt.rename_limit = ctx.cfg.renamelimit;
+ opt.flags.recursive = 1;
+ if (ignorews)
+ DIFF_XDL_SET(&opt, IGNORE_WHITESPACE);
+ opt.format_callback = cgit_diff_tree_cb;
+ opt.format_callback_data = fn;
+ if (prefix) {
+ item = xcalloc(1, sizeof(*item));
+ item->match = xstrdup(prefix);
+ item->len = strlen(prefix);
+ opt.pathspec.nr = 1;
+ opt.pathspec.items = item;
+ }
+ diff_setup_done(&opt);
+
+ if (old_oid && !is_null_oid(old_oid))
+ diff_tree_oid(old_oid, new_oid, "", &opt);
+ else
+ diff_root_tree_oid(new_oid, "", &opt);
+ diffcore_std(&opt);
+ diff_flush(&opt);
+}
+
+void cgit_diff_commit(struct commit *commit, filepair_fn fn, const char *prefix)
+{
+ const struct object_id *old_oid = NULL;
+
+ if (commit->parents)
+ old_oid = &commit->parents->item->object.oid;
+ cgit_diff_tree(old_oid, &commit->object.oid, fn, prefix,
+ ctx.qry.ignorews);
+}
+
+int cgit_parse_snapshots_mask(const char *str)
+{
+ struct string_list tokens = STRING_LIST_INIT_DUP;
+ struct string_list_item *item;
+ const struct cgit_snapshot_format *f;
+ int rv = 0;
+
+ /* favor legacy setting */
+ if (atoi(str))
+ return 1;
+
+ if (strcmp(str, "all") == 0)
+ return INT_MAX;
+
+ string_list_split(&tokens, str, " ", -1);
+ string_list_remove_empty_items(&tokens, 0);
+
+ for_each_string_list_item(item, &tokens) {
+ for (f = cgit_snapshot_formats; f->suffix; f++) {
+ if (!strcmp(item->string, f->suffix) ||
+ !strcmp(item->string, f->suffix + 1)) {
+ rv |= cgit_snapshot_format_bit(f);
+ break;
+ }
+ }
+ }
+
+ string_list_clear(&tokens, 0);
+ return rv;
+}
+
+typedef struct {
+ char * name;
+ char * value;
+} cgit_env_var;
+
+void cgit_prepare_repo_env(struct cgit_repo * repo)
+{
+ cgit_env_var env_vars[] = {
+ { .name = "CGIT_REPO_URL", .value = repo->url },
+ { .name = "CGIT_REPO_NAME", .value = repo->name },
+ { .name = "CGIT_REPO_PATH", .value = repo->path },
+ { .name = "CGIT_REPO_OWNER", .value = repo->owner },
+ { .name = "CGIT_REPO_DEFBRANCH", .value = repo->defbranch },
+ { .name = "CGIT_REPO_SECTION", .value = repo->section },
+ { .name = "CGIT_REPO_CLONE_URL", .value = repo->clone_url }
+ };
+ int env_var_count = ARRAY_SIZE(env_vars);
+ cgit_env_var *p, *q;
+ static char *warn = "cgit warning: failed to set env: %s=%s\n";
+
+ p = env_vars;
+ q = p + env_var_count;
+ for (; p < q; p++)
+ if (p->value && setenv(p->name, p->value, 1))
+ fprintf(stderr, warn, p->name, p->value);
+}
+
+/* Read the content of the specified file into a newly allocated buffer,
+ * zeroterminate the buffer, truncate at a new line, and return 0 on success,
+ * errno otherwise.
+ */
+int read_first_line(const char *path, char **buf, size_t *size)
+{
+ int fd, e;
+ struct stat st;
+
+ fd = open(path, O_RDONLY);
+ if (fd == -1)
+ return errno;
+ if (fstat(fd, &st)) {
+ e = errno;
+ close(fd);
+ return e;
+ }
+ if (!S_ISREG(st.st_mode)) {
+ close(fd);
+ return EISDIR;
+ }
+ *buf = xmalloc(st.st_size + 1);
+ *size = read_in_full(fd, *buf, st.st_size);
+ e = errno;
+ (*buf)[*size] = '\0';
+ *strchrnul(*buf, '\n') = '\0';
+ close(fd);
+ return (*size == st.st_size ? 0 : e);
+}
+
+char *strdup_first_line(const char *txt)
+{
+ char *t = xstrdup(txt);
+ *strchrnul(t, '\n') = '\0';
+ return t;
+}
+
+static int is_token_char(char c)
+{
+ return isalnum(c) || c == '_';
+}
+
+/* Replace name with getenv(name), return pointer to zero-terminating char
+ */
+static char *expand_macro(char *name, int maxlength)
+{
+ char *value;
+ size_t len;
+
+ len = 0;
+ value = getenv(name);
+ if (value) {
+ len = strlen(value) + 1;
+ if (len > maxlength)
+ len = maxlength;
+ strlcpy(name, value, len);
+ --len;
+ }
+ return name + len;
+}
+
+#define EXPBUFSIZE (1024 * 8)
+
+/* Replace all tokens prefixed by '$' in the specified text with the
+ * value of the named environment variable.
+ * NB: the return value is a static buffer, i.e. it must be strdup'd
+ * by the caller.
+ */
+char *expand_macros(const char *txt)
+{
+ static char result[EXPBUFSIZE];
+ char *p, *start;
+ int len;
+
+ p = result;
+ start = NULL;
+ while (p < result + EXPBUFSIZE - 1 && txt && *txt) {
+ *p = *txt;
+ if (start) {
+ if (!is_token_char(*txt)) {
+ if (p - start > 0) {
+ *p = '\0';
+ len = result + EXPBUFSIZE - start - 1;
+ p = expand_macro(start, len) - 1;
+ }
+ start = NULL;
+ txt--;
+ }
+ p++;
+ txt++;
+ continue;
+ }
+ if (*txt == '$') {
+ start = p;
+ txt++;
+ continue;
+ }
+ p++;
+ txt++;
+ }
+ *p = '\0';
+ if (start && p - start > 0) {
+ len = result + EXPBUFSIZE - start - 1;
+ p = expand_macro(start, len);
+ *p = '\0';
+ }
+ return result;
+}
+
+char *get_mimetype_for_filename(const char *filename)
+{
+ const char *ext;
+ char *mimetype, line[1024];
+ struct string_list list = STRING_LIST_INIT_NODUP;
+ int i;
+ FILE *file;
+ struct string_list_item *mime;
+
+ if (!filename)
+ return NULL;
+
+ ext = strrchr(filename, '.');
+ if (!ext)
+ return NULL;
+ ++ext;
+ if (!ext[0])
+ return NULL;
+ mime = string_list_lookup(&ctx.cfg.mimetypes, ext);
+ if (mime)
+ return xstrdup(mime->util);
+
+ if (!ctx.cfg.mimetype_file)
+ return NULL;
+ file = fopen(ctx.cfg.mimetype_file, "r");
+ if (!file)
+ return NULL;
+ while (fgets(line, sizeof(line), file)) {
+ if (!line[0] || line[0] == '#')
+ continue;
+ string_list_split_in_place(&list, line, " \t\r\n", -1);
+ string_list_remove_empty_items(&list, 0);
+ mimetype = list.items[0].string;
+ for (i = 1; i < list.nr; i++) {
+ if (!strcasecmp(ext, list.items[i].string)) {
+ fclose(file);
+ return xstrdup(mimetype);
+ }
+ }
+ string_list_clear(&list, 0);
+ }
+ fclose(file);
+ return NULL;
+}
diff --git a/cgit/ui-atom.c b/cgit/ui-atom.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-atom.c
@@ -0,0 +1,163 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-atom.c: functions for atom feeds
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-atom.h"
+#include "html.h"
+#include "ui-shared.h"
+
+static void add_entry(struct commit *commit, const char *host)
+{
+ char delim = '&';
+ char *hex;
+ char *mail, *t, *t2;
+ struct commitinfo *info;
+
+ info = cgit_parse_commit(commit);
+ hex = oid_to_hex(&commit->object.oid);
+ html("<entry>\n");
+ html("<title>");
+ html_txt(info->subject);
+ html("</title>\n");
+ html("<updated>");
+ html_txt(show_date(info->committer_date, 0,
+ date_mode_from_type(DATE_ISO8601_STRICT)));
+ html("</updated>\n");
+ html("<author>\n");
+ if (info->author) {
+ html("<name>");
+ html_txt(info->author);
+ html("</name>\n");
+ }
+ if (info->author_email && !ctx.cfg.noplainemail) {
+ mail = xstrdup(info->author_email);
+ t = strchr(mail, '<');
+ if (t)
+ t++;
+ else
+ t = mail;
+ t2 = strchr(t, '>');
+ if (t2)
+ *t2 = '\0';
+ html("<email>");
+ html_txt(t);
+ html("</email>\n");
+ free(mail);
+ }
+ html("</author>\n");
+ html("<published>");
+ html_txt(show_date(info->author_date, 0,
+ date_mode_from_type(DATE_ISO8601_STRICT)));
+ html("</published>\n");
+ if (host) {
+ char *pageurl;
+ html("<link rel='alternate' type='text/html' href='");
+ html(cgit_httpscheme());
+ html_attr(host);
+ pageurl = cgit_pageurl(ctx.repo->url, "commit", NULL);
+ html_attr(pageurl);
+ if (ctx.cfg.virtual_root)
+ delim = '?';
+ html_attrf("%cid=%s", delim, hex);
+ html("'/>\n");
+ free(pageurl);
+ }
+ html("<id>");
+ html_txtf("urn:%s:%s", the_hash_algo->name, hex);
+ html("</id>\n");
+ html("<content type='text'>\n");
+ html_txt(info->msg);
+ html("</content>\n");
+ html("</entry>\n");
+ cgit_free_commitinfo(info);
+}
+
+
+void cgit_print_atom(char *tip, const char *path, int max_count)
+{
+ char *host;
+ const char *argv[] = {NULL, tip, NULL, NULL, NULL};
+ struct commit *commit;
+ struct rev_info rev;
+ int argc = 2;
+ bool first = true;
+
+ if (ctx.qry.show_all)
+ argv[1] = "--all";
+ else if (!tip)
+ argv[1] = ctx.qry.head;
+
+ if (path) {
+ argv[argc++] = "--";
+ argv[argc++] = path;
+ }
+
+ repo_init_revisions(the_repository, &rev, NULL);
+ rev.abbrev = DEFAULT_ABBREV;
+ rev.commit_format = CMIT_FMT_DEFAULT;
+ rev.verbose_header = 1;
+ rev.show_root_diff = 0;
+ rev.max_count = max_count;
+ setup_revisions(argc, argv, &rev, NULL);
+ prepare_revision_walk(&rev);
+
+ host = cgit_hosturl();
+ ctx.page.mimetype = "text/xml";
+ ctx.page.charset = "utf-8";
+ cgit_print_http_headers();
+ html("<feed xmlns='http://www.w3.org/2005/Atom'>\n");
+ html("<title>");
+ html_txt(ctx.repo->name);
+ if (path) {
+ html("/");
+ html_txt(path);
+ }
+ if (tip && !ctx.qry.show_all) {
+ html(", branch ");
+ html_txt(tip);
+ }
+ html("</title>\n");
+ html("<subtitle>");
+ html_txt(ctx.repo->desc);
+ html("</subtitle>\n");
+ if (host) {
+ char *fullurl = cgit_currentfullurl();
+ char *repourl = cgit_repourl(ctx.repo->url);
+ html("<id>");
+ html_txtf("%s%s%s", cgit_httpscheme(), host, fullurl);
+ html("</id>\n");
+ html("<link rel='self' href='");
+ html_attrf("%s%s%s", cgit_httpscheme(), host, fullurl);
+ html("'/>\n");
+ html("<link rel='alternate' type='text/html' href='");
+ html_attrf("%s%s%s", cgit_httpscheme(), host, repourl);
+ html("'/>\n");
+ free(fullurl);
+ free(repourl);
+ }
+ while ((commit = get_revision(&rev)) != NULL) {
+ if (first) {
+ html("<updated>");
+ html_txt(show_date(commit->date, 0,
+ date_mode_from_type(DATE_ISO8601_STRICT)));
+ html("</updated>\n");
+ first = false;
+ }
+ add_entry(commit, host);
+ release_commit_memory(the_repository->parsed_objects, commit);
+ commit->parents = NULL;
+ }
+ html("</feed>\n");
+ free(host);
+}
diff --git a/cgit/ui-atom.h b/cgit/ui-atom.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-atom.h
@@ -0,0 +1,10 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_ATOM_H
+#define UI_ATOM_H
+
+extern void cgit_print_atom(char *tip, const char *path, int max_count);
+
+#endif
diff --git a/cgit/ui-blame.c b/cgit/ui-blame.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-blame.c
@@ -0,0 +1,312 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-blame.c: functions for blame output
+ *
+ * Copyright (C) 2006-2017 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-blame.h"
+#include "html.h"
+#include "ui-shared.h"
+#include "strvec.h"
+#include "blame.h"
+
+
+static char *emit_suspect_detail(struct blame_origin *suspect)
+{
+ struct commitinfo *info;
+ struct strbuf detail = STRBUF_INIT;
+
+ info = cgit_parse_commit(suspect->commit);
+
+ strbuf_addf(&detail, "author %s", info->author);
+ if (!ctx.cfg.noplainemail)
+ strbuf_addf(&detail, " %s", info->author_email);
+ strbuf_addf(&detail, " %s\n",
+ show_date(info->author_date, info->author_tz,
+ cgit_date_mode(DATE_ISO8601)));
+
+ strbuf_addf(&detail, "committer %s", info->committer);
+ if (!ctx.cfg.noplainemail)
+ strbuf_addf(&detail, " %s", info->committer_email);
+ strbuf_addf(&detail, " %s\n\n",
+ show_date(info->committer_date, info->committer_tz,
+ cgit_date_mode(DATE_ISO8601)));
+
+ strbuf_addstr(&detail, info->subject);
+
+ cgit_free_commitinfo(info);
+ return strbuf_detach(&detail, NULL);
+}
+
+static void emit_blame_entry_hash(struct blame_entry *ent)
+{
+ struct blame_origin *suspect = ent->suspect;
+ struct object_id *oid = &suspect->commit->object.oid;
+ unsigned long line = 0;
+
+ char *detail = emit_suspect_detail(suspect);
+ html("<span class='oid'>");
+ cgit_commit_link(repo_find_unique_abbrev(the_repository, oid, DEFAULT_ABBREV), detail,
+ NULL, ctx.qry.head, oid_to_hex(oid), suspect->path);
+ html("</span>");
+ free(detail);
+
+ if (!repo_parse_commit(the_repository, suspect->commit) && suspect->commit->parents) {
+ struct commit *parent = suspect->commit->parents->item;
+
+ html(" ");
+ cgit_blame_link("^", "Blame the previous revision", NULL,
+ ctx.qry.head, oid_to_hex(&parent->object.oid),
+ suspect->path);
+ }
+
+ while (line++ < ent->num_lines)
+ html("\n");
+}
+
+static void emit_blame_entry_linenumber(struct blame_entry *ent)
+{
+ const char *numberfmt = "<a id='n%1$d' href='#n%1$d'>%1$d</a>\n";
+
+ unsigned long lineno = ent->lno;
+ while (lineno < ent->lno + ent->num_lines)
+ htmlf(numberfmt, ++lineno);
+}
+
+static void emit_blame_entry_line_background(struct blame_scoreboard *sb,
+ struct blame_entry *ent)
+{
+ unsigned long line;
+ size_t len, maxlen = 2;
+ const char* pos, *endpos;
+
+ for (line = ent->lno; line < ent->lno + ent->num_lines; line++) {
+ html("\n");
+ pos = blame_nth_line(sb, line);
+ endpos = blame_nth_line(sb, line + 1);
+ len = 0;
+ while (pos < endpos) {
+ len++;
+ if (*pos++ == '\t')
+ len = (len + 7) & ~7;
+ }
+ if (len > maxlen)
+ maxlen = len;
+ }
+
+ for (len = 0; len < maxlen - 1; len++)
+ html(" ");
+}
+
+struct walk_tree_context {
+ char *curr_rev;
+ int match_baselen;
+ int state;
+};
+
+static void print_object(const struct object_id *oid, const char *path,
+ const char *rev)
+{
+ enum object_type type;
+ char *buf;
+ unsigned long size;
+ struct strvec rev_argv = STRVEC_INIT;
+ struct rev_info revs;
+ struct blame_scoreboard sb;
+ struct blame_origin *o;
+ struct blame_entry *ent = NULL;
+
+ type = odb_read_object_info(the_repository->objects, oid, &size);
+ if (type == OBJ_BAD) {
+ cgit_print_error_page(404, "Not found", "Bad object name: %s",
+ oid_to_hex(oid));
+ return;
+ }
+
+ buf = odb_read_object(the_repository->objects, oid, &type, &size);
+ if (!buf) {
+ cgit_print_error_page(500, "Internal server error",
+ "Error reading object %s", oid_to_hex(oid));
+ return;
+ }
+
+ strvec_push(&rev_argv, "blame");
+ strvec_push(&rev_argv, rev);
+ repo_init_revisions(the_repository, &revs, NULL);
+ revs.diffopt.flags.allow_textconv = 1;
+ setup_revisions(rev_argv.nr, rev_argv.v, &revs, NULL);
+ init_scoreboard(&sb);
+ sb.revs = &revs;
+ sb.repo = the_repository;
+ sb.path = path;
+ setup_scoreboard(&sb, &o);
+ o->suspects = blame_entry_prepend(NULL, 0, sb.num_lines, o);
+ prio_queue_put(&sb.commits, o->commit);
+ blame_origin_decref(o);
+ sb.ent = NULL;
+ sb.path = path;
+ assign_blame(&sb, 0);
+ blame_sort_final(&sb);
+ blame_coalesce(&sb);
+
+ cgit_set_title_from_path(path);
+
+ cgit_print_layout_start();
+ htmlf("blob: %s (", oid_to_hex(oid));
+ cgit_plain_link("plain", NULL, NULL, ctx.qry.head, rev, path);
+ html(") (");
+ cgit_tree_link("tree", NULL, NULL, ctx.qry.head, rev, path);
+ html(")\n");
+
+ if (buffer_is_binary(buf, size)) {
+ html("<div class='error'>blob is binary.</div>");
+ goto cleanup;
+ }
+ if (ctx.cfg.max_blob_size && size / 1024 > ctx.cfg.max_blob_size) {
+ htmlf("<div class='error'>blob size (%ldKB)"
+ " exceeds display size limit (%dKB).</div>",
+ size / 1024, ctx.cfg.max_blob_size);
+ goto cleanup;
+ }
+
+ html("<table class='blame blob'>\n<tr>\n");
+
+ /* Commit hashes */
+ html("<td class='hashes'>");
+ for (ent = sb.ent; ent; ent = ent->next) {
+ html("<div class='alt'><pre>");
+ emit_blame_entry_hash(ent);
+ html("</pre></div>");
+ }
+ html("</td>\n");
+
+ /* Line numbers */
+ if (ctx.cfg.enable_tree_linenumbers) {
+ html("<td class='linenumbers'>");
+ for (ent = sb.ent; ent; ent = ent->next) {
+ html("<div class='alt'><pre>");
+ emit_blame_entry_linenumber(ent);
+ html("</pre></div>");
+ }
+ html("</td>\n");
+ }
+
+ html("<td class='lines'><div>");
+
+ /* Colored bars behind lines */
+ html("<div>");
+ for (ent = sb.ent; ent; ) {
+ struct blame_entry *e = ent->next;
+ html("<div class='alt'><pre>");
+ emit_blame_entry_line_background(&sb, ent);
+ html("</pre></div>");
+ free(ent);
+ ent = e;
+ }
+ html("</div>");
+
+ free((void *)sb.final_buf);
+
+ /* Lines */
+ html("<pre><code>");
+ html_txt(buf);
+ html("</code></pre>");
+
+ html("</div></td>\n");
+
+ html("</tr>\n</table>\n");
+
+ cgit_print_layout_end();
+
+cleanup:
+ free(buf);
+}
+
+static int walk_tree(const struct object_id *oid, struct strbuf *base,
+ const char *pathname, unsigned mode, void *cbdata)
+{
+ struct walk_tree_context *walk_tree_ctx = cbdata;
+
+ if (base->len == walk_tree_ctx->match_baselen) {
+ if (S_ISREG(mode)) {
+ struct strbuf buffer = STRBUF_INIT;
+ strbuf_addbuf(&buffer, base);
+ strbuf_addstr(&buffer, pathname);
+ print_object(oid, buffer.buf, walk_tree_ctx->curr_rev);
+ strbuf_release(&buffer);
+ walk_tree_ctx->state = 1;
+ } else if (S_ISDIR(mode)) {
+ walk_tree_ctx->state = 2;
+ }
+ } else if (base->len < INT_MAX
+ && (int)base->len > walk_tree_ctx->match_baselen) {
+ walk_tree_ctx->state = 2;
+ } else if (S_ISDIR(mode)) {
+ return READ_TREE_RECURSIVE;
+ }
+ return 0;
+}
+
+static int basedir_len(const char *path)
+{
+ const char *p = strrchr(path, '/');
+ if (p)
+ return p - path + 1;
+ return 0;
+}
+
+void cgit_print_blame(void)
+{
+ const char *rev = ctx.qry.oid;
+ struct object_id oid;
+ struct commit *commit;
+ struct pathspec_item path_items = {
+ .match = ctx.qry.path,
+ .len = ctx.qry.path ? strlen(ctx.qry.path) : 0
+ };
+ struct pathspec paths = {
+ .nr = 1,
+ .items = &path_items
+ };
+ struct walk_tree_context walk_tree_ctx = {
+ .state = 0
+ };
+
+ if (!rev)
+ rev = ctx.qry.head;
+
+ if (repo_get_oid(the_repository, rev, &oid)) {
+ cgit_print_error_page(404, "Not found",
+ "Invalid revision name: %s", rev);
+ return;
+ }
+ commit = lookup_commit_reference(the_repository, &oid);
+ if (!commit || repo_parse_commit(the_repository, commit)) {
+ cgit_print_error_page(404, "Not found",
+ "Invalid commit reference: %s", rev);
+ return;
+ }
+
+ walk_tree_ctx.curr_rev = xstrdup(rev);
+ walk_tree_ctx.match_baselen = (path_items.match) ?
+ basedir_len(path_items.match) : -1;
+
+ read_tree(the_repository, repo_get_commit_tree(the_repository, commit),
+ &paths, walk_tree, &walk_tree_ctx);
+ if (!walk_tree_ctx.state)
+ cgit_print_error_page(404, "Not found", "Not found");
+ else if (walk_tree_ctx.state == 2)
+ cgit_print_error_page(404, "No blame for folders",
+ "Blame is not available for folders.");
+
+ free(walk_tree_ctx.curr_rev);
+}
diff --git a/cgit/ui-blame.h b/cgit/ui-blame.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-blame.h
@@ -0,0 +1,10 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_BLAME_H
+#define UI_BLAME_H
+
+extern void cgit_print_blame(void);
+
+#endif /* UI_BLAME_H */
diff --git a/cgit/ui-blob.c b/cgit/ui-blob.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-blob.c
@@ -0,0 +1,188 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-blob.c: show blob content
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-blob.h"
+#include "html.h"
+#include "ui-shared.h"
+
+struct walk_tree_context {
+ const char *match_path;
+ struct object_id *matched_oid;
+ unsigned int found_path:1;
+ unsigned int file_only:1;
+};
+
+static int walk_tree(const struct object_id *oid, struct strbuf *base,
+ const char *pathname, unsigned mode, void *cbdata)
+{
+ struct walk_tree_context *walk_tree_ctx = cbdata;
+
+ if (walk_tree_ctx->file_only && !S_ISREG(mode))
+ return READ_TREE_RECURSIVE;
+ if (strncmp(base->buf, walk_tree_ctx->match_path, base->len)
+ || strcmp(walk_tree_ctx->match_path + base->len, pathname))
+ return READ_TREE_RECURSIVE;
+ oidcpy(walk_tree_ctx->matched_oid, oid);
+ walk_tree_ctx->found_path = 1;
+ return 0;
+}
+
+int cgit_ref_path_exists(const char *path, const char *ref, int file_only)
+{
+ struct object_id oid;
+ unsigned long size;
+ struct pathspec_item path_items = {
+ .match = xstrdup(path),
+ .len = strlen(path)
+ };
+ struct pathspec paths = {
+ .nr = 1,
+ .items = &path_items
+ };
+ struct walk_tree_context walk_tree_ctx = {
+ .match_path = path,
+ .matched_oid = &oid,
+ .found_path = 0,
+ .file_only = file_only
+ };
+
+ if (repo_get_oid(the_repository, ref, &oid))
+ goto done;
+ if (odb_read_object_info(the_repository->objects, &oid, &size) != OBJ_COMMIT)
+ goto done;
+ read_tree(the_repository,
+ repo_get_commit_tree(the_repository, lookup_commit_reference(the_repository, &oid)),
+ &paths, walk_tree, &walk_tree_ctx);
+
+done:
+ free(path_items.match);
+ return walk_tree_ctx.found_path;
+}
+
+int cgit_print_file(char *path, const char *head, int file_only)
+{
+ struct object_id oid;
+ enum object_type type;
+ char *buf;
+ unsigned long size;
+ struct commit *commit;
+ struct pathspec_item path_items = {
+ .match = path,
+ .len = strlen(path)
+ };
+ struct pathspec paths = {
+ .nr = 1,
+ .items = &path_items
+ };
+ struct walk_tree_context walk_tree_ctx = {
+ .match_path = path,
+ .matched_oid = &oid,
+ .found_path = 0,
+ .file_only = file_only
+ };
+
+ if (repo_get_oid(the_repository, head, &oid))
+ return -1;
+ type = odb_read_object_info(the_repository->objects, &oid, &size);
+ if (type == OBJ_COMMIT) {
+ commit = lookup_commit_reference(the_repository, &oid);
+ read_tree(the_repository, repo_get_commit_tree(the_repository, commit),
+ &paths, walk_tree, &walk_tree_ctx);
+ if (!walk_tree_ctx.found_path)
+ return -1;
+ type = odb_read_object_info(the_repository->objects, &oid, &size);
+ }
+ if (type == OBJ_BAD)
+ return -1;
+ buf = odb_read_object(the_repository->objects, &oid, &type, &size);
+ if (!buf)
+ return -1;
+ buf[size] = '\0';
+ html_ntxt(buf, size);
+ free(buf);
+ return 0;
+}
+
+void cgit_print_blob(const char *hex, char *path, const char *head, int file_only)
+{
+ struct object_id oid;
+ enum object_type type;
+ char *buf;
+ unsigned long size;
+ struct commit *commit;
+ struct pathspec_item path_items = {
+ .match = path,
+ .len = path ? strlen(path) : 0
+ };
+ struct pathspec paths = {
+ .nr = 1,
+ .items = &path_items
+ };
+ struct walk_tree_context walk_tree_ctx = {
+ .match_path = path,
+ .matched_oid = &oid,
+ .found_path = 0,
+ .file_only = file_only
+ };
+
+ if (hex) {
+ if (get_oid_hex(hex, &oid)) {
+ cgit_print_error_page(400, "Bad request",
+ "Bad hex value: %s", hex);
+ return;
+ }
+ } else {
+ if (repo_get_oid(the_repository, head, &oid)) {
+ cgit_print_error_page(404, "Not found",
+ "Bad ref: %s", head);
+ return;
+ }
+ }
+
+ type = odb_read_object_info(the_repository->objects, &oid, &size);
+
+ if ((!hex) && type == OBJ_COMMIT && path) {
+ commit = lookup_commit_reference(the_repository, &oid);
+ read_tree(the_repository, repo_get_commit_tree(the_repository, commit),
+ &paths, walk_tree, &walk_tree_ctx);
+ type = odb_read_object_info(the_repository->objects, &oid, &size);
+ }
+
+ if (type == OBJ_BAD) {
+ cgit_print_error_page(404, "Not found",
+ "Bad object name: %s", hex);
+ return;
+ }
+
+ buf = odb_read_object(the_repository->objects, &oid, &type, &size);
+ if (!buf) {
+ cgit_print_error_page(500, "Internal server error",
+ "Error reading object %s", hex);
+ return;
+ }
+
+ buf[size] = '\0';
+ if (buffer_is_binary(buf, size))
+ ctx.page.mimetype = "application/octet-stream";
+ else
+ ctx.page.mimetype = "text/plain";
+ ctx.page.filename = path;
+
+ html("X-Content-Type-Options: nosniff\n");
+ html("Content-Security-Policy: default-src 'none'\n");
+ cgit_print_http_headers();
+ html_raw(buf, size);
+ free(buf);
+}
diff --git a/cgit/ui-blob.h b/cgit/ui-blob.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-blob.h
@@ -0,0 +1,12 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_BLOB_H
+#define UI_BLOB_H
+
+extern int cgit_ref_path_exists(const char *path, const char *ref, int file_only);
+extern int cgit_print_file(char *path, const char *head, int file_only);
+extern void cgit_print_blob(const char *hex, char *path, const char *head, int file_only);
+
+#endif /* UI_BLOB_H */
diff --git a/cgit/ui-clone.c b/cgit/ui-clone.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-clone.c
@@ -0,0 +1,142 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-clone.c: functions for http cloning, based on
+ * git's http-backend.c by Shawn O. Pearce
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-clone.h"
+#include "html.h"
+#include "ui-shared.h"
+#include "packfile.h"
+
+static int print_ref_info(const struct reference *ref, void *cb_data)
+{
+ struct object *obj;
+
+ if (!(obj = parse_object(the_repository, ref->oid)))
+ return 0;
+
+ htmlf("%s\t%s\n", oid_to_hex(ref->oid), ref->name);
+ if (obj->type == OBJ_TAG) {
+ if (!(obj = deref_tag(the_repository, obj, ref->name, 0)))
+ return 0;
+ htmlf("%s\t%s^{}\n", oid_to_hex(&obj->oid), ref->name);
+ }
+ return 0;
+}
+
+static void print_pack_info(void)
+{
+ struct odb_source *source;
+ char *offset;
+
+ ctx.page.mimetype = "text/plain";
+ ctx.page.filename = "objects/info/packs";
+ cgit_print_http_headers();
+ odb_reprepare(the_repository->objects);
+ for (source = the_repository->objects->sources; source; source = source->next) {
+ struct odb_source_files *files = odb_source_files_downcast(source);
+ struct packfile_list_entry *e;
+ for (e = files->packed->packs.head; e; e = e->next) {
+ struct packed_git *p = e->pack;
+ if (p->pack_local) {
+ offset = strrchr(p->pack_name, '/');
+ if (offset && offset[1] != '\0')
+ ++offset;
+ else
+ offset = p->pack_name;
+ htmlf("P %s\n", offset);
+ }
+ }
+ }
+}
+
+static void send_file(const char *path)
+{
+ struct stat st;
+
+ if (stat(path, &st)) {
+ switch (errno) {
+ case ENOENT:
+ cgit_print_error_page(404, "Not found", "Not found");
+ break;
+ case EACCES:
+ cgit_print_error_page(403, "Forbidden", "Forbidden");
+ break;
+ default:
+ cgit_print_error_page(400, "Bad request", "Bad request");
+ }
+ return;
+ }
+ ctx.page.mimetype = "application/octet-stream";
+ ctx.page.filename = path;
+ skip_prefix(path, ctx.repo->path, &ctx.page.filename);
+ skip_prefix(ctx.page.filename, "/", &ctx.page.filename);
+ cgit_print_http_headers();
+ html_include(path);
+}
+
+void cgit_clone_info(void)
+{
+ if (!ctx.qry.path || strcmp(ctx.qry.path, "refs")) {
+ cgit_print_error_page(400, "Bad request", "Bad request");
+ return;
+ }
+
+ ctx.page.mimetype = "text/plain";
+ ctx.page.filename = "info/refs";
+ cgit_print_http_headers();
+ refs_for_each_ref(get_main_ref_store(the_repository),
+ print_ref_info, NULL);
+}
+
+void cgit_clone_objects(void)
+{
+ char *p, *path;
+
+ if (!ctx.qry.path)
+ goto err;
+
+ if (!strcmp(ctx.qry.path, "info/packs")) {
+ print_pack_info();
+ return;
+ }
+
+ /* Avoid directory traversal by forbidding "..", but also work around
+ * other funny business by just specifying a fairly strict format. For
+ * example, now we don't have to stress out about the Cygwin port.
+ */
+ for (p = ctx.qry.path; *p; ++p) {
+ if (*p == '.' && *(p + 1) == '.')
+ goto err;
+ if (!isalnum(*p) && *p != '/' && *p != '.' && *p != '-')
+ goto err;
+ }
+
+ path = repo_git_path(the_repository, "objects/%s", ctx.qry.path);
+ send_file(path);
+ free(path);
+ return;
+
+err:
+ cgit_print_error_page(400, "Bad request", "Bad request");
+}
+
+void cgit_clone_head(void)
+{
+ char *path;
+
+ path = repo_git_path(the_repository, "HEAD");
+ send_file(path);
+ free(path);
+}
diff --git a/cgit/ui-clone.h b/cgit/ui-clone.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-clone.h
@@ -0,0 +1,12 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_CLONE_H
+#define UI_CLONE_H
+
+void cgit_clone_info(void);
+void cgit_clone_objects(void);
+void cgit_clone_head(void);
+
+#endif /* UI_CLONE_H */
diff --git a/cgit/ui-commit.c b/cgit/ui-commit.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-commit.c
@@ -0,0 +1,144 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-commit.c: generate commit view
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-commit.h"
+#include "html.h"
+#include "ui-shared.h"
+#include "ui-diff.h"
+#include "ui-log.h"
+
+void cgit_print_commit(char *hex, const char *prefix)
+{
+ struct commit *commit, *parent;
+ struct commitinfo *info, *parent_info;
+ struct commit_list *p;
+ struct strbuf notes = STRBUF_INIT;
+ struct object_id oid;
+ char *tmp, *tmp2;
+ int parents = 0;
+
+ if (!hex)
+ hex = ctx.qry.head;
+
+ if (repo_get_oid(the_repository, hex, &oid)) {
+ cgit_print_error_page(400, "Bad request",
+ "Bad object id: %s", hex);
+ return;
+ }
+ commit = lookup_commit_reference(the_repository, &oid);
+ if (!commit) {
+ cgit_print_error_page(404, "Not found",
+ "Bad commit reference: %s", hex);
+ return;
+ }
+ info = cgit_parse_commit(commit);
+
+ format_display_notes(&oid, &notes, PAGE_ENCODING, 1);
+
+ load_ref_decorations(NULL, DECORATE_FULL_REFS);
+
+ ctx.page.title = fmtalloc("%s - %s", info->subject, ctx.page.title);
+ cgit_print_layout_start();
+ cgit_print_diff_ctrls();
+ html("<table summary='commit info' class='commit-info'>\n");
+ html("<tr><th>author</th><td>");
+ html_txt(info->author);
+ if (!ctx.cfg.noplainemail) {
+ html(" ");
+ html_txt(info->author_email);
+ }
+ html("</td><td class='right'>");
+ html_txt(show_date(info->author_date, info->author_tz,
+ cgit_date_mode(DATE_ISO8601)));
+ html("</td></tr>\n");
+ html("<tr><th>committer</th><td>");
+ html_txt(info->committer);
+ if (!ctx.cfg.noplainemail) {
+ html(" ");
+ html_txt(info->committer_email);
+ }
+ html("</td><td class='right'>");
+ html_txt(show_date(info->committer_date, info->committer_tz,
+ cgit_date_mode(DATE_ISO8601)));
+ html("</td></tr>\n");
+ html("<tr><th>commit</th><td colspan='2' class='oid'>");
+ tmp = oid_to_hex(&commit->object.oid);
+ cgit_commit_link(tmp, NULL, NULL, ctx.qry.head, tmp, prefix);
+ html(" (");
+ cgit_patch_link("patch", NULL, NULL, NULL, tmp, prefix);
+ html(")</td></tr>\n");
+ html("<tr><th>tree</th><td colspan='2' class='oid'>");
+ tmp = xstrdup(hex);
+ cgit_tree_link(oid_to_hex(get_commit_tree_oid(commit)), NULL, NULL,
+ ctx.qry.head, tmp, NULL);
+ if (prefix) {
+ html(" /");
+ cgit_tree_link(prefix, NULL, NULL, ctx.qry.head, tmp, prefix);
+ }
+ free(tmp);
+ html("</td></tr>\n");
+ for (p = commit->parents; p; p = p->next) {
+ parent = lookup_commit_reference(the_repository, &p->item->object.oid);
+ if (!parent) {
+ html("<tr><td colspan='3'>");
+ cgit_print_error("Error reading parent commit");
+ html("</td></tr>");
+ continue;
+ }
+ html("<tr><th>parent</th>"
+ "<td colspan='2' class='oid'>");
+ tmp = tmp2 = oid_to_hex(&p->item->object.oid);
+ if (ctx.repo->enable_subject_links) {
+ parent_info = cgit_parse_commit(parent);
+ tmp2 = parent_info->subject;
+ }
+ cgit_commit_link(tmp2, NULL, NULL, ctx.qry.head, tmp, prefix);
+ html(" (");
+ cgit_diff_link("diff", NULL, NULL, ctx.qry.head, hex,
+ oid_to_hex(&p->item->object.oid), prefix);
+ html(")</td></tr>");
+ parents++;
+ }
+ if (ctx.repo->snapshots) {
+ html("<tr><th>download</th><td colspan='2' class='oid'>");
+ cgit_print_snapshot_links(ctx.repo, hex, "<br/>");
+ html("</td></tr>");
+ }
+ html("</table>\n");
+ html("<div class='commit-subject'>");
+ html_txt(info->subject);
+ show_commit_decorations(commit);
+ html("</div>");
+ html("<div class='commit-msg'>");
+ html_txt(info->msg);
+ html("</div>");
+ if (notes.len != 0) {
+ html("<div class='notes-header'>Notes</div>");
+ html("<div class='notes'>");
+ html_txt(notes.buf);
+ html("</div>");
+ html("<div class='notes-footer'></div>");
+ }
+ if (parents < 3) {
+ if (parents)
+ tmp = oid_to_hex(&commit->parents->item->object.oid);
+ else
+ tmp = NULL;
+ cgit_print_diff(ctx.qry.oid, tmp, prefix, 0, 0);
+ }
+ strbuf_release(&notes);
+ cgit_free_commitinfo(info);
+ cgit_print_layout_end();
+}
diff --git a/cgit/ui-commit.h b/cgit/ui-commit.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-commit.h
@@ -0,0 +1,10 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_COMMIT_H
+#define UI_COMMIT_H
+
+extern void cgit_print_commit(char *hex, const char *prefix);
+
+#endif /* UI_COMMIT_H */
diff --git a/cgit/ui-diff.c b/cgit/ui-diff.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-diff.c
@@ -0,0 +1,507 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-diff.c: show diff between two blobs
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-diff.h"
+#include "html.h"
+#include "ui-shared.h"
+#include "ui-ssdiff.h"
+
+struct object_id old_rev_oid[1];
+struct object_id new_rev_oid[1];
+
+static int files, slots;
+static int total_adds, total_rems, max_changes;
+static int lines_added, lines_removed;
+
+static struct fileinfo {
+ char status;
+ struct object_id old_oid[1];
+ struct object_id new_oid[1];
+ unsigned short old_mode;
+ unsigned short new_mode;
+ char *old_path;
+ char *new_path;
+ unsigned int added;
+ unsigned int removed;
+ unsigned long old_size;
+ unsigned long new_size;
+ unsigned int binary:1;
+} *items;
+
+static int use_ssdiff = 0;
+static struct diff_filepair *current_filepair;
+static const char *current_prefix;
+
+struct diff_filespec *cgit_get_current_old_file(void)
+{
+ return current_filepair->one;
+}
+
+struct diff_filespec *cgit_get_current_new_file(void)
+{
+ return current_filepair->two;
+}
+
+static void print_fileinfo(struct fileinfo *info)
+{
+ char *class;
+
+ switch (info->status) {
+ case DIFF_STATUS_ADDED:
+ class = "add";
+ break;
+ case DIFF_STATUS_COPIED:
+ class = "cpy";
+ break;
+ case DIFF_STATUS_DELETED:
+ class = "del";
+ break;
+ case DIFF_STATUS_MODIFIED:
+ class = "upd";
+ break;
+ case DIFF_STATUS_RENAMED:
+ class = "mov";
+ break;
+ case DIFF_STATUS_TYPE_CHANGED:
+ class = "typ";
+ break;
+ case DIFF_STATUS_UNKNOWN:
+ class = "unk";
+ break;
+ case DIFF_STATUS_UNMERGED:
+ class = "stg";
+ break;
+ default:
+ die("bug: unhandled diff status %c", info->status);
+ }
+
+ html("<tr>");
+ html("<td class='mode'>");
+ if (is_null_oid(info->new_oid)) {
+ cgit_print_filemode(info->old_mode);
+ } else {
+ cgit_print_filemode(info->new_mode);
+ }
+
+ if (info->old_mode != info->new_mode &&
+ !is_null_oid(info->old_oid) &&
+ !is_null_oid(info->new_oid)) {
+ html("<span class='modechange'>[");
+ cgit_print_filemode(info->old_mode);
+ html("]</span>");
+ }
+ htmlf("</td><td class='%s'>", class);
+ cgit_diff_link(info->new_path, NULL, NULL, ctx.qry.head, ctx.qry.oid,
+ ctx.qry.oid2, info->new_path);
+ if (info->status == DIFF_STATUS_COPIED || info->status == DIFF_STATUS_RENAMED) {
+ htmlf(" (%s from ",
+ info->status == DIFF_STATUS_COPIED ? "copied" : "renamed");
+ html_txt(info->old_path);
+ html(")");
+ }
+ html("</td><td class='right'>");
+ if (info->binary) {
+ htmlf("bin</td><td class='graph'>%ld -> %ld bytes",
+ info->old_size, info->new_size);
+ return;
+ }
+ htmlf("%d", info->added + info->removed);
+ html("</td><td class='graph'>");
+ htmlf("<table summary='file diffstat' width='%d%%'><tr>", (max_changes > 100 ? 100 : max_changes));
+ htmlf("<td class='add' style='width: %.1f%%;'/>",
+ info->added * 100.0 / max_changes);
+ htmlf("<td class='rem' style='width: %.1f%%;'/>",
+ info->removed * 100.0 / max_changes);
+ htmlf("<td class='none' style='width: %.1f%%;'/>",
+ (max_changes - info->removed - info->added) * 100.0 / max_changes);
+ html("</tr></table></td></tr>\n");
+}
+
+static void count_diff_lines(char *line, int len)
+{
+ if (line && (len > 0)) {
+ if (line[0] == '+')
+ lines_added++;
+ else if (line[0] == '-')
+ lines_removed++;
+ }
+}
+
+static int show_filepair(struct diff_filepair *pair)
+{
+ /* Always show if we have no limiting prefix. */
+ if (!current_prefix)
+ return 1;
+
+ /* Show if either path in the pair begins with the prefix. */
+ if (starts_with(pair->one->path, current_prefix) ||
+ starts_with(pair->two->path, current_prefix))
+ return 1;
+
+ /* Otherwise we don't want to show this filepair. */
+ return 0;
+}
+
+static void inspect_filepair(struct diff_filepair *pair)
+{
+ int binary = 0;
+ unsigned long old_size = 0;
+ unsigned long new_size = 0;
+
+ if (!show_filepair(pair))
+ return;
+
+ files++;
+ lines_added = 0;
+ lines_removed = 0;
+ cgit_diff_files(&pair->one->oid, &pair->two->oid, &old_size, &new_size,
+ &binary, 0, ctx.qry.ignorews, count_diff_lines);
+ if (files >= slots) {
+ if (slots == 0)
+ slots = 4;
+ else
+ slots = slots * 2;
+ items = xrealloc(items, slots * sizeof(struct fileinfo));
+ }
+ items[files-1].status = pair->status;
+ oidcpy(items[files-1].old_oid, &pair->one->oid);
+ oidcpy(items[files-1].new_oid, &pair->two->oid);
+ items[files-1].old_mode = pair->one->mode;
+ items[files-1].new_mode = pair->two->mode;
+ items[files-1].old_path = xstrdup(pair->one->path);
+ items[files-1].new_path = xstrdup(pair->two->path);
+ items[files-1].added = lines_added;
+ items[files-1].removed = lines_removed;
+ items[files-1].old_size = old_size;
+ items[files-1].new_size = new_size;
+ items[files-1].binary = binary;
+ if (lines_added + lines_removed > max_changes)
+ max_changes = lines_added + lines_removed;
+ total_adds += lines_added;
+ total_rems += lines_removed;
+}
+
+static void cgit_print_diffstat(const struct object_id *old_oid,
+ const struct object_id *new_oid,
+ const char *prefix)
+{
+ int i;
+
+ html("<div class='diffstat-header'>");
+ cgit_diff_link("Diffstat", NULL, NULL, ctx.qry.head, ctx.qry.oid,
+ ctx.qry.oid2, NULL);
+ if (prefix) {
+ html(" (limited to '");
+ html_txt(prefix);
+ html("')");
+ }
+ html("</div>");
+ html("<table summary='diffstat' class='diffstat'>");
+ max_changes = 0;
+ cgit_diff_tree(old_oid, new_oid, inspect_filepair, prefix,
+ ctx.qry.ignorews);
+ for (i = 0; i<files; i++)
+ print_fileinfo(&items[i]);
+ html("</table>");
+ html("<div class='diffstat-summary'>");
+ htmlf("%d files changed, %d insertions, %d deletions",
+ files, total_adds, total_rems);
+ html("</div>");
+}
+
+
+/*
+ * print a single line returned from xdiff
+ */
+static void print_line(char *line, int len)
+{
+ char *class = "ctx";
+ char c = line[len-1];
+
+ if (line[0] == '+')
+ class = "add";
+ else if (line[0] == '-')
+ class = "del";
+ else if (line[0] == '@')
+ class = "hunk";
+
+ htmlf("<div class='%s'>", class);
+ line[len-1] = '\0';
+ html_txt(line);
+ html("</div>");
+ line[len-1] = c;
+}
+
+static void header(const struct object_id *oid1, char *path1, int mode1,
+ const struct object_id *oid2, char *path2, int mode2)
+{
+ char *abbrev1, *abbrev2;
+ int subproject;
+
+ subproject = (S_ISGITLINK(mode1) || S_ISGITLINK(mode2));
+ html("<div class='head'>");
+ html("diff --git a/");
+ html_txt(path1);
+ html(" b/");
+ html_txt(path2);
+
+ if (mode1 == 0)
+ htmlf("<br/>new file mode %.6o", mode2);
+
+ if (mode2 == 0)
+ htmlf("<br/>deleted file mode %.6o", mode1);
+
+ if (!subproject) {
+ abbrev1 = xstrdup(repo_find_unique_abbrev(the_repository, oid1, DEFAULT_ABBREV));
+ abbrev2 = xstrdup(repo_find_unique_abbrev(the_repository, oid2, DEFAULT_ABBREV));
+ htmlf("<br/>index %s..%s", abbrev1, abbrev2);
+ free(abbrev1);
+ free(abbrev2);
+ if (mode1 != 0 && mode2 != 0) {
+ htmlf(" %.6o", mode1);
+ if (mode2 != mode1)
+ htmlf("..%.6o", mode2);
+ }
+ if (is_null_oid(oid1)) {
+ path1 = "dev/null";
+ html("<br/>--- /");
+ } else
+ html("<br/>--- a/");
+ if (mode1 != 0)
+ cgit_tree_link(path1, NULL, NULL, ctx.qry.head,
+ oid_to_hex(old_rev_oid), path1);
+ else
+ html_txt(path1);
+ if (is_null_oid(oid2)) {
+ path2 = "dev/null";
+ html("<br/>+++ /");
+ } else
+ html("<br/>+++ b/");
+ if (mode2 != 0)
+ cgit_tree_link(path2, NULL, NULL, ctx.qry.head,
+ oid_to_hex(new_rev_oid), path2);
+ else
+ html_txt(path2);
+ }
+ html("</div>");
+}
+
+static void filepair_cb(struct diff_filepair *pair)
+{
+ unsigned long old_size = 0;
+ unsigned long new_size = 0;
+ int binary = 0;
+ linediff_fn print_line_fn = print_line;
+
+ if (!show_filepair(pair))
+ return;
+
+ current_filepair = pair;
+ if (use_ssdiff) {
+ cgit_ssdiff_header_begin();
+ print_line_fn = cgit_ssdiff_line_cb;
+ }
+ header(&pair->one->oid, pair->one->path, pair->one->mode,
+ &pair->two->oid, pair->two->path, pair->two->mode);
+ if (use_ssdiff)
+ cgit_ssdiff_header_end();
+ if (S_ISGITLINK(pair->one->mode) || S_ISGITLINK(pair->two->mode)) {
+ if (S_ISGITLINK(pair->one->mode))
+ print_line_fn(fmt("-Subproject %s", oid_to_hex(&pair->one->oid)), 52);
+ if (S_ISGITLINK(pair->two->mode))
+ print_line_fn(fmt("+Subproject %s", oid_to_hex(&pair->two->oid)), 52);
+ if (use_ssdiff)
+ cgit_ssdiff_footer();
+ return;
+ }
+ if (cgit_diff_files(&pair->one->oid, &pair->two->oid, &old_size,
+ &new_size, &binary, ctx.qry.context,
+ ctx.qry.ignorews, print_line_fn))
+ cgit_print_error("Error running diff");
+ if (binary) {
+ if (use_ssdiff)
+ html("<tr><td colspan='4'>Binary files differ</td></tr>");
+ else
+ html("Binary files differ");
+ }
+ if (use_ssdiff)
+ cgit_ssdiff_footer();
+}
+
+void cgit_print_diff_ctrls(void)
+{
+ int i, curr;
+
+ html("<div class='cgit-panel'>");
+ html("<b>diff options</b>");
+ html("<form method='get'>");
+ cgit_add_hidden_formfields(1, 0, ctx.qry.page);
+ html("<table>");
+ html("<tr><td colspan='2'/></tr>");
+ html("<tr>");
+ html("<td class='label'>context:</td>");
+ html("<td class='ctrl'>");
+ html("<select name='context' onchange='this.form.submit();'>");
+ curr = ctx.qry.context;
+ if (!curr)
+ curr = 3;
+ for (i = 1; i <= 10; i++)
+ html_intoption(i, fmt("%d", i), curr);
+ for (i = 15; i <= 40; i += 5)
+ html_intoption(i, fmt("%d", i), curr);
+ html("</select>");
+ html("</td>");
+ html("</tr><tr>");
+ html("<td class='label'>space:</td>");
+ html("<td class='ctrl'>");
+ html("<select name='ignorews' onchange='this.form.submit();'>");
+ html_intoption(0, "include", ctx.qry.ignorews);
+ html_intoption(1, "ignore", ctx.qry.ignorews);
+ html("</select>");
+ html("</td>");
+ html("</tr><tr>");
+ html("<td class='label'>mode:</td>");
+ html("<td class='ctrl'>");
+ html("<select name='dt' onchange='this.form.submit();'>");
+ curr = ctx.qry.has_difftype ? ctx.qry.difftype : ctx.cfg.difftype;
+ html_intoption(0, "unified", curr);
+ html_intoption(1, "ssdiff", curr);
+ html_intoption(2, "stat only", curr);
+ html("</select></td></tr>");
+ html("<tr><td/><td class='ctrl'>");
+ html("<noscript><input type='submit' value='reload'/></noscript>");
+ html("</td></tr></table>");
+ html("</form>");
+ html("</div>");
+}
+
+void cgit_print_diff(const char *new_rev, const char *old_rev,
+ const char *prefix, int show_ctrls, int raw)
+{
+ struct commit *commit, *commit2;
+ const struct object_id *old_tree_oid, *new_tree_oid;
+ diff_type difftype;
+
+ /*
+ * If "follow" is set then the diff machinery needs to examine the
+ * entire commit to detect renames so we must limit the paths in our
+ * own callbacks and not pass the prefix to the diff machinery.
+ */
+ if (ctx.qry.follow && ctx.repo->enable_follow_links) {
+ current_prefix = prefix;
+ prefix = "";
+ } else {
+ current_prefix = NULL;
+ }
+
+ if (!new_rev)
+ new_rev = ctx.qry.head;
+ if (repo_get_oid(the_repository, new_rev, new_rev_oid)) {
+ cgit_print_error_page(404, "Not found",
+ "Bad object name: %s", new_rev);
+ return;
+ }
+ commit = lookup_commit_reference(the_repository, new_rev_oid);
+ if (!commit || repo_parse_commit(the_repository, commit)) {
+ cgit_print_error_page(404, "Not found",
+ "Bad commit: %s", oid_to_hex(new_rev_oid));
+ return;
+ }
+ new_tree_oid = get_commit_tree_oid(commit);
+
+ if (old_rev) {
+ if (repo_get_oid(the_repository, old_rev, old_rev_oid)) {
+ cgit_print_error_page(404, "Not found",
+ "Bad object name: %s", old_rev);
+ return;
+ }
+ } else if (commit->parents && commit->parents->item) {
+ oidcpy(old_rev_oid, &commit->parents->item->object.oid);
+ } else {
+ oidclr(old_rev_oid, the_repository->hash_algo);
+ }
+
+ if (!is_null_oid(old_rev_oid)) {
+ commit2 = lookup_commit_reference(the_repository, old_rev_oid);
+ if (!commit2 || repo_parse_commit(the_repository, commit2)) {
+ cgit_print_error_page(404, "Not found",
+ "Bad commit: %s", oid_to_hex(old_rev_oid));
+ return;
+ }
+ old_tree_oid = get_commit_tree_oid(commit2);
+ } else {
+ old_tree_oid = NULL;
+ }
+
+ if (raw) {
+ struct diff_options diffopt;
+
+ repo_diff_setup(the_repository, &diffopt);
+ diffopt.output_format = DIFF_FORMAT_PATCH;
+ diffopt.flags.recursive = 1;
+ diff_setup_done(&diffopt);
+
+ ctx.page.mimetype = "text/plain";
+ cgit_print_http_headers();
+ if (old_tree_oid) {
+ diff_tree_oid(old_tree_oid, new_tree_oid, "",
+ &diffopt);
+ } else {
+ diff_root_tree_oid(new_tree_oid, "", &diffopt);
+ }
+ diffcore_std(&diffopt);
+ diff_flush(&diffopt);
+
+ return;
+ }
+
+ difftype = ctx.qry.has_difftype ? ctx.qry.difftype : ctx.cfg.difftype;
+ use_ssdiff = difftype == DIFF_SSDIFF;
+
+ if (show_ctrls) {
+ cgit_print_layout_start();
+ cgit_print_diff_ctrls();
+ }
+
+ /*
+ * Clicking on a link to a file in the diff stat should show a diff
+ * of the file, showing the diff stat limited to a single file is
+ * pretty useless. All links from this point on will be to
+ * individual files, so we simply reset the difftype in the query
+ * here to avoid propagating DIFF_STATONLY to the individual files.
+ */
+ if (difftype == DIFF_STATONLY)
+ ctx.qry.difftype = ctx.cfg.difftype;
+
+ cgit_print_diffstat(old_rev_oid, new_rev_oid, prefix);
+
+ if (difftype == DIFF_STATONLY)
+ return;
+
+ if (use_ssdiff) {
+ html("<table summary='ssdiff' class='ssdiff'>");
+ } else {
+ html("<table summary='diff' class='diff'>");
+ html("<tr><td>");
+ }
+ cgit_diff_tree(old_rev_oid, new_rev_oid, filepair_cb, prefix,
+ ctx.qry.ignorews);
+ if (!use_ssdiff)
+ html("</td></tr>");
+ html("</table>");
+
+ if (show_ctrls)
+ cgit_print_layout_end();
+}
diff --git a/cgit/ui-diff.h b/cgit/ui-diff.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-diff.h
@@ -0,0 +1,19 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_DIFF_H
+#define UI_DIFF_H
+
+extern void cgit_print_diff_ctrls(void);
+
+extern void cgit_print_diff(const char *new_hex, const char *old_hex,
+ const char *prefix, int show_ctrls, int raw);
+
+extern struct diff_filespec *cgit_get_current_old_file(void);
+extern struct diff_filespec *cgit_get_current_new_file(void);
+
+extern struct object_id old_rev_oid[1];
+extern struct object_id new_rev_oid[1];
+
+#endif /* UI_DIFF_H */
diff --git a/cgit/ui-log.c b/cgit/ui-log.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-log.c
@@ -0,0 +1,555 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-log.c: functions for log output
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-log.h"
+#include "html.h"
+#include "ui-shared.h"
+#include "strvec.h"
+
+static int files, add_lines, rem_lines, lines_counted;
+
+/*
+ * The list of available column colors in the commit graph.
+ */
+static const char *column_colors_html[] = {
+ "<span class='column1'>",
+ "<span class='column2'>",
+ "<span class='column3'>",
+ "<span class='column4'>",
+ "<span class='column5'>",
+ "<span class='column6'>",
+ "</span>",
+};
+
+#define COLUMN_COLORS_HTML_MAX (ARRAY_SIZE(column_colors_html) - 1)
+
+static void count_lines(char *line, int size)
+{
+ if (size <= 0)
+ return;
+
+ if (line[0] == '+')
+ add_lines++;
+
+ else if (line[0] == '-')
+ rem_lines++;
+}
+
+static void inspect_files(struct diff_filepair *pair)
+{
+ unsigned long old_size = 0;
+ unsigned long new_size = 0;
+ int binary = 0;
+
+ files++;
+ if (ctx.repo->enable_log_linecount)
+ cgit_diff_files(&pair->one->oid, &pair->two->oid, &old_size,
+ &new_size, &binary, 0, ctx.qry.ignorews,
+ count_lines);
+}
+
+void show_commit_decorations(struct commit *commit)
+{
+ const struct name_decoration *deco;
+ static char buf[1024];
+
+ buf[sizeof(buf) - 1] = 0;
+ deco = get_name_decoration(&commit->object);
+ if (!deco)
+ return;
+ html("<span class='decoration'>");
+ while (deco) {
+ struct object_id oid_tag, peeled;
+ int is_annotated = 0;
+
+ strlcpy(buf, prettify_refname(deco->name), sizeof(buf));
+ switch(deco->type) {
+ case DECORATION_NONE:
+ /* If the git-core doesn't recognize it,
+ * don't display anything. */
+ break;
+ case DECORATION_REF_LOCAL:
+ cgit_log_link(buf, NULL, "branch-deco", buf, NULL,
+ ctx.qry.vpath, 0, NULL, NULL,
+ ctx.qry.showmsg, 0);
+ break;
+ case DECORATION_REF_TAG:
+ if (!refs_read_ref(get_main_ref_store(the_repository), deco->name, &oid_tag) &&
+ !peel_object(the_repository, &oid_tag, &peeled, PEEL_OBJECT_VERIFY_TAGGED_OBJECT_TYPE))
+ is_annotated = !oideq(&oid_tag, &peeled);
+ cgit_tag_link(buf, NULL, is_annotated ? "tag-annotated-deco" : "tag-deco", buf);
+ break;
+ case DECORATION_REF_REMOTE:
+ if (!ctx.repo->enable_remote_branches)
+ break;
+ cgit_log_link(buf, NULL, "remote-deco", NULL,
+ oid_to_hex(&commit->object.oid),
+ ctx.qry.vpath, 0, NULL, NULL,
+ ctx.qry.showmsg, 0);
+ break;
+ default:
+ cgit_commit_link(buf, NULL, "deco", ctx.qry.head,
+ oid_to_hex(&commit->object.oid),
+ ctx.qry.vpath);
+ break;
+ }
+ deco = deco->next;
+ }
+ html("</span>");
+}
+
+static void handle_rename(struct diff_filepair *pair)
+{
+ /*
+ * After we have seen a rename, we generate links to the previous
+ * name of the file so that commit & diff views get fed the path
+ * that is correct for the commit they are showing, avoiding the
+ * need to walk the entire history leading back to every commit we
+ * show in order detect renames.
+ */
+ if (0 != strcmp(ctx.qry.vpath, pair->two->path)) {
+ free(ctx.qry.vpath);
+ ctx.qry.vpath = xstrdup(pair->two->path);
+ }
+ inspect_files(pair);
+}
+
+static int show_commit(struct commit *commit, struct rev_info *revs)
+{
+ struct commit_list *parents = commit->parents;
+ struct commit *parent;
+ int found = 0, saved_fmt;
+ struct diff_flags saved_flags = revs->diffopt.flags;
+
+ /* Always show if we're not in "follow" mode with a single file. */
+ if (!ctx.qry.follow)
+ return 1;
+
+ /*
+ * In "follow" mode, we don't show merges. This is consistent with
+ * "git log --follow -- <file>".
+ */
+ if (parents && parents->next)
+ return 0;
+
+ /*
+ * If this is the root commit, do what rev_info tells us.
+ */
+ if (!parents)
+ return revs->show_root_diff;
+
+ /* When we get here we have precisely one parent. */
+ parent = parents->item;
+ /* If we can't parse the commit, let print_commit() report an error. */
+ if (repo_parse_commit(the_repository, parent))
+ return 1;
+
+ files = 0;
+ add_lines = 0;
+ rem_lines = 0;
+
+ revs->diffopt.flags.recursive = 1;
+ diff_tree_oid(get_commit_tree_oid(parent),
+ get_commit_tree_oid(commit),
+ "", &revs->diffopt);
+ diffcore_std(&revs->diffopt);
+
+ found = !diff_queue_is_empty(&revs->diffopt);
+ saved_fmt = revs->diffopt.output_format;
+ revs->diffopt.output_format = DIFF_FORMAT_CALLBACK;
+ revs->diffopt.format_callback = cgit_diff_tree_cb;
+ revs->diffopt.format_callback_data = handle_rename;
+ revs->diffopt.no_free = 1;
+ diff_flush(&revs->diffopt);
+ revs->diffopt.output_format = saved_fmt;
+ revs->diffopt.flags = saved_flags;
+
+ lines_counted = 1;
+ return found;
+}
+
+static void print_commit(struct commit *commit, struct rev_info *revs)
+{
+ struct commitinfo *info;
+ int columns = revs->graph ? 4 : 3;
+ struct strbuf graphbuf = STRBUF_INIT;
+ struct strbuf msgbuf = STRBUF_INIT;
+
+ if (ctx.repo->enable_log_filecount)
+ columns++;
+ if (ctx.repo->enable_log_linecount)
+ columns++;
+
+ if (revs->graph) {
+ /* Advance graph until current commit */
+ while (!graph_next_line(revs->graph, &graphbuf)) {
+ /* Print graph segment in otherwise empty table row */
+ html("<tr class='nohover'><td class='commitgraph'>");
+ html(graphbuf.buf);
+ htmlf("</td><td colspan='%d' /></tr>\n", columns);
+ strbuf_setlen(&graphbuf, 0);
+ }
+ /* Current commit's graph segment is now ready in graphbuf */
+ }
+
+ info = cgit_parse_commit(commit);
+ htmlf("<tr%s>", ctx.qry.showmsg ? " class='logheader'" : "");
+
+ if (revs->graph) {
+ /* Print graph segment for current commit */
+ html("<td class='commitgraph'>");
+ html(graphbuf.buf);
+ html("</td>");
+ strbuf_setlen(&graphbuf, 0);
+ }
+ else {
+ html("<td>");
+ cgit_print_age(info->committer_date, info->committer_tz, TM_WEEK * 2);
+ html("</td>");
+ }
+
+ htmlf("<td%s>", ctx.qry.showmsg ? " class='logsubject'" : "");
+ if (ctx.qry.showmsg) {
+ /* line-wrap long commit subjects instead of truncating them */
+ size_t subject_len = strlen(info->subject);
+
+ if (subject_len > ctx.cfg.max_msg_len &&
+ ctx.cfg.max_msg_len >= 15) {
+ /* symbol for signaling line-wrap (in PAGE_ENCODING) */
+ const char wrap_symbol[] = { ' ', 0xE2, 0x86, 0xB5, 0 };
+ int i = ctx.cfg.max_msg_len - strlen(wrap_symbol);
+
+ /* Rewind i to preceding space character */
+ while (i > 0 && !isspace(info->subject[i]))
+ --i;
+ if (!i) /* Oops, zero spaces. Reset i */
+ i = ctx.cfg.max_msg_len - strlen(wrap_symbol);
+
+ /* add remainder starting at i to msgbuf */
+ strbuf_add(&msgbuf, info->subject + i, subject_len - i);
+ strbuf_trim(&msgbuf);
+ strbuf_add(&msgbuf, "\n\n", 2);
+
+ /* Place wrap_symbol at position i in info->subject */
+ strlcpy(info->subject + i, wrap_symbol, subject_len - i + 1);
+ }
+ }
+ cgit_commit_link(info->subject, NULL, NULL, ctx.qry.head,
+ oid_to_hex(&commit->object.oid), ctx.qry.vpath);
+ show_commit_decorations(commit);
+ html("</td><td>");
+ html_txt(info->author);
+
+ if (revs->graph) {
+ html("</td><td>");
+ cgit_print_age(info->committer_date, info->committer_tz, TM_WEEK * 2);
+ }
+
+ if (!lines_counted && (ctx.repo->enable_log_filecount ||
+ ctx.repo->enable_log_linecount)) {
+ files = 0;
+ add_lines = 0;
+ rem_lines = 0;
+ cgit_diff_commit(commit, inspect_files, ctx.qry.vpath);
+ }
+
+ if (ctx.repo->enable_log_filecount)
+ htmlf("</td><td>%d", files);
+ if (ctx.repo->enable_log_linecount)
+ htmlf("</td><td><span class='deletions'>-%d</span>/"
+ "<span class='insertions'>+%d</span>", rem_lines, add_lines);
+
+ html("</td></tr>\n");
+
+ if ((revs->graph && !graph_is_commit_finished(revs->graph))
+ || ctx.qry.showmsg) { /* Print a second table row */
+ html("<tr class='nohover-highlight'>");
+
+ if (ctx.qry.showmsg) {
+ /* Concatenate commit message + notes in msgbuf */
+ if (info->msg && *(info->msg)) {
+ strbuf_addstr(&msgbuf, info->msg);
+ strbuf_addch(&msgbuf, '\n');
+ }
+ format_display_notes(&commit->object.oid,
+ &msgbuf, PAGE_ENCODING, 0);
+ strbuf_addch(&msgbuf, '\n');
+ strbuf_ltrim(&msgbuf);
+ }
+
+ if (revs->graph) {
+ int lines = 0;
+
+ /* Calculate graph padding */
+ if (ctx.qry.showmsg) {
+ /* Count #lines in commit message + notes */
+ const char *p = msgbuf.buf;
+ lines = 1;
+ while ((p = strchr(p, '\n'))) {
+ p++;
+ lines++;
+ }
+ }
+
+ /* Print graph padding */
+ html("<td class='commitgraph'>");
+ while (lines > 0 || !graph_is_commit_finished(revs->graph)) {
+ if (graphbuf.len)
+ html("\n");
+ strbuf_setlen(&graphbuf, 0);
+ graph_next_line(revs->graph, &graphbuf);
+ html(graphbuf.buf);
+ lines--;
+ }
+ html("</td>\n");
+ }
+ else
+ html("<td/>"); /* Empty 'Age' column */
+
+ /* Print msgbuf into remainder of table row */
+ htmlf("<td colspan='%d'%s>\n", columns - (revs->graph ? 1 : 0),
+ ctx.qry.showmsg ? " class='logmsg'" : "");
+ html_txt(msgbuf.buf);
+ html("</td></tr>\n");
+ }
+
+ strbuf_release(&msgbuf);
+ strbuf_release(&graphbuf);
+ cgit_free_commitinfo(info);
+}
+
+static const char *disambiguate_ref(const char *ref, int *must_free_result)
+{
+ struct object_id oid;
+ struct strbuf longref = STRBUF_INIT;
+
+ strbuf_addf(&longref, "refs/heads/%s", ref);
+ if (repo_get_oid(the_repository, longref.buf, &oid) == 0) {
+ *must_free_result = 1;
+ return strbuf_detach(&longref, NULL);
+ }
+
+ *must_free_result = 0;
+ strbuf_release(&longref);
+ return ref;
+}
+
+static char *next_token(char **src)
+{
+ char *result;
+
+ if (!src || !*src)
+ return NULL;
+ while (isspace(**src))
+ (*src)++;
+ if (!**src)
+ return NULL;
+ result = *src;
+ while (**src) {
+ if (isspace(**src)) {
+ **src = '\0';
+ (*src)++;
+ break;
+ }
+ (*src)++;
+ }
+ return result;
+}
+
+void cgit_print_log(const char *tip, int ofs, int cnt, char *grep, char *pattern,
+ const char *path, int pager, int commit_graph, int commit_sort)
+{
+ struct rev_info rev;
+ struct commit *commit;
+ struct strvec rev_argv = STRVEC_INIT;
+ int i, columns = commit_graph ? 4 : 3;
+ int must_free_tip = 0;
+
+ /* rev_argv.argv[0] will be ignored by setup_revisions */
+ strvec_push(&rev_argv, "log_rev_setup");
+
+ if (!tip)
+ tip = ctx.qry.head;
+ tip = disambiguate_ref(tip, &must_free_tip);
+ strvec_push(&rev_argv, tip);
+
+ if (grep && pattern && *pattern) {
+ pattern = xstrdup(pattern);
+ if (!strcmp(grep, "grep") || !strcmp(grep, "author") ||
+ !strcmp(grep, "committer")) {
+ strvec_pushf(&rev_argv, "--%s=%s", grep, pattern);
+ } else if (!strcmp(grep, "range")) {
+ char *arg;
+ /* Split the pattern at whitespace and add each token
+ * as a revision expression. Do not accept other
+ * rev-list options. Also, replace the previously
+ * pushed tip (it's no longer relevant).
+ */
+ strvec_pop(&rev_argv);
+ while ((arg = next_token(&pattern))) {
+ if (*arg == '-') {
+ fprintf(stderr, "Bad range expr: %s\n",
+ arg);
+ break;
+ }
+ strvec_push(&rev_argv, arg);
+ }
+ }
+ }
+
+ if (!path || !ctx.repo->enable_follow_links) {
+ /*
+ * If we don't have a path, "follow" is a no-op so make sure
+ * the variable is set to false to avoid needing to check
+ * both this and whether we have a path everywhere.
+ */
+ ctx.qry.follow = 0;
+ }
+
+ if (commit_graph && !ctx.qry.follow) {
+ strvec_push(&rev_argv, "--graph");
+ strvec_push(&rev_argv, "--color");
+ graph_set_column_colors(column_colors_html,
+ COLUMN_COLORS_HTML_MAX);
+ }
+
+ if (commit_sort == 1)
+ strvec_push(&rev_argv, "--date-order");
+ else if (commit_sort == 2)
+ strvec_push(&rev_argv, "--topo-order");
+
+ if (path && ctx.qry.follow)
+ strvec_push(&rev_argv, "--follow");
+ strvec_push(&rev_argv, "--");
+ if (path)
+ strvec_push(&rev_argv, path);
+
+ repo_init_revisions(the_repository, &rev, NULL);
+ rev.abbrev = DEFAULT_ABBREV;
+ rev.commit_format = CMIT_FMT_DEFAULT;
+ rev.verbose_header = 1;
+ rev.show_root_diff = 0;
+ rev.ignore_missing = 1;
+ rev.simplify_history = 1;
+ setup_revisions(rev_argv.nr, rev_argv.v, &rev, NULL);
+ load_ref_decorations(NULL, DECORATE_FULL_REFS);
+ rev.show_decorations = 1;
+ rev.grep_filter.ignore_case = 1;
+
+ rev.diffopt.detect_rename = 1;
+ rev.diffopt.rename_limit = ctx.cfg.renamelimit;
+ if (ctx.qry.ignorews)
+ DIFF_XDL_SET(&rev.diffopt, IGNORE_WHITESPACE);
+
+ compile_grep_patterns(&rev.grep_filter);
+ prepare_revision_walk(&rev);
+
+ if (pager) {
+ cgit_print_layout_start();
+ html("<table class='list nowrap'>");
+ }
+
+ html("<tr class='nohover'>");
+ if (commit_graph)
+ html("<th></th>");
+ else
+ html("<th class='left'>Age</th>");
+ html("<th class='left'>Commit message");
+ if (pager) {
+ html(" (");
+ cgit_log_link(ctx.qry.showmsg ? "Collapse" : "Expand", NULL,
+ NULL, ctx.qry.head, ctx.qry.oid,
+ ctx.qry.vpath, ctx.qry.ofs, ctx.qry.grep,
+ ctx.qry.search, ctx.qry.showmsg ? 0 : 1,
+ ctx.qry.follow);
+ html(")");
+ }
+ html("</th><th class='left'>Author</th>");
+ if (rev.graph)
+ html("<th class='left'>Age</th>");
+ if (ctx.repo->enable_log_filecount) {
+ html("<th class='left'>Files</th>");
+ columns++;
+ }
+ if (ctx.repo->enable_log_linecount) {
+ html("<th class='left'>Lines</th>");
+ columns++;
+ }
+ html("</tr>\n");
+
+ if (ofs<0)
+ ofs = 0;
+
+ for (i = 0; i < ofs && (commit = get_revision(&rev)) != NULL; /* nop */) {
+ if (show_commit(commit, &rev))
+ i++;
+ release_commit_memory(the_repository->parsed_objects, commit);
+ commit->parents = NULL;
+ }
+
+ for (i = 0; i < cnt && (commit = get_revision(&rev)) != NULL; /* nop */) {
+ /*
+ * In "follow" mode, we must count the files and lines the
+ * first time we invoke diff on a given commit, and we need
+ * to do that to see if the commit touches the path we care
+ * about, so we do it in show_commit. Hence we must clear
+ * lines_counted here.
+ *
+ * This has the side effect of avoiding running diff twice
+ * when we are both following renames and showing file
+ * and/or line counts.
+ */
+ lines_counted = 0;
+ if (show_commit(commit, &rev)) {
+ i++;
+ print_commit(commit, &rev);
+ }
+ release_commit_memory(the_repository->parsed_objects, commit);
+ commit->parents = NULL;
+ }
+ if (pager) {
+ html("</table><ul class='pager'>");
+ if (ofs > 0) {
+ html("<li>");
+ cgit_log_link("[prev]", NULL, NULL, ctx.qry.head,
+ ctx.qry.oid, ctx.qry.vpath,
+ ofs - cnt, ctx.qry.grep,
+ ctx.qry.search, ctx.qry.showmsg,
+ ctx.qry.follow);
+ html("</li>");
+ }
+ if ((commit = get_revision(&rev)) != NULL) {
+ html("<li>");
+ cgit_log_link("[next]", NULL, NULL, ctx.qry.head,
+ ctx.qry.oid, ctx.qry.vpath,
+ ofs + cnt, ctx.qry.grep,
+ ctx.qry.search, ctx.qry.showmsg,
+ ctx.qry.follow);
+ html("</li>");
+ }
+ html("</ul>");
+ cgit_print_layout_end();
+ } else if ((commit = get_revision(&rev)) != NULL) {
+ htmlf("<tr class='nohover'><td colspan='%d'>", columns);
+ cgit_log_link("[...]", NULL, NULL, ctx.qry.head, NULL,
+ ctx.qry.vpath, 0, NULL, NULL, ctx.qry.showmsg,
+ ctx.qry.follow);
+ html("</td></tr>\n");
+ }
+
+ /* If we allocated tip then it is safe to cast away const. */
+ if (must_free_tip)
+ free((char*) tip);
+}
diff --git a/cgit/ui-log.h b/cgit/ui-log.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-log.h
@@ -0,0 +1,13 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_LOG_H
+#define UI_LOG_H
+
+extern void cgit_print_log(const char *tip, int ofs, int cnt, char *grep,
+ char *pattern, const char *path, int pager,
+ int commit_graph, int commit_sort);
+extern void show_commit_decorations(struct commit *commit);
+
+#endif /* UI_LOG_H */
diff --git a/cgit/ui-patch.c b/cgit/ui-patch.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-patch.c
@@ -0,0 +1,104 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-patch.c: generate patch view
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-patch.h"
+#include "html.h"
+#include "ui-shared.h"
+
+/* two commit hashes with two dots in between and termination */
+#define REV_RANGE_LEN 2 * GIT_MAX_HEXSZ + 3
+
+void cgit_print_patch(const char *new_rev, const char *old_rev,
+ const char *prefix)
+{
+ struct rev_info rev;
+ struct commit *commit;
+ struct object_id new_rev_oid, old_rev_oid;
+ char rev_range[REV_RANGE_LEN];
+ const char *rev_argv[] = { NULL, "--reverse", "--format=email", rev_range, "--", prefix, NULL };
+ int rev_argc = ARRAY_SIZE(rev_argv) - 1;
+ char *patchname;
+
+ if (!prefix)
+ rev_argc--;
+
+ if (!new_rev)
+ new_rev = ctx.qry.head;
+
+ if (repo_get_oid(the_repository, new_rev, &new_rev_oid)) {
+ cgit_print_error_page(404, "Not found",
+ "Bad object id: %s", new_rev);
+ return;
+ }
+ commit = lookup_commit_reference(the_repository, &new_rev_oid);
+ if (!commit) {
+ cgit_print_error_page(404, "Not found",
+ "Bad commit reference: %s", new_rev);
+ return;
+ }
+
+ if (old_rev) {
+ if (repo_get_oid(the_repository, old_rev, &old_rev_oid)) {
+ cgit_print_error_page(404, "Not found",
+ "Bad object id: %s", old_rev);
+ return;
+ }
+ if (!lookup_commit_reference(the_repository, &old_rev_oid)) {
+ cgit_print_error_page(404, "Not found",
+ "Bad commit reference: %s", old_rev);
+ return;
+ }
+ } else if (commit->parents && commit->parents->item) {
+ oidcpy(&old_rev_oid, &commit->parents->item->object.oid);
+ } else {
+ oidclr(&old_rev_oid, the_repository->hash_algo);
+ }
+
+ if (is_null_oid(&old_rev_oid)) {
+ memcpy(rev_range, oid_to_hex(&new_rev_oid), the_hash_algo->hexsz + 1);
+ } else {
+ xsnprintf(rev_range, REV_RANGE_LEN, "%s..%s", oid_to_hex(&old_rev_oid),
+ oid_to_hex(&new_rev_oid));
+ }
+
+ patchname = fmt("%s.patch", rev_range);
+ ctx.page.mimetype = "text/plain";
+ ctx.page.filename = patchname;
+ cgit_print_http_headers();
+
+ if (ctx.cfg.noplainemail) {
+ rev_argv[2] = "--format=format:From %H Mon Sep 17 00:00:00 "
+ "2001%nFrom: %an%nDate: %aD%n%w(78,0,1)Subject: "
+ "%s%n%n%w(0)%b";
+ }
+
+ repo_init_revisions(the_repository, &rev, NULL);
+ rev.abbrev = DEFAULT_ABBREV;
+ rev.verbose_header = 1;
+ rev.diff = 1;
+ rev.show_root_diff = 1;
+ rev.max_parents = 1;
+ rev.diffopt.output_format |= DIFF_FORMAT_DIFFSTAT |
+ DIFF_FORMAT_PATCH | DIFF_FORMAT_SUMMARY;
+ if (prefix)
+ rev.diffopt.stat_sep = fmt("(limited to '%s')\n\n", prefix);
+ setup_revisions(rev_argc, rev_argv, &rev, NULL);
+ prepare_revision_walk(&rev);
+
+ while ((commit = get_revision(&rev)) != NULL) {
+ log_tree_commit(&rev, commit);
+ printf("-- \ncgit %s\n\n", cgit_version);
+ }
+}
diff --git a/cgit/ui-patch.h b/cgit/ui-patch.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-patch.h
@@ -0,0 +1,11 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_PATCH_H
+#define UI_PATCH_H
+
+extern void cgit_print_patch(const char *new_rev, const char *old_rev,
+ const char *prefix);
+
+#endif /* UI_PATCH_H */
diff --git a/cgit/ui-plain.c b/cgit/ui-plain.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-plain.c
@@ -0,0 +1,213 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-plain.c: functions for output of plain blobs by path
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-plain.h"
+#include "html.h"
+#include "ui-shared.h"
+
+struct walk_tree_context {
+ int match_baselen;
+ int match;
+};
+
+static int print_object(const struct object_id *oid, const char *path)
+{
+ enum object_type type;
+ char *buf, *mimetype;
+ unsigned long size;
+
+ type = odb_read_object_info(the_repository->objects, oid, &size);
+ if (type == OBJ_BAD) {
+ cgit_print_error_page(404, "Not found", "Not found");
+ return 0;
+ }
+
+ buf = odb_read_object(the_repository->objects, oid, &type, &size);
+ if (!buf) {
+ cgit_print_error_page(404, "Not found", "Not found");
+ return 0;
+ }
+
+ mimetype = get_mimetype_for_filename(path);
+ ctx.page.mimetype = mimetype;
+
+ if (!ctx.repo->enable_html_serving) {
+ html("X-Content-Type-Options: nosniff\n");
+ html("Content-Security-Policy: default-src 'none'\n");
+ if (mimetype) {
+ /* Built-in white list allows PDF and everything that isn't text/ and application/ */
+ if ((!strncmp(mimetype, "text/", 5) || !strncmp(mimetype, "application/", 12)) && strcmp(mimetype, "application/pdf"))
+ ctx.page.mimetype = NULL;
+ }
+ }
+
+ if (!ctx.page.mimetype) {
+ if (buffer_is_binary(buf, size)) {
+ ctx.page.mimetype = "application/octet-stream";
+ ctx.page.charset = NULL;
+ } else {
+ ctx.page.mimetype = "text/plain";
+ }
+ }
+ ctx.page.filename = path;
+ ctx.page.size = size;
+ ctx.page.etag = oid_to_hex(oid);
+ cgit_print_http_headers();
+ html_raw(buf, size);
+ free(mimetype);
+ free(buf);
+ return 1;
+}
+
+static char *buildpath(const char *base, int baselen, const char *path)
+{
+ if (path[0])
+ return fmtalloc("%.*s%s/", baselen, base, path);
+ else
+ return fmtalloc("%.*s/", baselen, base);
+}
+
+static void print_dir(const struct object_id *oid, const char *base,
+ int baselen, const char *path)
+{
+ char *fullpath, *slash;
+ size_t len;
+
+ fullpath = buildpath(base, baselen, path);
+ slash = (fullpath[0] == '/' ? "" : "/");
+ ctx.page.etag = oid_to_hex(oid);
+ cgit_print_http_headers();
+ htmlf("<html><head><title>%s", slash);
+ html_txt(fullpath);
+ htmlf("</title></head>\n<body>\n<h2>%s", slash);
+ html_txt(fullpath);
+ html("</h2>\n<ul>\n");
+ len = strlen(fullpath);
+ if (len > 1) {
+ fullpath[len - 1] = 0;
+ slash = strrchr(fullpath, '/');
+ if (slash)
+ *(slash + 1) = 0;
+ else {
+ free(fullpath);
+ fullpath = NULL;
+ }
+ html("<li>");
+ cgit_plain_link("../", NULL, NULL, ctx.qry.head, ctx.qry.oid,
+ fullpath);
+ html("</li>\n");
+ }
+ free(fullpath);
+}
+
+static void print_dir_entry(const struct object_id *oid, const char *base,
+ int baselen, const char *path, unsigned mode)
+{
+ char *fullpath;
+
+ fullpath = buildpath(base, baselen, path);
+ if (!S_ISDIR(mode) && !S_ISGITLINK(mode))
+ fullpath[strlen(fullpath) - 1] = 0;
+ html(" <li>");
+ if (S_ISGITLINK(mode)) {
+ cgit_submodule_link(NULL, fullpath, oid_to_hex(oid));
+ } else
+ cgit_plain_link(path, NULL, NULL, ctx.qry.head, ctx.qry.oid,
+ fullpath);
+ html("</li>\n");
+ free(fullpath);
+}
+
+static void print_dir_tail(void)
+{
+ html(" </ul>\n</body></html>\n");
+}
+
+static int walk_tree(const struct object_id *oid, struct strbuf *base,
+ const char *pathname, unsigned mode, void *cbdata)
+{
+ struct walk_tree_context *walk_tree_ctx = cbdata;
+
+ if (base->len == walk_tree_ctx->match_baselen) {
+ if (S_ISREG(mode) || S_ISLNK(mode)) {
+ if (print_object(oid, pathname))
+ walk_tree_ctx->match = 1;
+ } else if (S_ISDIR(mode)) {
+ print_dir(oid, base->buf, base->len, pathname);
+ walk_tree_ctx->match = 2;
+ return READ_TREE_RECURSIVE;
+ }
+ } else if (base->len < INT_MAX && (int)base->len > walk_tree_ctx->match_baselen) {
+ print_dir_entry(oid, base->buf, base->len, pathname, mode);
+ walk_tree_ctx->match = 2;
+ } else if (S_ISDIR(mode)) {
+ return READ_TREE_RECURSIVE;
+ }
+
+ return 0;
+}
+
+static int basedir_len(const char *path)
+{
+ const char *p = strrchr(path, '/');
+ if (p)
+ return p - path + 1;
+ return 0;
+}
+
+void cgit_print_plain(void)
+{
+ const char *rev = ctx.qry.oid;
+ struct object_id oid;
+ struct commit *commit;
+ struct pathspec_item path_items = {
+ .match = ctx.qry.path,
+ .len = ctx.qry.path ? strlen(ctx.qry.path) : 0
+ };
+ struct pathspec paths = {
+ .nr = 1,
+ .items = &path_items
+ };
+ struct walk_tree_context walk_tree_ctx = {
+ .match = 0
+ };
+
+ if (!rev)
+ rev = ctx.qry.head;
+
+ if (repo_get_oid(the_repository, rev, &oid)) {
+ cgit_print_error_page(404, "Not found", "Not found");
+ return;
+ }
+ commit = lookup_commit_reference(the_repository, &oid);
+ if (!commit || repo_parse_commit(the_repository, commit)) {
+ cgit_print_error_page(404, "Not found", "Not found");
+ return;
+ }
+ if (!path_items.match) {
+ path_items.match = "";
+ walk_tree_ctx.match_baselen = -1;
+ print_dir(get_commit_tree_oid(commit), "", 0, "");
+ walk_tree_ctx.match = 2;
+ }
+ else
+ walk_tree_ctx.match_baselen = basedir_len(path_items.match);
+ read_tree(the_repository, repo_get_commit_tree(the_repository, commit),
+ &paths, walk_tree, &walk_tree_ctx);
+ if (!walk_tree_ctx.match)
+ cgit_print_error_page(404, "Not found", "Not found");
+ else if (walk_tree_ctx.match == 2)
+ print_dir_tail();
+}
diff --git a/cgit/ui-plain.h b/cgit/ui-plain.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-plain.h
@@ -0,0 +1,10 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_PLAIN_H
+#define UI_PLAIN_H
+
+extern void cgit_print_plain(void);
+
+#endif /* UI_PLAIN_H */
diff --git a/cgit/ui-refs.c b/cgit/ui-refs.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-refs.c
@@ -0,0 +1,221 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-refs.c: browse symbolic refs
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-refs.h"
+#include "html.h"
+#include "ui-shared.h"
+
+static inline int cmp_age(int age1, int age2)
+{
+ /* age1 and age2 are assumed to be non-negative */
+ return age2 - age1;
+}
+
+static int cmp_ref_name(const void *a, const void *b)
+{
+ struct refinfo *r1 = *(struct refinfo **)a;
+ struct refinfo *r2 = *(struct refinfo **)b;
+
+ return strcmp(r1->refname, r2->refname);
+}
+
+static int cmp_branch_age(const void *a, const void *b)
+{
+ struct refinfo *r1 = *(struct refinfo **)a;
+ struct refinfo *r2 = *(struct refinfo **)b;
+
+ return cmp_age(r1->commit->committer_date, r2->commit->committer_date);
+}
+
+static int get_ref_age(struct refinfo *ref)
+{
+ if (!ref->object)
+ return 0;
+ switch (ref->object->type) {
+ case OBJ_TAG:
+ return ref->tag ? ref->tag->tagger_date : 0;
+ case OBJ_COMMIT:
+ return ref->commit ? ref->commit->committer_date : 0;
+ }
+ return 0;
+}
+
+static int cmp_tag_age(const void *a, const void *b)
+{
+ struct refinfo *r1 = *(struct refinfo **)a;
+ struct refinfo *r2 = *(struct refinfo **)b;
+
+ return cmp_age(get_ref_age(r1), get_ref_age(r2));
+}
+
+static int print_branch(struct refinfo *ref)
+{
+ struct commitinfo *info = ref->commit;
+ char *name = (char *)ref->refname;
+
+ if (!info)
+ return 1;
+ html("<tr><td>");
+ cgit_log_link(name, NULL, NULL, name, NULL, NULL, 0, NULL, NULL,
+ ctx.qry.showmsg, 0);
+ html("</td><td>");
+
+ if (ref->object->type == OBJ_COMMIT) {
+ cgit_commit_link(info->subject, NULL, NULL, name, NULL, NULL);
+ html("</td><td>");
+ html_txt(info->author);
+ html("</td><td colspan='2'>");
+ cgit_print_age(info->committer_date, info->committer_tz, -1);
+ } else {
+ html("</td><td></td><td>");
+ cgit_object_link(ref->object);
+ }
+ html("</td></tr>\n");
+ return 0;
+}
+
+static void print_tag_header(void)
+{
+ html("<tr class='nohover'><th class='left'>Tag</th>"
+ "<th class='left'>Download</th>"
+ "<th class='left'>Author</th>"
+ "<th class='left' colspan='2'>Age</th></tr>\n");
+}
+
+static int print_tag(struct refinfo *ref)
+{
+ struct tag *tag = NULL;
+ struct taginfo *info = NULL;
+ char *name = (char *)ref->refname;
+ struct object *obj = ref->object;
+
+ if (obj->type == OBJ_TAG) {
+ tag = (struct tag *)obj;
+ obj = tag->tagged;
+ info = ref->tag;
+ if (!info)
+ return 1;
+ }
+
+ html("<tr><td>");
+ cgit_tag_link(name, NULL, NULL, name);
+ html("</td><td>");
+ if (ctx.repo->snapshots && (obj->type == OBJ_COMMIT))
+ cgit_print_snapshot_links(ctx.repo, name, "&nbsp;&nbsp;");
+ else
+ cgit_object_link(obj);
+ html("</td><td>");
+ if (info) {
+ if (info->tagger)
+ html_txt(info->tagger);
+ } else if (ref->object->type == OBJ_COMMIT) {
+ html_txt(ref->commit->author);
+ }
+ html("</td><td colspan='2'>");
+ if (info) {
+ if (info->tagger_date > 0)
+ cgit_print_age(info->tagger_date, info->tagger_tz, -1);
+ } else if (ref->object->type == OBJ_COMMIT) {
+ cgit_print_age(ref->commit->commit->date, 0, -1);
+ }
+ html("</td></tr>\n");
+
+ return 0;
+}
+
+static void print_refs_link(const char *path)
+{
+ html("<tr class='nohover'><td colspan='5'>");
+ cgit_refs_link("[...]", NULL, NULL, ctx.qry.head, NULL, path);
+ html("</td></tr>");
+}
+
+void cgit_print_branches(int maxcount)
+{
+ struct reflist list;
+ int i;
+
+ html("<tr class='nohover'><th class='left'>Branch</th>"
+ "<th class='left'>Commit message</th>"
+ "<th class='left'>Author</th>"
+ "<th class='left' colspan='2'>Age</th></tr>\n");
+
+ list.refs = NULL;
+ list.alloc = list.count = 0;
+ refs_for_each_branch_ref(get_main_ref_store(the_repository),
+ cgit_refs_cb, &list);
+ if (ctx.repo->enable_remote_branches)
+ refs_for_each_remote_ref(get_main_ref_store(the_repository),
+ cgit_refs_cb, &list);
+
+ if (maxcount == 0 || maxcount > list.count)
+ maxcount = list.count;
+
+ qsort(list.refs, list.count, sizeof(*list.refs), cmp_branch_age);
+ if (ctx.repo->branch_sort == 0)
+ qsort(list.refs, maxcount, sizeof(*list.refs), cmp_ref_name);
+
+ for (i = 0; i < maxcount; i++)
+ print_branch(list.refs[i]);
+
+ if (maxcount < list.count)
+ print_refs_link("heads");
+
+ cgit_free_reflist_inner(&list);
+}
+
+void cgit_print_tags(int maxcount)
+{
+ struct reflist list;
+ int i;
+
+ list.refs = NULL;
+ list.alloc = list.count = 0;
+ refs_for_each_tag_ref(get_main_ref_store(the_repository),
+ cgit_refs_cb, &list);
+ if (list.count == 0)
+ return;
+ qsort(list.refs, list.count, sizeof(*list.refs), cmp_tag_age);
+ if (!maxcount)
+ maxcount = list.count;
+ else if (maxcount > list.count)
+ maxcount = list.count;
+ print_tag_header();
+ for (i = 0; i < maxcount; i++)
+ print_tag(list.refs[i]);
+
+ if (maxcount < list.count)
+ print_refs_link("tags");
+
+ cgit_free_reflist_inner(&list);
+}
+
+void cgit_print_refs(void)
+{
+ cgit_print_layout_start();
+ html("<table class='list nowrap'>");
+
+ if (ctx.qry.path && starts_with(ctx.qry.path, "heads"))
+ cgit_print_branches(0);
+ else if (ctx.qry.path && starts_with(ctx.qry.path, "tags"))
+ cgit_print_tags(0);
+ else {
+ cgit_print_branches(0);
+ html("<tr class='nohover'><td colspan='5'>&nbsp;</td></tr>");
+ cgit_print_tags(0);
+ }
+ html("</table>");
+ cgit_print_layout_end();
+}
diff --git a/cgit/ui-refs.h b/cgit/ui-refs.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-refs.h
@@ -0,0 +1,12 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_REFS_H
+#define UI_REFS_H
+
+extern void cgit_print_branches(int maxcount);
+extern void cgit_print_tags(int maxcount);
+extern void cgit_print_refs(void);
+
+#endif /* UI_REFS_H */
diff --git a/cgit/ui-repolist.c b/cgit/ui-repolist.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-repolist.c
@@ -0,0 +1,491 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-repolist.c: functions for generating the repolist page
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#include "cgit.h"
+#include "ui-repolist.h"
+#include "html.h"
+#include "json.h"
+#include "ui-shared.h"
+#include "version.h"
+
+static time_t read_agefile(const char *path)
+{
+ time_t result;
+ size_t size;
+ char *buf = NULL;
+ struct strbuf date_buf = STRBUF_INIT;
+
+ if (read_first_line(path, &buf, &size)) {
+ free(buf);
+ return 0;
+ }
+
+ if (parse_date(buf, &date_buf) == 0)
+ result = strtoul(date_buf.buf, NULL, 10);
+ else
+ result = 0;
+ free(buf);
+ strbuf_release(&date_buf);
+ return result;
+}
+
+static int get_repo_modtime(const struct cgit_repo *repo, time_t *mtime)
+{
+ struct strbuf path = STRBUF_INIT;
+ struct stat s;
+ struct cgit_repo *r = (struct cgit_repo *)repo;
+
+ if (repo->mtime != -1) {
+ *mtime = repo->mtime;
+ return 1;
+ }
+ strbuf_addf(&path, "%s/%s", repo->path, ctx.cfg.agefile);
+ if (stat(path.buf, &s) == 0) {
+ *mtime = read_agefile(path.buf);
+ if (*mtime) {
+ r->mtime = *mtime;
+ goto end;
+ }
+ }
+
+ strbuf_reset(&path);
+ strbuf_addf(&path, "%s/refs/heads/%s", repo->path,
+ repo->defbranch ? repo->defbranch : "master");
+ if (stat(path.buf, &s) == 0) {
+ *mtime = s.st_mtime;
+ r->mtime = *mtime;
+ goto end;
+ }
+
+ strbuf_reset(&path);
+ strbuf_addf(&path, "%s/%s", repo->path, "packed-refs");
+ if (stat(path.buf, &s) == 0) {
+ *mtime = s.st_mtime;
+ r->mtime = *mtime;
+ goto end;
+ }
+
+ *mtime = 0;
+ r->mtime = *mtime;
+end:
+ strbuf_release(&path);
+ return (r->mtime != 0);
+}
+
+static int is_match(struct cgit_repo *repo)
+{
+ if (!ctx.qry.search)
+ return 1;
+ if (repo->url && strcasestr(repo->url, ctx.qry.search))
+ return 1;
+ if (repo->name && strcasestr(repo->name, ctx.qry.search))
+ return 1;
+ if (repo->desc && strcasestr(repo->desc, ctx.qry.search))
+ return 1;
+ if (repo->owner && strcasestr(repo->owner, ctx.qry.search))
+ return 1;
+ return 0;
+}
+
+static int is_in_url(struct cgit_repo *repo)
+{
+ if (!ctx.qry.url)
+ return 1;
+ if (repo->url && starts_with(repo->url, ctx.qry.url))
+ return 1;
+ return 0;
+}
+
+static int is_visible(struct cgit_repo *repo)
+{
+ if (repo->hide || repo->ignore)
+ return 0;
+ if (!(is_match(repo) && is_in_url(repo)))
+ return 0;
+ return 1;
+}
+
+static int any_repos_visible(void)
+{
+ int i;
+
+ for (i = 0; i < cgit_repolist.count; i++) {
+ if (is_visible(&cgit_repolist.repos[i]))
+ return 1;
+ }
+ return 0;
+}
+
+static int cmp(const char *s1, const char *s2)
+{
+ if (s1 && s2) {
+ if (ctx.cfg.case_sensitive_sort)
+ return strcmp(s1, s2);
+ else
+ return strcasecmp(s1, s2);
+ }
+ if (s1 && !s2)
+ return -1;
+ if (s2 && !s1)
+ return 1;
+ return 0;
+}
+
+static int sort_name(const void *a, const void *b)
+{
+ const struct cgit_repo *r1 = a;
+ const struct cgit_repo *r2 = b;
+
+ return cmp(r1->name, r2->name);
+}
+
+static int sort_desc(const void *a, const void *b)
+{
+ const struct cgit_repo *r1 = a;
+ const struct cgit_repo *r2 = b;
+
+ return cmp(r1->desc, r2->desc);
+}
+
+static int sort_owner(const void *a, const void *b)
+{
+ const struct cgit_repo *r1 = a;
+ const struct cgit_repo *r2 = b;
+
+ return cmp(r1->owner, r2->owner);
+}
+
+static int sort_idle(const void *a, const void *b)
+{
+ const struct cgit_repo *r1 = a;
+ const struct cgit_repo *r2 = b;
+ time_t t1, t2;
+
+ t1 = t2 = 0;
+ get_repo_modtime(r1, &t1);
+ get_repo_modtime(r2, &t2);
+ return t2 - t1;
+}
+
+static int sort_section(const void *a, const void *b)
+{
+ const struct cgit_repo *r1 = a;
+ const struct cgit_repo *r2 = b;
+ int result;
+
+ result = cmp(r1->section, r2->section);
+ if (!result) {
+ if (!strcmp(ctx.cfg.repository_sort, "age"))
+ result = sort_idle(r1, r2);
+ if (!result)
+ result = cmp(r1->name, r2->name);
+ }
+ return result;
+}
+
+struct sortcolumn {
+ const char *name;
+ int (*fn)(const void *a, const void *b);
+};
+
+static const struct sortcolumn sortcolumn[] = {
+ {"section", sort_section},
+ {"name", sort_name},
+ {"desc", sort_desc},
+ {"owner", sort_owner},
+ {"idle", sort_idle},
+ {NULL, NULL}
+};
+
+static int sort_repolist(char *field)
+{
+ const struct sortcolumn *column;
+
+ for (column = &sortcolumn[0]; column->name; column++) {
+ if (strcmp(field, column->name))
+ continue;
+ qsort(cgit_repolist.repos, cgit_repolist.count,
+ sizeof(struct cgit_repo), column->fn);
+ return 1;
+ }
+ return 0;
+}
+
+
+static void json_include(const char *path)
+{
+ struct strbuf content = STRBUF_INIT;
+
+ if (!path || strbuf_read_file(&content, path, 0) < 0)
+ json("null");
+ else
+ json_value(content.buf);
+ strbuf_release(&content);
+}
+
+static void json_string_list(const struct string_list *list)
+{
+ struct string_list_item *item;
+ int first = 1;
+
+ json("[");
+ for_each_string_list_item(item, list) {
+ if (!first)
+ json(",");
+ json_value(item->string);
+ first = 0;
+ }
+ json("]");
+}
+
+static void url_arg(struct strbuf *url, const char *value)
+{
+ const unsigned char *p = (const unsigned char *)(value ? value : "");
+
+ for (; *p; p++) {
+ if (isalnum(*p) || strchr("!$()*,./:;@-[]_~", *p))
+ strbuf_addch(url, *p);
+ else if (*p == ' ')
+ strbuf_addch(url, '+');
+ else
+ strbuf_addf(url, "%%%02x", *p);
+ }
+}
+
+static void url_path(struct strbuf *url, const char *value)
+{
+ const unsigned char *p = (const unsigned char *)(value ? value : "");
+
+ for (; *p; p++) {
+ if (isalnum(*p) || strchr("!$()*,./:;@-[]_~+&", *p))
+ strbuf_addch(url, *p);
+ else
+ strbuf_addf(url, "%%%02x", *p);
+ }
+}
+
+static char *sort_url(const char *sort)
+{
+ char *currenturl = cgit_currenturl();
+ struct strbuf url = STRBUF_INIT;
+
+ strbuf_addf(&url, "%s?s=%s", currenturl, sort);
+ free(currenturl);
+ if (ctx.qry.search) {
+ strbuf_addstr(&url, "&q=");
+ url_arg(&url, ctx.qry.search);
+ }
+ return strbuf_detach(&url, NULL);
+}
+
+static char *pager_url(const char *search, const char *sort, int ofs)
+{
+ char *currenturl = cgit_currenturl();
+ struct strbuf url = STRBUF_INIT;
+ const char *delimiter = "?";
+
+ strbuf_addstr(&url, currenturl);
+ free(currenturl);
+ if (search) {
+ strbuf_addf(&url, "?q=%s", search);
+ delimiter = "&";
+ }
+ if (sort) {
+ strbuf_addf(&url, "%ss=%s", delimiter, sort);
+ delimiter = "&";
+ }
+ if (ofs)
+ strbuf_addf(&url, "%sofs=%d", delimiter, ofs);
+ return strbuf_detach(&url, NULL);
+}
+
+static char *repo_url(const struct cgit_repo *repo, const char *page,
+ const char *query)
+{
+ struct strbuf url = STRBUF_INIT;
+ const char *delimiter = "?";
+
+ if (ctx.cfg.virtual_root) {
+ url_path(&url, ctx.cfg.virtual_root);
+ url_path(&url, repo->url);
+ if (repo->url[strlen(repo->url) - 1] != '/')
+ strbuf_addch(&url, '/');
+ if (page) {
+ url_path(&url, page);
+ strbuf_addch(&url, '/');
+ }
+ } else {
+ url_path(&url, ctx.cfg.script_name);
+ strbuf_addstr(&url, "?url=");
+ url_arg(&url, repo->url);
+ if (repo->url[strlen(repo->url) - 1] != '/')
+ strbuf_addch(&url, '/');
+ if (page) {
+ url_arg(&url, page);
+ strbuf_addch(&url, '/');
+ }
+ delimiter = "&";
+ }
+ if (query)
+ strbuf_addf(&url, "%s%s", delimiter, query);
+ return strbuf_detach(&url, NULL);
+}
+
+static void json_description(const char *description)
+{
+ size_t len = description ? strlen(description) : 0;
+ size_t shown = len;
+
+ if (shown > ctx.cfg.max_repodesc_len)
+ shown = ctx.cfg.max_repodesc_len;
+ char *text = xmemdupz(description ? description : "", shown);
+ json("{");
+ json_key("text");
+ json_value(text);
+ free(text);
+ json(",");
+ json_key("truncated");
+ json_bool(len > shown);
+ json("}");
+}
+
+static void json_age(struct cgit_repo *repo)
+{
+ time_t t, now, seconds;
+ const char *unit;
+ double amount;
+
+ if (!get_repo_modtime(repo, &t)) {
+ json("null");
+ return;
+ }
+ time(&now);
+ seconds = now > t ? now - t : 0;
+ if (seconds < TM_HOUR * 2) { unit = "minutes"; amount = seconds * 1.0 / TM_MIN; }
+ else if (seconds < TM_DAY * 2) { unit = "hours"; amount = seconds * 1.0 / TM_HOUR; }
+ else if (seconds < TM_WEEK * 2) { unit = "days"; amount = seconds * 1.0 / TM_DAY; }
+ else if (seconds < TM_MONTH * 2) { unit = "weeks"; amount = seconds * 1.0 / TM_WEEK; }
+ else if (seconds < TM_YEAR * 2) { unit = "months"; amount = seconds * 1.0 / TM_MONTH; }
+ else { unit = "years"; amount = seconds * 1.0 / TM_YEAR; }
+ json("{");
+ json_key("timestamp"); json_int(t); json(",");
+ json_key("title"); json_value(show_date(t, 0, cgit_date_mode(DATE_ISO8601))); json(",");
+ json_key("unit"); json_value(unit); json(",");
+ json_key("amount"); jsonf("%.0f", amount);
+ json("}");
+}
+
+static void json_shell(void)
+{
+ json_key("shell"); json("{");
+ json_key("embedded"); json_bool(ctx.cfg.embedded); json(",");
+ json_key("robots"); json_value(ctx.cfg.robots); json(",");
+ json_key("css"); json_string_list(&ctx.cfg.css); json(",");
+ json_key("js"); json_string_list(&ctx.cfg.js); json(",");
+ json_key("favicon"); json_value(ctx.cfg.favicon); json(",");
+ json_key("head_include"); json_include(ctx.cfg.head_include); json(",");
+ json_key("header"); json_include(ctx.cfg.header); json(",");
+ json_key("footer_configured"); json_bool(ctx.cfg.footer != NULL); json(",");
+ json_key("footer"); json_include(ctx.cfg.footer); json(",");
+ json_key("logo"); json_value(ctx.cfg.logo); json(",");
+ json_key("logo_link"); json_value(ctx.cfg.logo_link); json(",");
+ json_key("cgit_version"); json_value(cgit_version); json(",");
+ json_key("git_version"); json_value(git_version_string); json(",");
+ json_key("generated_at"); json_value(show_date(time(NULL), 0, cgit_date_mode(DATE_ISO8601)));
+ json("}");
+}
+
+void cgit_print_repolist(void)
+{
+ int i, hits = 0, sorted = 0, first = 1;
+ char *last_section = NULL;
+
+ if (!any_repos_visible()) {
+ cgit_print_error_page(404, "Not found", "No repositories found");
+ return;
+ }
+ ctx.page.title = ctx.cfg.root_title;
+ ctx.page.mimetype = "application/vnd.gilti.repolist+json";
+ ctx.page.charset = NULL;
+ cgit_print_http_headers();
+
+ if (ctx.qry.sort)
+ sorted = sort_repolist(ctx.qry.sort);
+ else if (ctx.cfg.section_sort)
+ sort_repolist("section");
+
+ json("{");
+ json_key("page"); json_value("repolist"); json(",");
+ json_key("title"); json_value(ctx.cfg.root_title); json(",");
+ json_key("root_desc"); json_value(ctx.cfg.root_desc); json(",");
+ json_key("root_url"); json_value(cgit_rooturl()); json(",");
+ json_key("about_url"); { struct strbuf about = STRBUF_INIT; strbuf_addf(&about, "%s?p=about", cgit_rooturl()); json_value(about.buf); strbuf_release(&about); } json(",");
+ json_key("noheader"); json_bool(ctx.cfg.noheader); json(",");
+ json_key("search"); json_value(ctx.qry.search); json(",");
+ json_key("current_url"); { char *url = cgit_currenturl(); json_value(url); free(url); } json(",");
+ json_key("root_readme"); json_bool(ctx.cfg.root_readme != NULL); json(",");
+ json_key("owner_enabled"); json_bool(ctx.cfg.enable_index_owner); json(",");
+ json_key("links_enabled"); json_bool(ctx.cfg.enable_index_links); json(",");
+ json_key("section_grouping"); json_bool(!sorted); json(",");
+ json_shell(); json(",");
+ json_key("sort_urls"); json("{");
+ { const char *names[] = {"name", "desc", "owner", "idle"}; for (i = 0; i < 4; i++) { char *url = sort_url(names[i]); if (i) json(","); json_key(names[i]); json_value(url); free(url); } }
+ json("},");
+ json_key("rows"); json("[");
+ for (i = 0; i < cgit_repolist.count; i++) {
+ struct cgit_repo *repo = &cgit_repolist.repos[i];
+ char *section, *url;
+ if (!is_visible(repo)) continue;
+ hits++;
+ if (hits <= ctx.qry.ofs || hits > ctx.qry.ofs + ctx.cfg.max_repo_count) continue;
+ section = repo->section && *repo->section ? repo->section : NULL;
+ if (!sorted &&
+ ((last_section == NULL && section != NULL) ||
+ (last_section != NULL && section == NULL) ||
+ (last_section != NULL && section != NULL &&
+ strcmp(section, last_section)))) {
+ if (!first) json(",");
+ json("{"); json_key("section");
+ if (section) json_value(section); else json("null");
+ json("}");
+ first = 0;
+ last_section = section;
+ }
+ if (!first) json(",");
+ json("{");
+ json_key("name"); json_value(repo->name); json(",");
+ json_key("section"); if (section) json_value(section); else json("null"); json(",");
+ json_key("url"); url = repo_url(repo, NULL, NULL); json_value(url); free(url); json(",");
+ json_key("description"); json_description(repo->desc); json(",");
+ json_key("owner"); json_value(repo->owner); json(",");
+ json_key("owner_url"); { char *current = cgit_currenturl(); struct strbuf owner = STRBUF_INIT; strbuf_addf(&owner, "%s?q=", current); url_arg(&owner, repo->owner); json_value(owner.buf); strbuf_release(&owner); free(current); } json(",");
+ json_key("idle"); json_age(repo); json(",");
+ json_key("log_url"); url = repo_url(repo, "log", ctx.qry.showmsg ? "showmsg=1" : NULL); json_value(url); free(url); json(",");
+ json_key("tree_url"); url = repo_url(repo, "tree", NULL); json_value(url); free(url);
+ json("}"); first = 0;
+ }
+ json("],");
+ json_key("pager"); json("[");
+ if (hits > ctx.cfg.max_repo_count)
+ for (i = 0; i * ctx.cfg.max_repo_count < hits; i++) { char *url = pager_url(ctx.qry.search, ctx.qry.sort, i * ctx.cfg.max_repo_count); if (i) json(","); json("{"); json_key("url"); json_value(url); free(url); json(","); json_key("current"); json_bool(ctx.qry.ofs == i * ctx.cfg.max_repo_count); json("}"); }
+ json("]}");
+}
+
+void cgit_print_site_readme(void)
+{
+ cgit_print_layout_start();
+ if (!ctx.cfg.root_readme)
+ goto done;
+ html_include(ctx.cfg.root_readme);
+done:
+ cgit_print_layout_end();
+}
diff --git a/cgit/ui-repolist.h b/cgit/ui-repolist.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-repolist.h
@@ -0,0 +1,11 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_REPOLIST_H
+#define UI_REPOLIST_H
+
+extern void cgit_print_repolist(void);
+extern void cgit_print_site_readme(void);
+
+#endif /* UI_REPOLIST_H */
diff --git a/cgit/ui-shared.c b/cgit/ui-shared.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-shared.c
@@ -0,0 +1,1250 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-FileCopyrightText: 2026 Nikolay Govorov
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-shared.c: common web output functions
+ *
+ * Copyright (C) 2006-2017 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-shared.h"
+#include "cmd.h"
+#include "html.h"
+#include "version.h"
+
+static const char cgit_doctype[] =
+"<!DOCTYPE html>\n";
+
+static char *http_date(time_t t)
+{
+ static char day[][4] =
+ {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
+ static char month[][4] =
+ {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
+ struct tm tm;
+ gmtime_r(&t, &tm);
+ return fmt("%s, %02d %s %04d %02d:%02d:%02d GMT", day[tm.tm_wday],
+ tm.tm_mday, month[tm.tm_mon], 1900 + tm.tm_year,
+ tm.tm_hour, tm.tm_min, tm.tm_sec);
+}
+
+void cgit_print_error(const char *fmt, ...)
+{
+ va_list ap;
+ va_start(ap, fmt);
+ cgit_vprint_error(fmt, ap);
+ va_end(ap);
+}
+
+void cgit_vprint_error(const char *fmt, va_list ap)
+{
+ va_list cp;
+ html("<div class='error'>");
+ va_copy(cp, ap);
+ html_vtxtf(fmt, cp);
+ va_end(cp);
+ html("</div>\n");
+}
+
+const char *cgit_httpscheme(void)
+{
+ if (ctx.env.https && !strcmp(ctx.env.https, "on"))
+ return "https://";
+ else
+ return "http://";
+}
+
+char *cgit_hosturl(void)
+{
+ if (ctx.env.http_host)
+ return xstrdup(ctx.env.http_host);
+ if (!ctx.env.server_name)
+ return NULL;
+ if (!ctx.env.server_port || atoi(ctx.env.server_port) == 80)
+ return xstrdup(ctx.env.server_name);
+ return fmtalloc("%s:%s", ctx.env.server_name, ctx.env.server_port);
+}
+
+char *cgit_currenturl(void)
+{
+ const char *root = cgit_rooturl();
+
+ if (!ctx.qry.url)
+ return xstrdup(root);
+ if (root[0] && root[strlen(root) - 1] == '/')
+ return fmtalloc("%s%s", root, ctx.qry.url);
+ return fmtalloc("%s/%s", root, ctx.qry.url);
+}
+
+char *cgit_currentfullurl(void)
+{
+ const char *root = cgit_rooturl();
+ const char *orig_query = ctx.env.query_string ? ctx.env.query_string : "";
+ size_t len = strlen(orig_query);
+ char *query = xmalloc(len + 2), *start_url, *ret;
+
+ /* Remove all url=... parts from query string */
+ memcpy(query + 1, orig_query, len + 1);
+ query[0] = '?';
+ start_url = query;
+ while ((start_url = strstr(start_url, "url=")) != NULL) {
+ if (start_url[-1] == '?' || start_url[-1] == '&') {
+ const char *end_url = strchr(start_url, '&');
+ if (end_url)
+ memmove(start_url, end_url + 1, strlen(end_url));
+ else
+ start_url[0] = '\0';
+ } else
+ ++start_url;
+ }
+ if (!query[1])
+ query[0] = '\0';
+
+ if (!ctx.qry.url)
+ ret = fmtalloc("%s%s", root, query);
+ else if (root[0] && root[strlen(root) - 1] == '/')
+ ret = fmtalloc("%s%s%s", root, ctx.qry.url, query);
+ else
+ ret = fmtalloc("%s/%s%s", root, ctx.qry.url, query);
+ free(query);
+ return ret;
+}
+
+const char *cgit_rooturl(void)
+{
+ if (ctx.cfg.virtual_root)
+ return ctx.cfg.virtual_root;
+ else
+ return ctx.cfg.script_name;
+}
+
+char *cgit_repourl(const char *reponame)
+{
+ if (ctx.cfg.virtual_root)
+ return fmtalloc("%s%s/", ctx.cfg.virtual_root, reponame);
+ else
+ return fmtalloc("?r=%s", reponame);
+}
+
+char *cgit_fileurl(const char *reponame, const char *pagename,
+ const char *filename, const char *query)
+{
+ struct strbuf sb = STRBUF_INIT;
+ char *delim;
+
+ if (ctx.cfg.virtual_root) {
+ strbuf_addf(&sb, "%s%s/%s/%s", ctx.cfg.virtual_root, reponame,
+ pagename, (filename ? filename:""));
+ delim = "?";
+ } else {
+ strbuf_addf(&sb, "?url=%s/%s/%s", reponame, pagename,
+ (filename ? filename : ""));
+ delim = "&amp;";
+ }
+ if (query)
+ strbuf_addf(&sb, "%s%s", delim, query);
+ return strbuf_detach(&sb, NULL);
+}
+
+char *cgit_pageurl(const char *reponame, const char *pagename,
+ const char *query)
+{
+ return cgit_fileurl(reponame, pagename, NULL, query);
+}
+
+const char *cgit_repobasename(const char *reponame)
+{
+ /* I assume we don't need to store more than one repo basename */
+ static char rvbuf[1024];
+ int p;
+ const char *rv;
+ size_t len;
+
+ len = strlcpy(rvbuf, reponame, sizeof(rvbuf));
+ if (len >= sizeof(rvbuf))
+ die("cgit_repobasename: truncated repository name '%s'", reponame);
+ p = len - 1;
+ /* strip trailing slashes */
+ while (p && rvbuf[p] == '/')
+ rvbuf[p--] = '\0';
+ /* strip trailing .git */
+ if (p >= 3 && starts_with(&rvbuf[p-3], ".git")) {
+ p -= 3;
+ rvbuf[p--] = '\0';
+ }
+ /* strip more trailing slashes if any */
+ while (p && rvbuf[p] == '/')
+ rvbuf[p--] = '\0';
+ /* find last slash in the remaining string */
+ rv = strrchr(rvbuf, '/');
+ if (rv)
+ return ++rv;
+ return rvbuf;
+}
+
+const char *cgit_snapshot_prefix(const struct cgit_repo *repo)
+{
+ if (repo->snapshot_prefix)
+ return repo->snapshot_prefix;
+
+ return cgit_repobasename(repo->url);
+}
+
+static void site_url(const char *page, const char *search, const char *sort, int ofs, int always_root)
+{
+ char *delim = "?";
+
+ if (always_root || page)
+ html_attr(cgit_rooturl());
+ else {
+ char *currenturl = cgit_currenturl();
+ html_attr(currenturl);
+ free(currenturl);
+ }
+
+ if (page) {
+ htmlf("?p=%s", page);
+ delim = "&amp;";
+ }
+ if (search) {
+ html(delim);
+ html("q=");
+ html_attr(search);
+ delim = "&amp;";
+ }
+ if (sort) {
+ html(delim);
+ html("s=");
+ html_attr(sort);
+ delim = "&amp;";
+ }
+ if (ofs) {
+ html(delim);
+ htmlf("ofs=%d", ofs);
+ }
+}
+
+static void site_link(const char *page, const char *name, const char *title,
+ const char *class, const char *search, const char *sort, int ofs, int always_root)
+{
+ html("<a");
+ if (title) {
+ html(" title='");
+ html_attr(title);
+ html("'");
+ }
+ if (class) {
+ html(" class='");
+ html_attr(class);
+ html("'");
+ }
+ html(" href='");
+ site_url(page, search, sort, ofs, always_root);
+ html("'>");
+ html_txt(name);
+ html("</a>");
+}
+
+void cgit_index_link(const char *name, const char *title, const char *class,
+ const char *pattern, const char *sort, int ofs, int always_root)
+{
+ site_link(NULL, name, title, class, pattern, sort, ofs, always_root);
+}
+
+static char *repolink(const char *title, const char *class, const char *page,
+ const char *head, const char *path)
+{
+ char *delim = "?";
+
+ html("<a");
+ if (title) {
+ html(" title='");
+ html_attr(title);
+ html("'");
+ }
+ if (class) {
+ html(" class='");
+ html_attr(class);
+ html("'");
+ }
+ html(" href='");
+ if (ctx.cfg.virtual_root) {
+ html_url_path(ctx.cfg.virtual_root);
+ html_url_path(ctx.repo->url);
+ if (ctx.repo->url[strlen(ctx.repo->url) - 1] != '/')
+ html("/");
+ if (page) {
+ html_url_path(page);
+ html("/");
+ if (path)
+ html_url_path(path);
+ }
+ } else {
+ html_url_path(ctx.cfg.script_name);
+ html("?url=");
+ html_url_arg(ctx.repo->url);
+ if (ctx.repo->url[strlen(ctx.repo->url) - 1] != '/')
+ html("/");
+ if (page) {
+ html_url_arg(page);
+ html("/");
+ if (path)
+ html_url_arg(path);
+ }
+ delim = "&amp;";
+ }
+ if (head && ctx.repo->defbranch && strcmp(head, ctx.repo->defbranch)) {
+ html(delim);
+ html("h=");
+ html_url_arg(head);
+ delim = "&amp;";
+ }
+ return fmt("%s", delim);
+}
+
+static void reporevlink(const char *page, const char *name, const char *title,
+ const char *class, const char *head, const char *rev,
+ const char *path)
+{
+ char *delim;
+
+ delim = repolink(title, class, page, head, path);
+ if (rev && ctx.qry.head != NULL && strcmp(rev, ctx.qry.head)) {
+ html(delim);
+ html("id=");
+ html_url_arg(rev);
+ }
+ html("'>");
+ html_txt(name);
+ html("</a>");
+}
+
+void cgit_summary_link(const char *name, const char *title, const char *class,
+ const char *head)
+{
+ reporevlink(NULL, name, title, class, head, NULL, NULL);
+}
+
+void cgit_tag_link(const char *name, const char *title, const char *class,
+ const char *tag)
+{
+ reporevlink("tag", name, title, class, tag, NULL, NULL);
+}
+
+void cgit_tree_link(const char *name, const char *title, const char *class,
+ const char *head, const char *rev, const char *path)
+{
+ reporevlink("tree", name, title, class, head, rev, path);
+}
+
+void cgit_plain_link(const char *name, const char *title, const char *class,
+ const char *head, const char *rev, const char *path)
+{
+ reporevlink("plain", name, title, class, head, rev, path);
+}
+
+void cgit_blame_link(const char *name, const char *title, const char *class,
+ const char *head, const char *rev, const char *path)
+{
+ reporevlink("blame", name, title, class, head, rev, 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)
+{
+ char *delim;
+
+ delim = repolink(title, class, "log", head, path);
+ if (rev && ctx.qry.head && strcmp(rev, ctx.qry.head)) {
+ html(delim);
+ html("id=");
+ html_url_arg(rev);
+ delim = "&amp;";
+ }
+ if (grep && pattern) {
+ html(delim);
+ html("qt=");
+ html_url_arg(grep);
+ delim = "&amp;";
+ html(delim);
+ html("q=");
+ html_url_arg(pattern);
+ }
+ if (ofs > 0) {
+ html(delim);
+ html("ofs=");
+ htmlf("%d", ofs);
+ delim = "&amp;";
+ }
+ if (showmsg) {
+ html(delim);
+ html("showmsg=1");
+ delim = "&amp;";
+ }
+ if (follow) {
+ html(delim);
+ html("follow=1");
+ }
+ html("'>");
+ html_txt(name);
+ html("</a>");
+}
+
+void cgit_commit_link(const char *name, const char *title, const char *class,
+ const char *head, const char *rev, const char *path)
+{
+ char *delim;
+
+ delim = repolink(title, class, "commit", head, path);
+ if (rev && ctx.qry.head && strcmp(rev, ctx.qry.head)) {
+ html(delim);
+ html("id=");
+ html_url_arg(rev);
+ delim = "&amp;";
+ }
+ if (ctx.qry.difftype) {
+ html(delim);
+ htmlf("dt=%d", ctx.qry.difftype);
+ delim = "&amp;";
+ }
+ if (ctx.qry.context > 0 && ctx.qry.context != 3) {
+ html(delim);
+ html("context=");
+ htmlf("%d", ctx.qry.context);
+ delim = "&amp;";
+ }
+ if (ctx.qry.ignorews) {
+ html(delim);
+ html("ignorews=1");
+ delim = "&amp;";
+ }
+ if (ctx.qry.follow) {
+ html(delim);
+ html("follow=1");
+ }
+ html("'>");
+ if (name[0] != '\0') {
+ if (strlen(name) > ctx.cfg.max_msg_len && ctx.cfg.max_msg_len >= 15) {
+ html_ntxt(name, ctx.cfg.max_msg_len - 3);
+ html("...");
+ } else
+ html_txt(name);
+ } else
+ html_txt("(no commit message)");
+ html("</a>");
+}
+
+void cgit_refs_link(const char *name, const char *title, const char *class,
+ const char *head, const char *rev, const char *path)
+{
+ reporevlink("refs", name, title, class, head, rev, path);
+}
+
+void cgit_snapshot_link(const char *name, const char *title, const char *class,
+ const char *head, const char *rev,
+ const char *archivename)
+{
+ reporevlink("snapshot", name, title, class, head, rev, archivename);
+}
+
+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)
+{
+ char *delim;
+
+ delim = repolink(title, class, "diff", head, path);
+ if (new_rev && ctx.qry.head != NULL && strcmp(new_rev, ctx.qry.head)) {
+ html(delim);
+ html("id=");
+ html_url_arg(new_rev);
+ delim = "&amp;";
+ }
+ if (old_rev) {
+ html(delim);
+ html("id2=");
+ html_url_arg(old_rev);
+ delim = "&amp;";
+ }
+ if (ctx.qry.difftype) {
+ html(delim);
+ htmlf("dt=%d", ctx.qry.difftype);
+ delim = "&amp;";
+ }
+ if (ctx.qry.context > 0 && ctx.qry.context != 3) {
+ html(delim);
+ html("context=");
+ htmlf("%d", ctx.qry.context);
+ delim = "&amp;";
+ }
+ if (ctx.qry.ignorews) {
+ html(delim);
+ html("ignorews=1");
+ delim = "&amp;";
+ }
+ if (ctx.qry.follow) {
+ html(delim);
+ html("follow=1");
+ }
+ html("'>");
+ html_txt(name);
+ html("</a>");
+}
+
+void cgit_patch_link(const char *name, const char *title, const char *class,
+ const char *head, const char *rev, const char *path)
+{
+ reporevlink("patch", name, title, class, head, rev, path);
+}
+
+void cgit_stats_link(const char *name, const char *title, const char *class,
+ const char *head, const char *path)
+{
+ reporevlink("stats", name, title, class, head, NULL, path);
+}
+
+static void cgit_self_link(char *name, const char *title, const char *class)
+{
+ if (!strcmp(ctx.qry.page, "repolist"))
+ cgit_index_link(name, title, class, ctx.qry.search, ctx.qry.sort,
+ ctx.qry.ofs, 1);
+ else if (!strcmp(ctx.qry.page, "summary"))
+ cgit_summary_link(name, title, class, ctx.qry.head);
+ else if (!strcmp(ctx.qry.page, "tag"))
+ cgit_tag_link(name, title, class, ctx.qry.has_oid ?
+ ctx.qry.oid : ctx.qry.head);
+ else if (!strcmp(ctx.qry.page, "tree"))
+ cgit_tree_link(name, title, class, ctx.qry.head,
+ ctx.qry.has_oid ? ctx.qry.oid : NULL,
+ ctx.qry.path);
+ else if (!strcmp(ctx.qry.page, "plain"))
+ cgit_plain_link(name, title, class, ctx.qry.head,
+ ctx.qry.has_oid ? ctx.qry.oid : NULL,
+ ctx.qry.path);
+ else if (!strcmp(ctx.qry.page, "blame"))
+ cgit_blame_link(name, title, class, ctx.qry.head,
+ ctx.qry.has_oid ? ctx.qry.oid : NULL,
+ ctx.qry.path);
+ else if (!strcmp(ctx.qry.page, "log"))
+ cgit_log_link(name, title, class, ctx.qry.head,
+ ctx.qry.has_oid ? ctx.qry.oid : NULL,
+ ctx.qry.path, ctx.qry.ofs,
+ ctx.qry.grep, ctx.qry.search,
+ ctx.qry.showmsg, ctx.qry.follow);
+ else if (!strcmp(ctx.qry.page, "commit"))
+ cgit_commit_link(name, title, class, ctx.qry.head,
+ ctx.qry.has_oid ? ctx.qry.oid : NULL,
+ ctx.qry.path);
+ else if (!strcmp(ctx.qry.page, "patch"))
+ cgit_patch_link(name, title, class, ctx.qry.head,
+ ctx.qry.has_oid ? ctx.qry.oid : NULL,
+ ctx.qry.path);
+ else if (!strcmp(ctx.qry.page, "refs"))
+ cgit_refs_link(name, title, class, ctx.qry.head,
+ ctx.qry.has_oid ? ctx.qry.oid : NULL,
+ ctx.qry.path);
+ else if (!strcmp(ctx.qry.page, "snapshot"))
+ cgit_snapshot_link(name, title, class, ctx.qry.head,
+ ctx.qry.has_oid ? ctx.qry.oid : NULL,
+ ctx.qry.path);
+ else if (!strcmp(ctx.qry.page, "diff"))
+ cgit_diff_link(name, title, class, ctx.qry.head,
+ ctx.qry.oid, ctx.qry.oid2,
+ ctx.qry.path);
+ else if (!strcmp(ctx.qry.page, "stats"))
+ cgit_stats_link(name, title, class, ctx.qry.head,
+ ctx.qry.path);
+ else {
+ /* Don't known how to make link for this page */
+ repolink(title, class, ctx.qry.page, ctx.qry.head, ctx.qry.path);
+ html("><!-- cgit_self_link() doesn't know how to make link for page '");
+ html_txt(ctx.qry.page);
+ html("' -->");
+ html_txt(name);
+ html("</a>");
+ }
+}
+
+void cgit_object_link(struct object *obj)
+{
+ char *page, *shortrev, *fullrev, *name;
+
+ fullrev = oid_to_hex(&obj->oid);
+ shortrev = xstrdup(fullrev);
+ shortrev[10] = '\0';
+ if (obj->type == OBJ_COMMIT) {
+ cgit_commit_link(fmt("commit %s...", shortrev), NULL, NULL,
+ ctx.qry.head, fullrev, NULL);
+ return;
+ } else if (obj->type == OBJ_TREE)
+ page = "tree";
+ else if (obj->type == OBJ_TAG)
+ page = "tag";
+ else
+ page = "blob";
+ name = fmt("%s %s...", type_name(obj->type), shortrev);
+ reporevlink(page, name, NULL, NULL, ctx.qry.head, fullrev, NULL);
+}
+
+static struct string_list_item *lookup_path(struct string_list *list,
+ const char *path)
+{
+ struct string_list_item *item;
+
+ while (path && path[0]) {
+ if ((item = string_list_lookup(list, path)))
+ return item;
+ if (!(path = strchr(path, '/')))
+ break;
+ path++;
+ }
+ return NULL;
+}
+
+void cgit_submodule_link(const char *class, char *path, const char *rev)
+{
+ struct string_list *list;
+ struct string_list_item *item;
+ char tail, *dir;
+ size_t len;
+
+ len = 0;
+ tail = 0;
+ list = &ctx.repo->submodules;
+ item = lookup_path(list, path);
+ if (!item) {
+ len = strlen(path);
+ tail = path[len - 1];
+ if (tail == '/') {
+ path[len - 1] = 0;
+ item = lookup_path(list, path);
+ }
+ }
+ if (item || ctx.repo->module_link) {
+ html("<a ");
+ if (class)
+ htmlf("class='%s' ", class);
+ html("href='");
+ if (item) {
+ html_attrf(item->util, rev);
+ } else {
+ dir = strrchr(path, '/');
+ if (dir)
+ dir++;
+ else
+ dir = path;
+ html_attrf(ctx.repo->module_link, dir, rev);
+ }
+ html("'>");
+ html_txt(path);
+ html("</a>");
+ } else {
+ html("<span");
+ if (class)
+ htmlf(" class='%s'", class);
+ html(">");
+ html_txt(path);
+ html("</span>");
+ }
+ html_txtf(" @ %.7s", rev);
+ if (item && tail)
+ path[len - 1] = tail;
+}
+
+const struct date_mode cgit_date_mode(enum date_mode_type type)
+{
+ static struct date_mode mode;
+ mode.type = type;
+ mode.local = ctx.cfg.local_time;
+ return mode;
+}
+
+static void print_rel_date(time_t t, int tz, double value,
+ const char *class, const char *suffix)
+{
+ htmlf("<span class='%s' data-ut='%" PRIu64 "' title='", class, (uint64_t)t);
+ html_attr(show_date(t, tz, cgit_date_mode(DATE_ISO8601)));
+ htmlf("'>%.0f %s</span>", value, suffix);
+}
+
+void cgit_print_age(time_t t, int tz, time_t max_relative)
+{
+ time_t now, secs;
+
+ if (!t)
+ return;
+ time(&now);
+ secs = now - t;
+ if (secs < 0)
+ secs = 0;
+
+ if (secs > max_relative && max_relative >= 0) {
+ html("<span title='");
+ html_attr(show_date(t, tz, cgit_date_mode(DATE_ISO8601)));
+ html("'>");
+ html_txt(show_date(t, tz, cgit_date_mode(DATE_SHORT)));
+ html("</span>");
+ return;
+ }
+
+ if (secs < TM_HOUR * 2) {
+ print_rel_date(t, tz, secs * 1.0 / TM_MIN, "age-mins", "min.");
+ return;
+ }
+ if (secs < TM_DAY * 2) {
+ print_rel_date(t, tz, secs * 1.0 / TM_HOUR, "age-hours", "hours");
+ return;
+ }
+ if (secs < TM_WEEK * 2) {
+ print_rel_date(t, tz, secs * 1.0 / TM_DAY, "age-days", "days");
+ return;
+ }
+ if (secs < TM_MONTH * 2) {
+ print_rel_date(t, tz, secs * 1.0 / TM_WEEK, "age-weeks", "weeks");
+ return;
+ }
+ if (secs < TM_YEAR * 2) {
+ print_rel_date(t, tz, secs * 1.0 / TM_MONTH, "age-months", "months");
+ return;
+ }
+ print_rel_date(t, tz, secs * 1.0 / TM_YEAR, "age-years", "years");
+}
+
+void cgit_print_http_headers(void)
+{
+ if (ctx.env.no_http && !strcmp(ctx.env.no_http, "1"))
+ return;
+
+ if (ctx.page.status)
+ htmlf("Status: %d %s\n", ctx.page.status, ctx.page.statusmsg);
+ if (ctx.page.mimetype && ctx.page.charset)
+ htmlf("Content-Type: %s; charset=%s\n", ctx.page.mimetype,
+ ctx.page.charset);
+ else if (ctx.page.mimetype)
+ htmlf("Content-Type: %s\n", ctx.page.mimetype);
+ if (ctx.page.size)
+ htmlf("Content-Length: %zd\n", ctx.page.size);
+ if (ctx.page.filename) {
+ html("Content-Disposition: inline; filename=\"");
+ html_header_arg_in_quotes(ctx.page.filename);
+ html("\"\n");
+ }
+ htmlf("Last-Modified: %s\n", http_date(ctx.page.modified));
+ if (ctx.page.etag)
+ htmlf("ETag: \"%s\"\n", ctx.page.etag);
+ html("\n");
+ if (ctx.env.request_method && !strcmp(ctx.env.request_method, "HEAD"))
+ exit(0);
+}
+
+void cgit_redirect(const char *url, bool permanent)
+{
+ htmlf("Status: %d %s\n", permanent ? 301 : 302, permanent ? "Moved" : "Found");
+ html("Location: ");
+ html_url_path(url);
+ html("\n\n");
+}
+
+static void print_rel_vcs_link(const char *url)
+{
+ html("<link rel='vcs-git' href='");
+ html_attr(url);
+ html("' title='");
+ html_attr(ctx.repo->name);
+ html(" Git repository'/>\n");
+}
+
+static int emit_css_link(struct string_list_item *s, void *arg)
+{
+ /* Do not emit anything if css= is specified. */
+ if (s && *s->string == '\0')
+ return 0;
+
+ html("<link rel='stylesheet' type='text/css' href='");
+ if (s)
+ html_attr(s->string);
+ else
+ html_attr((const char *)arg);
+ html("'/>\n");
+
+ return 0;
+}
+
+static int emit_js_link(struct string_list_item *s, void *arg)
+{
+ /* Do not emit anything if js= is specified. */
+ if (s && *s->string == '\0')
+ return 0;
+
+ html("<script type='text/javascript' src='");
+ if (s)
+ html_attr(s->string);
+ else
+ html_attr((const char *)arg);
+ html("'></script>\n");
+
+ return 0;
+}
+
+void cgit_print_docstart(void)
+{
+ char *host = cgit_hosturl();
+
+ if (ctx.cfg.embedded) {
+ if (ctx.cfg.header)
+ html_include(ctx.cfg.header);
+ return;
+ }
+
+ html(cgit_doctype);
+ html("<html lang='en'>\n");
+ html("<head>\n");
+ html("<title>");
+ html_txt(ctx.page.title);
+ html("</title>\n");
+ htmlf("<meta name='generator' content='cgit %s'/>\n", cgit_version);
+ if (ctx.cfg.robots && *ctx.cfg.robots)
+ htmlf("<meta name='robots' content='%s'/>\n", ctx.cfg.robots);
+
+ if (ctx.cfg.css.items)
+ for_each_string_list(&ctx.cfg.css, emit_css_link, NULL);
+ else
+ emit_css_link(NULL, "/cgit.css");
+
+ if (ctx.cfg.js.items)
+ for_each_string_list(&ctx.cfg.js, emit_js_link, NULL);
+ else
+ emit_js_link(NULL, "/cgit.js");
+
+ if (ctx.cfg.favicon && *ctx.cfg.favicon) {
+ html("<link rel='shortcut icon' href='");
+ html_attr(ctx.cfg.favicon);
+ html("'/>\n");
+ }
+ if (host && ctx.repo && ctx.qry.head) {
+ char *fileurl;
+ struct strbuf sb = STRBUF_INIT;
+ strbuf_addf(&sb, "h=%s", ctx.qry.head);
+
+ html("<link rel='alternate' title='Atom feed' href='");
+ html(cgit_httpscheme());
+ html_attr(host);
+ fileurl = cgit_fileurl(ctx.repo->url, "atom", ctx.qry.vpath,
+ sb.buf);
+ html_attr(fileurl);
+ html("' type='application/atom+xml'/>\n");
+ strbuf_release(&sb);
+ free(fileurl);
+ }
+ if (ctx.repo)
+ cgit_add_clone_urls(print_rel_vcs_link);
+ if (ctx.cfg.head_include)
+ html_include(ctx.cfg.head_include);
+ if (ctx.repo && ctx.repo->extra_head_content)
+ html(ctx.repo->extra_head_content);
+ html("</head>\n");
+ html("<body>\n");
+ if (ctx.cfg.header)
+ html_include(ctx.cfg.header);
+ free(host);
+}
+
+void cgit_print_docend(void)
+{
+ html("</div> <!-- class=content -->\n");
+ if (ctx.cfg.embedded) {
+ html("</div> <!-- id=cgit -->\n");
+ if (ctx.cfg.footer)
+ html_include(ctx.cfg.footer);
+ return;
+ }
+ if (ctx.cfg.footer)
+ html_include(ctx.cfg.footer);
+ else {
+ htmlf("<div class='footer'>generated by <a href='https://git.zx2c4.com/cgit/about/'>cgit %s</a> "
+ "(<a href='https://git-scm.com/'>git %s</a>) at ", cgit_version, git_version_string);
+ html_txt(show_date(time(NULL), 0, cgit_date_mode(DATE_ISO8601)));
+ html("</div>\n");
+ }
+ html("</div> <!-- id=cgit -->\n");
+ html("</body>\n</html>\n");
+}
+
+void cgit_print_error_page(int code, const char *msg, const char *fmt, ...)
+{
+ va_list ap;
+ va_start(ap, fmt);
+ cgit_vprint_error_page(code, msg, fmt, ap);
+ va_end(ap);
+}
+
+void cgit_vprint_error_page(int code, const char *msg, const char *fmt, va_list ap)
+{
+ ctx.page.status = code;
+ ctx.page.statusmsg = msg;
+ cgit_print_layout_start();
+ cgit_vprint_error(fmt, ap);
+ cgit_print_layout_end();
+}
+
+void cgit_print_layout_start(void)
+{
+ cgit_print_http_headers();
+ cgit_print_docstart();
+ cgit_print_pageheader();
+}
+
+void cgit_print_layout_end(void)
+{
+ cgit_print_docend();
+}
+
+static void add_clone_urls(void (*fn)(const char *), char *txt, char *suffix)
+{
+ struct strbuf **url_list = strbuf_split_str(txt, ' ', 0);
+ int i;
+
+ for (i = 0; url_list[i]; i++) {
+ strbuf_rtrim(url_list[i]);
+ if (url_list[i]->len == 0)
+ continue;
+ if (suffix && *suffix)
+ strbuf_addf(url_list[i], "/%s", suffix);
+ fn(url_list[i]->buf);
+ }
+
+ strbuf_list_free(url_list);
+}
+
+void cgit_add_clone_urls(void (*fn)(const char *))
+{
+ if (ctx.repo->clone_url)
+ add_clone_urls(fn, expand_macros(ctx.repo->clone_url), NULL);
+ else if (ctx.cfg.clone_prefix)
+ add_clone_urls(fn, ctx.cfg.clone_prefix, ctx.repo->url);
+}
+
+static int print_branch_option(const struct reference *ref, void *cb_data)
+{
+ char *name = (char *)ref->name;
+ html_option(name, name, ctx.qry.head);
+ return 0;
+}
+
+void cgit_add_hidden_formfields(int incl_head, int incl_search,
+ const char *page)
+{
+ if (!ctx.cfg.virtual_root) {
+ struct strbuf url = STRBUF_INIT;
+
+ strbuf_addf(&url, "%s/%s", ctx.qry.repo, page);
+ if (ctx.qry.vpath)
+ strbuf_addf(&url, "/%s", ctx.qry.vpath);
+ html_hidden("url", url.buf);
+ strbuf_release(&url);
+ }
+
+ if (incl_head && ctx.qry.head && ctx.repo->defbranch &&
+ strcmp(ctx.qry.head, ctx.repo->defbranch))
+ html_hidden("h", ctx.qry.head);
+
+ if (ctx.qry.oid)
+ html_hidden("id", ctx.qry.oid);
+ if (ctx.qry.oid2)
+ html_hidden("id2", ctx.qry.oid2);
+ if (ctx.qry.showmsg)
+ html_hidden("showmsg", "1");
+
+ if (incl_search) {
+ if (ctx.qry.grep)
+ html_hidden("qt", ctx.qry.grep);
+ if (ctx.qry.search)
+ html_hidden("q", ctx.qry.search);
+ }
+}
+
+static const char *hc(const char *page)
+{
+ if (!ctx.qry.page)
+ return NULL;
+
+ return strcmp(ctx.qry.page, page) ? NULL : "active";
+}
+
+static void cgit_print_path_crumbs(char *path)
+{
+ char *old_path = ctx.qry.path;
+ char *p = path, *q, *end = path + strlen(path);
+ int levels = 0;
+
+ ctx.qry.path = NULL;
+ cgit_self_link("root", NULL, NULL);
+ ctx.qry.path = p = path;
+ while (p < end) {
+ if (!(q = strchr(p, '/')) || levels > 15)
+ q = end;
+ *q = '\0';
+ html_txt("/");
+ cgit_self_link(p, NULL, NULL);
+ if (q < end)
+ *q = '/';
+ p = q + 1;
+ ++levels;
+ }
+ ctx.qry.path = old_path;
+}
+
+static void print_header(void)
+{
+ char *logo = NULL, *logo_link = NULL;
+
+ html("<table id='header'>\n");
+ html("<tr>\n");
+
+ if (ctx.repo && ctx.repo->logo && *ctx.repo->logo)
+ logo = ctx.repo->logo;
+ else
+ logo = ctx.cfg.logo;
+ if (ctx.repo && ctx.repo->logo_link && *ctx.repo->logo_link)
+ logo_link = ctx.repo->logo_link;
+ else
+ logo_link = ctx.cfg.logo_link;
+ if (logo && *logo) {
+ html("<td class='logo' rowspan='2'><a href='");
+ if (logo_link && *logo_link)
+ html_attr(logo_link);
+ else
+ html_attr(cgit_rooturl());
+ html("'><img src='");
+ html_attr(logo);
+ html("' alt='cgit logo'/></a></td>\n");
+ }
+
+ html("<td class='main'>");
+ if (ctx.repo) {
+ cgit_index_link("index", NULL, NULL, NULL, NULL, 0, 1);
+ html(" : ");
+ cgit_summary_link(ctx.repo->name, NULL, NULL, NULL);
+ html("</td><td class='form'>");
+ html("<form method='get'>\n");
+ cgit_add_hidden_formfields(0, 1, ctx.qry.page);
+ html("<select name='h' onchange='this.form.submit();'>\n");
+ refs_for_each_branch_ref(get_main_ref_store(the_repository),
+ print_branch_option, ctx.qry.head);
+ if (ctx.repo->enable_remote_branches)
+ refs_for_each_remote_ref(get_main_ref_store(the_repository),
+ print_branch_option, ctx.qry.head);
+ html("</select> ");
+ html("<input type='submit' value='switch'/>");
+ html("</form>");
+ } else
+ html_txt(ctx.cfg.root_title);
+ html("</td></tr>\n");
+
+ html("<tr><td class='sub'>");
+ if (ctx.repo) {
+ html_txt(ctx.repo->desc);
+ html("</td><td class='sub right'>");
+ html_txt(ctx.repo->owner);
+ } else {
+ if (ctx.cfg.root_desc)
+ html_txt(ctx.cfg.root_desc);
+ }
+ html("</td></tr></table>\n");
+}
+
+void cgit_print_pageheader(void)
+{
+ html("<div id='cgit'>");
+ if (!ctx.cfg.noheader)
+ print_header();
+
+ html("<table class='tabs'><tr><td>\n");
+ if (ctx.repo) {
+ if (ctx.repo->readme.nr)
+ reporevlink("about", "about", NULL,
+ hc("about"), ctx.qry.head, NULL,
+ NULL);
+ cgit_summary_link("summary", NULL, hc("summary"),
+ ctx.qry.head);
+ cgit_refs_link("refs", NULL, hc("refs"), ctx.qry.head,
+ ctx.qry.oid, NULL);
+ cgit_log_link("log", NULL, hc("log"), ctx.qry.head,
+ NULL, ctx.qry.vpath, 0, NULL, NULL,
+ ctx.qry.showmsg, ctx.qry.follow);
+ if (ctx.qry.page && !strcmp(ctx.qry.page, "blame"))
+ cgit_blame_link("blame", NULL, hc("blame"), ctx.qry.head,
+ ctx.qry.oid, ctx.qry.vpath);
+ else
+ cgit_tree_link("tree", NULL, hc("tree"), ctx.qry.head,
+ ctx.qry.oid, ctx.qry.vpath);
+ cgit_commit_link("commit", NULL, hc("commit"),
+ ctx.qry.head, ctx.qry.oid, ctx.qry.vpath);
+ cgit_diff_link("diff", NULL, hc("diff"), ctx.qry.head,
+ ctx.qry.oid, ctx.qry.oid2, ctx.qry.vpath);
+ if (ctx.repo->max_stats)
+ cgit_stats_link("stats", NULL, hc("stats"),
+ ctx.qry.head, ctx.qry.vpath);
+ if (ctx.repo->homepage) {
+ html("<a href='");
+ html_attr(ctx.repo->homepage);
+ html("'>homepage</a>");
+ }
+ html("</td><td class='form'>");
+ html("<form class='right' method='get' action='");
+ if (ctx.cfg.virtual_root) {
+ char *fileurl = cgit_fileurl(ctx.qry.repo, "log",
+ ctx.qry.vpath, NULL);
+ html_url_path(fileurl);
+ free(fileurl);
+ }
+ html("'>\n");
+ cgit_add_hidden_formfields(1, 0, "log");
+ html("<select name='qt'>\n");
+ html_option("grep", "log msg", ctx.qry.grep);
+ html_option("author", "author", ctx.qry.grep);
+ html_option("committer", "committer", ctx.qry.grep);
+ html_option("range", "range", ctx.qry.grep);
+ html("</select>\n");
+ html("<input class='txt' type='search' size='10' name='q' value='");
+ html_attr(ctx.qry.search);
+ html("'/>\n");
+ html("<input type='submit' value='search'/>\n");
+ html("</form>\n");
+ } else {
+ char *currenturl = cgit_currenturl();
+ site_link(NULL, "index", NULL, hc("repolist"), NULL, NULL, 0, 1);
+ if (ctx.cfg.root_readme)
+ site_link("about", "about", NULL, hc("about"),
+ NULL, NULL, 0, 1);
+ html("</td><td class='form'>");
+ html("<form method='get' action='");
+ html_attr(currenturl);
+ html("'>\n");
+ html("<input type='search' name='q' size='10' value='");
+ html_attr(ctx.qry.search);
+ html("'/>\n");
+ html("<input type='submit' value='search'/>\n");
+ html("</form>");
+ free(currenturl);
+ }
+ html("</td></tr></table>\n");
+ if (ctx.repo && ctx.qry.vpath) {
+ html("<div class='path'>");
+ html("path: ");
+ cgit_print_path_crumbs(ctx.qry.vpath);
+ if (ctx.repo->enable_follow_links && !strcmp(ctx.qry.page, "log")) {
+ html(" (");
+ ctx.qry.follow = !ctx.qry.follow;
+ cgit_self_link(ctx.qry.follow ? "follow" : "unfollow",
+ NULL, NULL);
+ ctx.qry.follow = !ctx.qry.follow;
+ html(")");
+ }
+ html("</div>");
+ }
+ html("<div class='content'>");
+}
+
+void cgit_print_filemode(unsigned short mode)
+{
+ if (S_ISDIR(mode))
+ html("d");
+ else if (S_ISLNK(mode))
+ html("l");
+ else if (S_ISGITLINK(mode))
+ html("m");
+ else
+ html("-");
+ html_fileperm(mode >> 6);
+ html_fileperm(mode >> 3);
+ html_fileperm(mode);
+}
+
+void cgit_compose_snapshot_prefix(struct strbuf *filename, const char *base,
+ const char *ref)
+{
+ struct object_id oid;
+
+ /*
+ * Prettify snapshot names by stripping leading "v" or "V" if the tag
+ * name starts with {v,V}[0-9] and the prettify mapping is injective,
+ * i.e. each stripped tag can be inverted without ambiguities.
+ */
+ if (repo_get_oid(the_repository, fmt("refs/tags/%s", ref), &oid) == 0 &&
+ (ref[0] == 'v' || ref[0] == 'V') && isdigit(ref[1]) &&
+ ((repo_get_oid(the_repository, fmt("refs/tags/%s", ref + 1), &oid) == 0) +
+ (repo_get_oid(the_repository, fmt("refs/tags/v%s", ref + 1), &oid) == 0) +
+ (repo_get_oid(the_repository, fmt("refs/tags/V%s", ref + 1), &oid) == 0) == 1))
+ ref++;
+
+ strbuf_addf(filename, "%s-%s", base, ref);
+}
+
+void cgit_print_snapshot_links(const struct cgit_repo *repo, const char *ref,
+ const char *separator)
+{
+ const struct cgit_snapshot_format *f;
+ struct strbuf filename = STRBUF_INIT;
+ const char *basename;
+ size_t prefixlen;
+
+ basename = cgit_snapshot_prefix(repo);
+ if (starts_with(ref, basename))
+ strbuf_addstr(&filename, ref);
+ else
+ cgit_compose_snapshot_prefix(&filename, basename, ref);
+
+ prefixlen = filename.len;
+ for (f = cgit_snapshot_formats; f->suffix; f++) {
+ if (!(repo->snapshots & cgit_snapshot_format_bit(f)))
+ continue;
+ strbuf_setlen(&filename, prefixlen);
+ strbuf_addstr(&filename, f->suffix);
+ cgit_snapshot_link(filename.buf, NULL, NULL, NULL, NULL,
+ filename.buf);
+ if (cgit_snapshot_get_sig(ref, f)) {
+ strbuf_addstr(&filename, ".asc");
+ html(" (");
+ cgit_snapshot_link("sig", NULL, NULL, NULL, NULL,
+ filename.buf);
+ html(")");
+ } else if (starts_with(f->suffix, ".tar") && cgit_snapshot_get_sig(ref, &cgit_snapshot_formats[0])) {
+ strbuf_setlen(&filename, strlen(filename.buf) - strlen(f->suffix));
+ strbuf_addstr(&filename, ".tar.asc");
+ html(" (");
+ cgit_snapshot_link("sig", NULL, NULL, NULL, NULL,
+ filename.buf);
+ html(")");
+ }
+ html(separator);
+ }
+ strbuf_release(&filename);
+}
+
+void cgit_set_title_from_path(const char *path)
+{
+ struct strbuf sb = STRBUF_INIT;
+ const char *slash, *last_slash;
+
+ if (!path)
+ return;
+
+ for (last_slash = path + strlen(path); (slash = memrchr(path, '/', last_slash - path)) != NULL; last_slash = slash) {
+ strbuf_add(&sb, slash + 1, last_slash - slash - 1);
+ strbuf_addstr(&sb, " \xc2\xab ");
+ }
+ strbuf_add(&sb, path, last_slash - path);
+ strbuf_addf(&sb, " - %s", ctx.page.title);
+ ctx.page.title = strbuf_detach(&sb, NULL);
+}
diff --git a/cgit/ui-shared.h b/cgit/ui-shared.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-shared.h
@@ -0,0 +1,91 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_SHARED_H
+#define UI_SHARED_H
+
+extern const char *cgit_httpscheme(void);
+extern char *cgit_hosturl(void);
+extern const char *cgit_rooturl(void);
+extern char *cgit_currenturl(void);
+extern char *cgit_currentfullurl(void);
+extern char *cgit_repourl(const char *reponame);
+extern char *cgit_fileurl(const char *reponame, const char *pagename,
+ const char *filename, const char *query);
+extern char *cgit_pageurl(const char *reponame, const char *pagename,
+ const char *query);
+
+extern void cgit_add_clone_urls(void (*fn)(const char *));
+
+extern void cgit_index_link(const char *name, const char *title,
+ const char *class, const char *pattern, const char *sort, int ofs, int always_root);
+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_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_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_snapshot_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *archivename);
+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);
+extern void cgit_stats_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *path);
+extern void cgit_object_link(struct object *obj);
+
+extern void cgit_submodule_link(const char *class, char *path,
+ const char *rev);
+
+extern void cgit_print_layout_start(void);
+extern void cgit_print_layout_end(void);
+
+__attribute__((format (printf,1,2)))
+extern void cgit_print_error(const char *fmt, ...);
+__attribute__((format (printf,1,0)))
+extern void cgit_vprint_error(const char *fmt, va_list ap);
+extern const struct date_mode cgit_date_mode(enum date_mode_type type);
+extern void cgit_print_age(time_t t, int tz, time_t max_relative);
+extern void cgit_print_http_headers(void);
+extern void cgit_redirect(const char *url, bool permanent);
+extern void cgit_print_docstart(void);
+extern void cgit_print_docend(void);
+__attribute__((format (printf,3,4)))
+extern void cgit_print_error_page(int code, const char *msg, const char *fmt, ...);
+extern void cgit_vprint_error_page(int code, const char *msg, const char *fmt, va_list ap);
+extern void cgit_print_pageheader(void);
+extern void cgit_print_filemode(unsigned short mode);
+extern void cgit_compose_snapshot_prefix(struct strbuf *filename,
+ const char *base, const char *ref);
+extern void cgit_print_snapshot_links(const struct cgit_repo *repo,
+ const char *ref, const char *separator);
+extern const char *cgit_snapshot_prefix(const struct cgit_repo *repo);
+extern void cgit_add_hidden_formfields(int incl_head, int incl_search,
+ const char *page);
+
+extern void cgit_set_title_from_path(const char *path);
+#endif /* UI_SHARED_H */
diff --git a/cgit/ui-snapshot.c b/cgit/ui-snapshot.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-snapshot.c
@@ -0,0 +1,322 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-snapshot.c: generate snapshot of a commit
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-snapshot.h"
+#include "html.h"
+#include "ui-shared.h"
+
+static int write_archive_type(const char *format, const char *hex, const char *prefix)
+{
+ struct strvec argv = STRVEC_INIT;
+ const char **nargv;
+ int result;
+ strvec_push(&argv, "snapshot");
+ strvec_push(&argv, format);
+ if (prefix) {
+ struct strbuf buf = STRBUF_INIT;
+ strbuf_addstr(&buf, prefix);
+ strbuf_addch(&buf, '/');
+ strvec_push(&argv, "--prefix");
+ strvec_push(&argv, buf.buf);
+ strbuf_release(&buf);
+ }
+ strvec_push(&argv, hex);
+ /*
+ * Now we need to copy the pointers to arguments into a new
+ * structure because write_archive will rearrange its arguments
+ * which may result in duplicated/missing entries causing leaks
+ * or double-frees in strvec_clear.
+ */
+ nargv = xmalloc(sizeof(char *) * (argv.nr + 1));
+ /* strvec guarantees a trailing NULL entry. */
+ memcpy(nargv, argv.v, sizeof(char *) * (argv.nr + 1));
+
+ result = write_archive(argv.nr, nargv, NULL, the_repository, NULL, 0);
+ strvec_clear(&argv);
+ free(nargv);
+ return result;
+}
+
+static int write_tar_archive(const char *hex, const char *prefix)
+{
+ return write_archive_type("--format=tar", hex, prefix);
+}
+
+static int write_zip_archive(const char *hex, const char *prefix)
+{
+ return write_archive_type("--format=zip", hex, prefix);
+}
+
+static int write_compressed_tar_archive(const char *hex,
+ const char *prefix,
+ char *filter_argv[])
+{
+ int rv;
+ struct cgit_exec_filter f;
+ cgit_exec_filter_init(&f, filter_argv[0], filter_argv);
+
+ cgit_open_exec_filter(&f);
+ rv = write_tar_archive(hex, prefix);
+ cgit_close_exec_filter(&f);
+ return rv;
+}
+
+static int write_tar_gzip_archive(const char *hex, const char *prefix)
+{
+ char *argv[] = { "gzip", "-n", NULL };
+ return write_compressed_tar_archive(hex, prefix, argv);
+}
+
+static int write_tar_bzip2_archive(const char *hex, const char *prefix)
+{
+ char *argv[] = { "bzip2", NULL };
+ return write_compressed_tar_archive(hex, prefix, argv);
+}
+
+static int write_tar_lzip_archive(const char *hex, const char *prefix)
+{
+ char *argv[] = { "lzip", NULL };
+ return write_compressed_tar_archive(hex, prefix, argv);
+}
+
+static int write_tar_xz_archive(const char *hex, const char *prefix)
+{
+ char *argv[] = { "xz", NULL };
+ return write_compressed_tar_archive(hex, prefix, argv);
+}
+
+static int write_tar_zstd_archive(const char *hex, const char *prefix)
+{
+ char *argv[] = { "zstd", "-T0", NULL };
+ return write_compressed_tar_archive(hex, prefix, argv);
+}
+
+const struct cgit_snapshot_format cgit_snapshot_formats[] = {
+ /* .tar must remain the 0 index */
+ { ".tar", "application/x-tar", write_tar_archive },
+ { ".tar.gz", "application/x-gzip", write_tar_gzip_archive },
+ { ".tar.bz2", "application/x-bzip2", write_tar_bzip2_archive },
+ { ".tar.lz", "application/x-lzip", write_tar_lzip_archive },
+ { ".tar.xz", "application/x-xz", write_tar_xz_archive },
+ { ".tar.zst", "application/x-zstd", write_tar_zstd_archive },
+ { ".zip", "application/x-zip", write_zip_archive },
+ { NULL }
+};
+
+static struct notes_tree snapshot_sig_notes[ARRAY_SIZE(cgit_snapshot_formats)];
+
+const struct object_id *cgit_snapshot_get_sig(const char *ref,
+ const struct cgit_snapshot_format *f)
+{
+ struct notes_tree *tree;
+ struct object_id oid;
+
+ if (repo_get_oid(the_repository, ref, &oid))
+ return NULL;
+
+ tree = &snapshot_sig_notes[f - &cgit_snapshot_formats[0]];
+ if (!tree->initialized) {
+ struct strbuf notes_ref = STRBUF_INIT;
+
+ strbuf_addf(&notes_ref, "refs/notes/signatures/%s",
+ f->suffix + 1);
+
+ init_notes(tree, notes_ref.buf, combine_notes_ignore, 0);
+ strbuf_release(&notes_ref);
+ }
+
+ return get_note(tree, &oid);
+}
+
+static const struct cgit_snapshot_format *get_format(const char *filename)
+{
+ const struct cgit_snapshot_format *fmt;
+
+ for (fmt = cgit_snapshot_formats; fmt->suffix; fmt++) {
+ if (ends_with(filename, fmt->suffix))
+ return fmt;
+ }
+ return NULL;
+}
+
+const unsigned cgit_snapshot_format_bit(const struct cgit_snapshot_format *f)
+{
+ return BIT(f - &cgit_snapshot_formats[0]);
+}
+
+static int make_snapshot(const struct cgit_snapshot_format *format,
+ const char *hex, const char *prefix,
+ const char *filename)
+{
+ struct object_id oid;
+
+ if (repo_get_oid(the_repository, hex, &oid)) {
+ cgit_print_error_page(404, "Not found",
+ "Bad object id: %s", hex);
+ return 1;
+ }
+ if (!lookup_commit_reference(the_repository, &oid)) {
+ cgit_print_error_page(400, "Bad request",
+ "Not a commit reference: %s", hex);
+ return 1;
+ }
+ ctx.page.etag = oid_to_hex(&oid);
+ ctx.page.mimetype = xstrdup(format->mimetype);
+ ctx.page.filename = xstrdup(filename);
+ cgit_print_http_headers();
+ init_archivers();
+ format->write_func(hex, prefix);
+ return 0;
+}
+
+static int write_sig(const struct cgit_snapshot_format *format,
+ const char *hex, const char *archive,
+ const char *filename)
+{
+ const struct object_id *note = cgit_snapshot_get_sig(hex, format);
+ enum object_type type;
+ unsigned long size;
+ char *buf;
+
+ if (!note) {
+ cgit_print_error_page(404, "Not found",
+ "No signature for %s", archive);
+ return 0;
+ }
+
+ buf = odb_read_object(the_repository->objects, note, &type, &size);
+ if (!buf) {
+ cgit_print_error_page(404, "Not found", "Not found");
+ return 0;
+ }
+
+ html("X-Content-Type-Options: nosniff\n");
+ html("Content-Security-Policy: default-src 'none'\n");
+ ctx.page.etag = oid_to_hex(note);
+ ctx.page.mimetype = xstrdup("application/pgp-signature");
+ ctx.page.filename = xstrdup(filename);
+ cgit_print_http_headers();
+
+ html_raw(buf, size);
+ free(buf);
+ return 0;
+}
+
+/* Try to guess the requested revision from the requested snapshot name.
+ * First the format extension is stripped, e.g. "cgit-0.7.2.tar.gz" become
+ * "cgit-0.7.2". If this is a valid commit object name we've got a winner.
+ * Otherwise, if the snapshot name has a prefix matching the result from
+ * repo_basename(), we strip the basename and any following '-' and '_'
+ * characters ("cgit-0.7.2" -> "0.7.2") and check the resulting name once
+ * more. If this still isn't a valid commit object name, we check if pre-
+ * pending a 'v' or a 'V' to the remaining snapshot name ("0.7.2" ->
+ * "v0.7.2") gives us something valid.
+ */
+static const char *get_ref_from_filename(const struct cgit_repo *repo,
+ const char *filename,
+ const struct cgit_snapshot_format *format)
+{
+ const char *reponame;
+ struct object_id oid;
+ struct strbuf snapshot = STRBUF_INIT;
+ int result = 1;
+
+ strbuf_addstr(&snapshot, filename);
+ strbuf_setlen(&snapshot, snapshot.len - strlen(format->suffix));
+
+ if (repo_get_oid(the_repository, snapshot.buf, &oid) == 0)
+ goto out;
+
+ reponame = cgit_snapshot_prefix(repo);
+ if (starts_with(snapshot.buf, reponame)) {
+ const char *new_start = snapshot.buf;
+ new_start += strlen(reponame);
+ while (new_start && (*new_start == '-' || *new_start == '_'))
+ new_start++;
+ strbuf_splice(&snapshot, 0, new_start - snapshot.buf, "", 0);
+ }
+
+ if (repo_get_oid(the_repository, snapshot.buf, &oid) == 0)
+ goto out;
+
+ strbuf_insert(&snapshot, 0, "v", 1);
+ if (repo_get_oid(the_repository, snapshot.buf, &oid) == 0)
+ goto out;
+
+ strbuf_splice(&snapshot, 0, 1, "V", 1);
+ if (repo_get_oid(the_repository, snapshot.buf, &oid) == 0)
+ goto out;
+
+ result = 0;
+ strbuf_release(&snapshot);
+
+out:
+ return result ? strbuf_detach(&snapshot, NULL) : NULL;
+}
+
+void cgit_print_snapshot(const char *head, const char *hex,
+ const char *filename, int dwim)
+{
+ const struct cgit_snapshot_format* f;
+ const char *sig_filename = NULL;
+ char *adj_filename = NULL;
+ char *prefix = NULL;
+
+ if (!filename) {
+ cgit_print_error_page(400, "Bad request",
+ "No snapshot name specified");
+ return;
+ }
+
+ if (ends_with(filename, ".asc")) {
+ sig_filename = filename;
+
+ /* Strip ".asc" from filename for common format processing */
+ adj_filename = xstrdup(filename);
+ adj_filename[strlen(adj_filename) - 4] = '\0';
+ filename = adj_filename;
+ }
+
+ f = get_format(filename);
+ if (!f || (!sig_filename && !(ctx.repo->snapshots & cgit_snapshot_format_bit(f)))) {
+ cgit_print_error_page(400, "Bad request",
+ "Unsupported snapshot format: %s", filename);
+ return;
+ }
+
+ if (!hex && dwim) {
+ hex = get_ref_from_filename(ctx.repo, filename, f);
+ if (hex == NULL) {
+ cgit_print_error_page(404, "Not found", "Not found");
+ return;
+ }
+ prefix = xstrdup(filename);
+ prefix[strlen(filename) - strlen(f->suffix)] = '\0';
+ }
+
+ if (!hex)
+ hex = head;
+
+ if (!prefix)
+ prefix = xstrdup(cgit_snapshot_prefix(ctx.repo));
+
+ if (sig_filename)
+ write_sig(f, hex, filename, sig_filename);
+ else
+ make_snapshot(f, hex, prefix, filename);
+
+ free(prefix);
+ free(adj_filename);
+}
diff --git a/cgit/ui-snapshot.h b/cgit/ui-snapshot.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-snapshot.h
@@ -0,0 +1,11 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_SNAPSHOT_H
+#define UI_SNAPSHOT_H
+
+extern void cgit_print_snapshot(const char *head, const char *hex,
+ const char *filename, int dwim);
+
+#endif /* UI_SNAPSHOT_H */
diff --git a/cgit/ui-ssdiff.c b/cgit/ui-ssdiff.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-ssdiff.c
@@ -0,0 +1,424 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#include "cgit.h"
+#include "ui-ssdiff.h"
+#include "html.h"
+#include "ui-shared.h"
+#include "ui-diff.h"
+
+extern int use_ssdiff;
+
+static int current_old_line, current_new_line;
+static int **L = NULL;
+
+struct deferred_lines {
+ int line_no;
+ char *line;
+ struct deferred_lines *next;
+};
+
+static struct deferred_lines *deferred_old, *deferred_old_last;
+static struct deferred_lines *deferred_new, *deferred_new_last;
+
+static void create_or_reset_lcs_table(void)
+{
+ int i;
+
+ if (L != NULL) {
+ memset(*L, 0, sizeof(int) * MAX_SSDIFF_SIZE);
+ return;
+ }
+
+ // xcalloc will die if we ran out of memory;
+ // not very helpful for debugging
+ L = (int**)xcalloc(MAX_SSDIFF_M, sizeof(int *));
+ *L = (int*)xcalloc(MAX_SSDIFF_SIZE, sizeof(int));
+
+ for (i = 1; i < MAX_SSDIFF_M; i++) {
+ L[i] = *L + i * MAX_SSDIFF_N;
+ }
+}
+
+static char *longest_common_subsequence(char *A, char *B)
+{
+ int i, j, ri;
+ int m = strlen(A);
+ int n = strlen(B);
+ int tmp1, tmp2;
+ int lcs_length;
+ char *result;
+
+ // We bail if the lines are too long
+ if (m >= MAX_SSDIFF_M || n >= MAX_SSDIFF_N)
+ return NULL;
+
+ create_or_reset_lcs_table();
+
+ for (i = m; i >= 0; i--) {
+ for (j = n; j >= 0; j--) {
+ if (A[i] == '\0' || B[j] == '\0') {
+ L[i][j] = 0;
+ } else if (A[i] == B[j]) {
+ L[i][j] = 1 + L[i + 1][j + 1];
+ } else {
+ tmp1 = L[i + 1][j];
+ tmp2 = L[i][j + 1];
+ L[i][j] = (tmp1 > tmp2 ? tmp1 : tmp2);
+ }
+ }
+ }
+
+ lcs_length = L[0][0];
+ result = xmalloc(lcs_length + 2);
+ memset(result, 0, sizeof(*result) * (lcs_length + 2));
+
+ ri = 0;
+ i = 0;
+ j = 0;
+ while (i < m && j < n) {
+ if (A[i] == B[j]) {
+ result[ri] = A[i];
+ ri += 1;
+ i += 1;
+ j += 1;
+ } else if (L[i + 1][j] >= L[i][j + 1]) {
+ i += 1;
+ } else {
+ j += 1;
+ }
+ }
+
+ return result;
+}
+
+static int line_from_hunk(char *line, char type)
+{
+ char *buf1, *buf2;
+ int len, res;
+
+ buf1 = strchr(line, type);
+ if (buf1 == NULL)
+ return 0;
+ buf1 += 1;
+ buf2 = strchr(buf1, ',');
+ if (buf2 == NULL)
+ return 0;
+ len = buf2 - buf1;
+ buf2 = xmalloc(len + 1);
+ strlcpy(buf2, buf1, len + 1);
+ res = atoi(buf2);
+ free(buf2);
+ return res;
+}
+
+static char *replace_tabs(char *line)
+{
+ char *prev_buf = line;
+ char *cur_buf;
+ size_t linelen = strlen(line);
+ int n_tabs = 0;
+ int i;
+ char *result;
+ size_t result_len;
+
+ if (linelen == 0) {
+ result = xmalloc(1);
+ result[0] = '\0';
+ return result;
+ }
+
+ for (i = 0; i < linelen; i++) {
+ if (line[i] == '\t')
+ n_tabs += 1;
+ }
+ result_len = linelen + n_tabs * 8;
+ result = xmalloc(result_len + 1);
+ result[0] = '\0';
+
+ for (;;) {
+ cur_buf = strchr(prev_buf, '\t');
+ if (!cur_buf) {
+ linelen = strlen(result);
+ strlcpy(&result[linelen], prev_buf, result_len - linelen + 1);
+ break;
+ } else {
+ linelen = strlen(result);
+ strlcpy(&result[linelen], prev_buf, cur_buf - prev_buf + 1);
+ linelen = strlen(result);
+ memset(&result[linelen], ' ', 8 - (linelen % 8));
+ result[linelen + 8 - (linelen % 8)] = '\0';
+ }
+ prev_buf = cur_buf + 1;
+ }
+ return result;
+}
+
+static int calc_deferred_lines(struct deferred_lines *start)
+{
+ struct deferred_lines *item = start;
+ int result = 0;
+ while (item) {
+ result += 1;
+ item = item->next;
+ }
+ return result;
+}
+
+static void deferred_old_add(char *line, int line_no)
+{
+ struct deferred_lines *item = xmalloc(sizeof(struct deferred_lines));
+ item->line = xstrdup(line);
+ item->line_no = line_no;
+ item->next = NULL;
+ if (deferred_old) {
+ deferred_old_last->next = item;
+ deferred_old_last = item;
+ } else {
+ deferred_old = deferred_old_last = item;
+ }
+}
+
+static void deferred_new_add(char *line, int line_no)
+{
+ struct deferred_lines *item = xmalloc(sizeof(struct deferred_lines));
+ item->line = xstrdup(line);
+ item->line_no = line_no;
+ item->next = NULL;
+ if (deferred_new) {
+ deferred_new_last->next = item;
+ deferred_new_last = item;
+ } else {
+ deferred_new = deferred_new_last = item;
+ }
+}
+
+static void print_part_with_lcs(char *class, char *line, char *lcs)
+{
+ int line_len = strlen(line);
+ int i, j;
+ char c[2] = " ";
+ int same = 1;
+
+ j = 0;
+ for (i = 0; i < line_len; i++) {
+ c[0] = line[i];
+ if (same) {
+ if (line[i] == lcs[j])
+ j += 1;
+ else {
+ same = 0;
+ htmlf("<span class='%s'>", class);
+ }
+ } else if (line[i] == lcs[j]) {
+ same = 1;
+ html("</span>");
+ j += 1;
+ }
+ html_txt(c);
+ }
+ if (!same)
+ html("</span>");
+}
+
+static void print_ssdiff_line(char *class,
+ int old_line_no,
+ char *old_line,
+ int new_line_no,
+ char *new_line, int individual_chars)
+{
+ char *lcs = NULL;
+
+ if (old_line)
+ old_line = replace_tabs(old_line + 1);
+ if (new_line)
+ new_line = replace_tabs(new_line + 1);
+ if (individual_chars && old_line && new_line)
+ lcs = longest_common_subsequence(old_line, new_line);
+ html("<tr>\n");
+ if (old_line_no > 0) {
+ struct diff_filespec *old_file = cgit_get_current_old_file();
+ char *lineno_str = fmt("n%d", old_line_no);
+ char *id_str = fmt("id=%s#%s", is_null_oid(&old_file->oid)?"HEAD":oid_to_hex(old_rev_oid), lineno_str);
+ char *fileurl = cgit_fileurl(ctx.repo->url, "tree", old_file->path, id_str);
+ html("<td class='lineno'><a href='");
+ html(fileurl);
+ htmlf("'>%s</a>", lineno_str + 1);
+ html("</td>");
+ htmlf("<td class='%s'>", class);
+ free(fileurl);
+ } else if (old_line)
+ htmlf("<td class='lineno'></td><td class='%s'>", class);
+ else
+ htmlf("<td class='lineno'></td><td class='%s_dark'>", class);
+ if (old_line) {
+ if (lcs)
+ print_part_with_lcs("del", old_line, lcs);
+ else
+ html_txt(old_line);
+ }
+
+ html("</td>\n");
+ if (new_line_no > 0) {
+ struct diff_filespec *new_file = cgit_get_current_new_file();
+ char *lineno_str = fmt("n%d", new_line_no);
+ char *id_str = fmt("id=%s#%s", is_null_oid(&new_file->oid)?"HEAD":oid_to_hex(new_rev_oid), lineno_str);
+ char *fileurl = cgit_fileurl(ctx.repo->url, "tree", new_file->path, id_str);
+ html("<td class='lineno'><a href='");
+ html(fileurl);
+ htmlf("'>%s</a>", lineno_str + 1);
+ html("</td>");
+ htmlf("<td class='%s'>", class);
+ free(fileurl);
+ } else if (new_line)
+ htmlf("<td class='lineno'></td><td class='%s'>", class);
+ else
+ htmlf("<td class='lineno'></td><td class='%s_dark'>", class);
+ if (new_line) {
+ if (lcs)
+ print_part_with_lcs("add", new_line, lcs);
+ else
+ html_txt(new_line);
+ }
+
+ html("</td></tr>");
+ if (lcs)
+ free(lcs);
+ if (new_line)
+ free(new_line);
+ if (old_line)
+ free(old_line);
+}
+
+static void print_deferred_old_lines(void)
+{
+ struct deferred_lines *iter_old, *tmp;
+ iter_old = deferred_old;
+ while (iter_old) {
+ print_ssdiff_line("del", iter_old->line_no,
+ iter_old->line, -1, NULL, 0);
+ tmp = iter_old->next;
+ free(iter_old);
+ iter_old = tmp;
+ }
+}
+
+static void print_deferred_new_lines(void)
+{
+ struct deferred_lines *iter_new, *tmp;
+ iter_new = deferred_new;
+ while (iter_new) {
+ print_ssdiff_line("add", -1, NULL,
+ iter_new->line_no, iter_new->line, 0);
+ tmp = iter_new->next;
+ free(iter_new);
+ iter_new = tmp;
+ }
+}
+
+static void print_deferred_changed_lines(void)
+{
+ struct deferred_lines *iter_old, *iter_new, *tmp;
+ int n_old_lines = calc_deferred_lines(deferred_old);
+ int n_new_lines = calc_deferred_lines(deferred_new);
+ int individual_chars = (n_old_lines == n_new_lines ? 1 : 0);
+
+ iter_old = deferred_old;
+ iter_new = deferred_new;
+ while (iter_old || iter_new) {
+ if (iter_old && iter_new)
+ print_ssdiff_line("changed", iter_old->line_no,
+ iter_old->line,
+ iter_new->line_no, iter_new->line,
+ individual_chars);
+ else if (iter_old)
+ print_ssdiff_line("changed", iter_old->line_no,
+ iter_old->line, -1, NULL, 0);
+ else if (iter_new)
+ print_ssdiff_line("changed", -1, NULL,
+ iter_new->line_no, iter_new->line, 0);
+ if (iter_old) {
+ tmp = iter_old->next;
+ free(iter_old);
+ iter_old = tmp;
+ }
+
+ if (iter_new) {
+ tmp = iter_new->next;
+ free(iter_new);
+ iter_new = tmp;
+ }
+ }
+}
+
+void cgit_ssdiff_print_deferred_lines(void)
+{
+ if (!deferred_old && !deferred_new)
+ return;
+ if (deferred_old && !deferred_new)
+ print_deferred_old_lines();
+ else if (!deferred_old && deferred_new)
+ print_deferred_new_lines();
+ else
+ print_deferred_changed_lines();
+ deferred_old = deferred_old_last = NULL;
+ deferred_new = deferred_new_last = NULL;
+}
+
+/*
+ * print a single line returned from xdiff
+ */
+void cgit_ssdiff_line_cb(char *line, int len)
+{
+ char c = line[len - 1];
+ line[len - 1] = '\0';
+ if (line[0] == '@') {
+ current_old_line = line_from_hunk(line, '-');
+ current_new_line = line_from_hunk(line, '+');
+ }
+
+ if (line[0] == ' ') {
+ if (deferred_old || deferred_new)
+ cgit_ssdiff_print_deferred_lines();
+ print_ssdiff_line("ctx", current_old_line, line,
+ current_new_line, line, 0);
+ current_old_line += 1;
+ current_new_line += 1;
+ } else if (line[0] == '+') {
+ deferred_new_add(line, current_new_line);
+ current_new_line += 1;
+ } else if (line[0] == '-') {
+ deferred_old_add(line, current_old_line);
+ current_old_line += 1;
+ } else if (line[0] == '@') {
+ html("<tr><td colspan='4' class='hunk'>");
+ html_txt(line);
+ html("</td></tr>");
+ } else {
+ html("<tr><td colspan='4' class='ctx'>");
+ html_txt(line);
+ html("</td></tr>");
+ }
+ line[len - 1] = c;
+}
+
+void cgit_ssdiff_header_begin(void)
+{
+ current_old_line = -1;
+ current_new_line = -1;
+ html("<tr><td class='space' colspan='4'><div></div></td></tr>");
+ html("<tr><td class='head' colspan='4'>");
+}
+
+void cgit_ssdiff_header_end(void)
+{
+ html("</td></tr>");
+}
+
+void cgit_ssdiff_footer(void)
+{
+ if (deferred_old || deferred_new)
+ cgit_ssdiff_print_deferred_lines();
+ html("<tr><td class='foot' colspan='4'></td></tr>");
+}
diff --git a/cgit/ui-ssdiff.h b/cgit/ui-ssdiff.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-ssdiff.h
@@ -0,0 +1,29 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_SSDIFF_H
+#define UI_SSDIFF_H
+
+/*
+ * ssdiff line limits
+ */
+#ifndef MAX_SSDIFF_M
+#define MAX_SSDIFF_M 128
+#endif
+
+#ifndef MAX_SSDIFF_N
+#define MAX_SSDIFF_N 128
+#endif
+#define MAX_SSDIFF_SIZE ((MAX_SSDIFF_M) * (MAX_SSDIFF_N))
+
+extern void cgit_ssdiff_print_deferred_lines(void);
+
+extern void cgit_ssdiff_line_cb(char *line, int len);
+
+extern void cgit_ssdiff_header_begin(void);
+extern void cgit_ssdiff_header_end(void);
+
+extern void cgit_ssdiff_footer(void);
+
+#endif /* UI_SSDIFF_H */
diff --git a/cgit/ui-stats.c b/cgit/ui-stats.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-stats.c
@@ -0,0 +1,439 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-stats.c: generate stats view
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-stats.h"
+#include "html.h"
+#include "ui-shared.h"
+
+struct authorstat {
+ long total;
+ struct string_list list;
+};
+
+#define DAY_SECS (60 * 60 * 24)
+#define WEEK_SECS (DAY_SECS * 7)
+
+static void trunc_week(struct tm *tm)
+{
+ time_t t = timegm(tm);
+ t -= ((tm->tm_wday + 6) % 7) * DAY_SECS;
+ gmtime_r(&t, tm);
+}
+
+static void dec_week(struct tm *tm)
+{
+ time_t t = timegm(tm);
+ t -= WEEK_SECS;
+ gmtime_r(&t, tm);
+}
+
+static void inc_week(struct tm *tm)
+{
+ time_t t = timegm(tm);
+ t += WEEK_SECS;
+ gmtime_r(&t, tm);
+}
+
+static char *pretty_week(struct tm *tm)
+{
+ static char buf[10];
+
+ strftime(buf, sizeof(buf), "W%V %G", tm);
+ return buf;
+}
+
+static void trunc_month(struct tm *tm)
+{
+ tm->tm_mday = 1;
+}
+
+static void dec_month(struct tm *tm)
+{
+ tm->tm_mon--;
+ if (tm->tm_mon < 0) {
+ tm->tm_year--;
+ tm->tm_mon = 11;
+ }
+}
+
+static void inc_month(struct tm *tm)
+{
+ tm->tm_mon++;
+ if (tm->tm_mon > 11) {
+ tm->tm_year++;
+ tm->tm_mon = 0;
+ }
+}
+
+static char *pretty_month(struct tm *tm)
+{
+ static const char *months[] = {
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
+ };
+ return fmt("%s %d", months[tm->tm_mon], tm->tm_year + 1900);
+}
+
+static void trunc_quarter(struct tm *tm)
+{
+ trunc_month(tm);
+ while (tm->tm_mon % 3 != 0)
+ dec_month(tm);
+}
+
+static void dec_quarter(struct tm *tm)
+{
+ dec_month(tm);
+ dec_month(tm);
+ dec_month(tm);
+}
+
+static void inc_quarter(struct tm *tm)
+{
+ inc_month(tm);
+ inc_month(tm);
+ inc_month(tm);
+}
+
+static char *pretty_quarter(struct tm *tm)
+{
+ return fmt("Q%d %d", tm->tm_mon / 3 + 1, tm->tm_year + 1900);
+}
+
+static void trunc_year(struct tm *tm)
+{
+ trunc_month(tm);
+ tm->tm_mon = 0;
+}
+
+static void dec_year(struct tm *tm)
+{
+ tm->tm_year--;
+}
+
+static void inc_year(struct tm *tm)
+{
+ tm->tm_year++;
+}
+
+static char *pretty_year(struct tm *tm)
+{
+ return fmt("%d", tm->tm_year + 1900);
+}
+
+static const struct cgit_period periods[] = {
+ {'w', "week", 12, 4, trunc_week, dec_week, inc_week, pretty_week},
+ {'m', "month", 12, 4, trunc_month, dec_month, inc_month, pretty_month},
+ {'q', "quarter", 12, 4, trunc_quarter, dec_quarter, inc_quarter, pretty_quarter},
+ {'y', "year", 12, 4, trunc_year, dec_year, inc_year, pretty_year},
+};
+
+/* Given a period code or name, return a period index (1, 2, 3 or 4)
+ * and update the period pointer to the correcsponding struct.
+ * If no matching code is found, return 0.
+ */
+int cgit_find_stats_period(const char *expr, const struct cgit_period **period)
+{
+ int i;
+ char code = '\0';
+
+ if (!expr)
+ return 0;
+
+ if (strlen(expr) == 1)
+ code = expr[0];
+
+ for (i = 0; i < sizeof(periods) / sizeof(periods[0]); i++)
+ if (periods[i].code == code || !strcmp(periods[i].name, expr)) {
+ if (period)
+ *period = &periods[i];
+ return i + 1;
+ }
+ return 0;
+}
+
+const char *cgit_find_stats_periodname(int idx)
+{
+ if (idx > 0 && idx < 4)
+ return periods[idx - 1].name;
+ else
+ return "";
+}
+
+static void add_commit(struct string_list *authors, struct commit *commit,
+ const struct cgit_period *period)
+{
+ struct commitinfo *info;
+ struct string_list_item *author, *item;
+ struct authorstat *authorstat;
+ struct string_list *items;
+ char *tmp;
+ struct tm date;
+ time_t t;
+ uintptr_t *counter;
+
+ info = cgit_parse_commit(commit);
+ tmp = xstrdup(info->author);
+ author = string_list_insert(authors, tmp);
+ if (!author->util)
+ author->util = xcalloc(1, sizeof(struct authorstat));
+ else
+ free(tmp);
+ authorstat = author->util;
+ items = &authorstat->list;
+ t = info->committer_date;
+ gmtime_r(&t, &date);
+ period->trunc(&date);
+ tmp = xstrdup(period->pretty(&date));
+ item = string_list_insert(items, tmp);
+ counter = (uintptr_t *)&item->util;
+ if (*counter)
+ free(tmp);
+ (*counter)++;
+
+ authorstat->total++;
+ cgit_free_commitinfo(info);
+}
+
+static int cmp_total_commits(const void *a1, const void *a2)
+{
+ const struct string_list_item *i1 = a1;
+ const struct string_list_item *i2 = a2;
+ const struct authorstat *auth1 = i1->util;
+ const struct authorstat *auth2 = i2->util;
+
+ return auth2->total - auth1->total;
+}
+
+/* Walk the commit DAG and collect number of commits per author per
+ * timeperiod into a nested string_list collection.
+ */
+static struct string_list collect_stats(const struct cgit_period *period)
+{
+ struct string_list authors;
+ struct rev_info rev;
+ struct commit *commit;
+ const char *argv[] = {NULL, ctx.qry.head, NULL, NULL, NULL, NULL};
+ int argc = 3;
+ time_t now;
+ long i;
+ struct tm tm;
+ char tmp[11];
+
+ time(&now);
+ gmtime_r(&now, &tm);
+ period->trunc(&tm);
+ for (i = 1; i < period->count; i++)
+ period->dec(&tm);
+ strftime(tmp, sizeof(tmp), "%Y-%m-%d", &tm);
+ argv[2] = xstrdup(fmt("--since=%s", tmp));
+ if (ctx.qry.path) {
+ argv[3] = "--";
+ argv[4] = ctx.qry.path;
+ argc += 2;
+ }
+ repo_init_revisions(the_repository, &rev, NULL);
+ rev.abbrev = DEFAULT_ABBREV;
+ rev.commit_format = CMIT_FMT_DEFAULT;
+ rev.max_parents = 1;
+ rev.verbose_header = 1;
+ rev.show_root_diff = 0;
+ setup_revisions(argc, argv, &rev, NULL);
+ prepare_revision_walk(&rev);
+ memset(&authors, 0, sizeof(authors));
+ while ((commit = get_revision(&rev)) != NULL) {
+ add_commit(&authors, commit, period);
+ release_commit_memory(the_repository->parsed_objects, commit);
+ commit->parents = NULL;
+ }
+ return authors;
+}
+
+static void print_combined_authorrow(struct string_list *authors, int from,
+ int to, const char *name,
+ const char *leftclass,
+ const char *centerclass,
+ const char *rightclass,
+ const struct cgit_period *period)
+{
+ struct string_list_item *author;
+ struct authorstat *authorstat;
+ struct string_list *items;
+ struct string_list_item *date;
+ time_t now;
+ long i, j, total, subtotal;
+ struct tm tm;
+ char *tmp;
+
+ time(&now);
+ gmtime_r(&now, &tm);
+ period->trunc(&tm);
+ for (i = 1; i < period->count; i++)
+ period->dec(&tm);
+
+ total = 0;
+ htmlf("<tr><td class='%s'>%s</td>", leftclass,
+ fmt(name, to - from + 1));
+ for (j = 0; j < period->count; j++) {
+ tmp = period->pretty(&tm);
+ period->inc(&tm);
+ subtotal = 0;
+ for (i = from; i <= to; i++) {
+ author = &authors->items[i];
+ authorstat = author->util;
+ items = &authorstat->list;
+ date = string_list_lookup(items, tmp);
+ if (date)
+ subtotal += (uintptr_t)date->util;
+ }
+ htmlf("<td class='%s'>%ld</td>", centerclass, subtotal);
+ total += subtotal;
+ }
+ htmlf("<td class='%s'>%ld</td></tr>", rightclass, total);
+}
+
+static void print_authors(struct string_list *authors, int top,
+ const struct cgit_period *period)
+{
+ struct string_list_item *author;
+ struct authorstat *authorstat;
+ struct string_list *items;
+ struct string_list_item *date;
+ time_t now;
+ long i, j, total;
+ struct tm tm;
+ char *tmp;
+
+ time(&now);
+ gmtime_r(&now, &tm);
+ period->trunc(&tm);
+ for (i = 1; i < period->count; i++)
+ period->dec(&tm);
+
+ html("<table class='stats'><tr><th>Author</th>");
+ for (j = 0; j < period->count; j++) {
+ tmp = period->pretty(&tm);
+ htmlf("<th>%s</th>", tmp);
+ period->inc(&tm);
+ }
+ html("<th>Total</th></tr>\n");
+
+ if (top <= 0 || top > authors->nr)
+ top = authors->nr;
+
+ for (i = 0; i < top; i++) {
+ author = &authors->items[i];
+ html("<tr><td class='left'>");
+ html_txt(author->string);
+ html("</td>");
+ authorstat = author->util;
+ items = &authorstat->list;
+ total = 0;
+ for (j = 0; j < period->count; j++)
+ period->dec(&tm);
+ for (j = 0; j < period->count; j++) {
+ tmp = period->pretty(&tm);
+ period->inc(&tm);
+ date = string_list_lookup(items, tmp);
+ if (!date)
+ html("<td>0</td>");
+ else {
+ htmlf("<td>%lu</td>", (uintptr_t)date->util);
+ total += (uintptr_t)date->util;
+ }
+ }
+ htmlf("<td class='sum'>%ld</td></tr>", total);
+ }
+
+ if (top < authors->nr)
+ print_combined_authorrow(authors, top, authors->nr - 1,
+ "Others (%ld)", "left", "", "sum", period);
+
+ print_combined_authorrow(authors, 0, authors->nr - 1, "Total",
+ "total", "sum", "sum", period);
+ html("</table>");
+}
+
+/* Create a sorted string_list with one entry per author. The util-field
+ * for each author is another string_list which is used to calculate the
+ * number of commits per time-interval.
+ */
+void cgit_show_stats(void)
+{
+ struct string_list authors;
+ const struct cgit_period *period;
+ int top, i;
+ const char *code = "w";
+
+ if (ctx.qry.period)
+ code = ctx.qry.period;
+
+ i = cgit_find_stats_period(code, &period);
+ if (!i) {
+ cgit_print_error_page(404, "Not found",
+ "Unknown statistics type: %c", code[0]);
+ return;
+ }
+ if (i > ctx.repo->max_stats) {
+ cgit_print_error_page(400, "Bad request",
+ "Statistics type disabled: %s", period->name);
+ return;
+ }
+ authors = collect_stats(period);
+ qsort(authors.items, authors.nr, sizeof(struct string_list_item),
+ cmp_total_commits);
+
+ top = ctx.qry.ofs;
+ if (!top)
+ top = 10;
+
+ cgit_print_layout_start();
+ html("<div class='cgit-panel'>");
+ html("<b>stat options</b>");
+ html("<form method='get'>");
+ cgit_add_hidden_formfields(1, 0, "stats");
+ html("<table><tr><td colspan='2'/></tr>");
+ if (ctx.repo->max_stats > 1) {
+ html("<tr><td class='label'>Period:</td>");
+ html("<td class='ctrl'><select name='period' onchange='this.form.submit();'>");
+ for (i = 0; i < ctx.repo->max_stats; i++)
+ html_option(fmt("%c", periods[i].code),
+ periods[i].name, fmt("%c", period->code));
+ html("</select></td></tr>");
+ }
+ html("<tr><td class='label'>Authors:</td>");
+ html("<td class='ctrl'><select name='ofs' onchange='this.form.submit();'>");
+ html_intoption(10, "10", top);
+ html_intoption(25, "25", top);
+ html_intoption(50, "50", top);
+ html_intoption(100, "100", top);
+ html_intoption(-1, "all", top);
+ html("</select></td></tr>");
+ html("<tr><td/><td class='ctrl'>");
+ html("<noscript><input type='submit' value='Reload'/></noscript>");
+ html("</td></tr></table>");
+ html("</form>");
+ html("</div>");
+ htmlf("<h2>Commits per author per %s", period->name);
+ if (ctx.qry.path) {
+ html(" (path '");
+ html_txt(ctx.qry.path);
+ html("')");
+ }
+ html("</h2>");
+ print_authors(&authors, top, period);
+ cgit_print_layout_end();
+}
+
diff --git a/cgit/ui-stats.h b/cgit/ui-stats.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-stats.h
@@ -0,0 +1,32 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_STATS_H
+#define UI_STATS_H
+
+#include "cgit.h"
+
+struct cgit_period {
+ const char code;
+ const char *name;
+ int max_periods;
+ int count;
+
+ /* Convert a tm value to the first day in the period */
+ void (*trunc)(struct tm *tm);
+
+ /* Update tm value to start of next/previous period */
+ void (*dec)(struct tm *tm);
+ void (*inc)(struct tm *tm);
+
+ /* Pretty-print a tm value */
+ char *(*pretty)(struct tm *tm);
+};
+
+extern int cgit_find_stats_period(const char *expr, const struct cgit_period **period);
+extern const char *cgit_find_stats_periodname(int idx);
+
+extern void cgit_show_stats(void);
+
+#endif /* UI_STATS_H */
diff --git a/cgit/ui-summary.c b/cgit/ui-summary.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-summary.c
@@ -0,0 +1,148 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-summary.c: functions for generating repo summary page
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#include "cgit.h"
+#include "ui-summary.h"
+#include "html.h"
+#include "ui-blob.h"
+#include "ui-log.h"
+#include "ui-plain.h"
+#include "ui-refs.h"
+#include "ui-shared.h"
+
+static int urls;
+
+static void print_url(const char *url)
+{
+ int columns = 3;
+
+ if (ctx.repo->enable_log_filecount)
+ columns++;
+ if (ctx.repo->enable_log_linecount)
+ columns++;
+
+ if (urls++ == 0) {
+ htmlf("<tr class='nohover'><td colspan='%d'>&nbsp;</td></tr>", columns);
+ htmlf("<tr class='nohover'><th class='left' colspan='%d'>Clone</th></tr>\n", columns);
+ }
+
+ htmlf("<tr><td colspan='%d'><a rel='vcs-git' href='", columns);
+ html_url_path(url);
+ html("' title='");
+ html_attr(ctx.repo->name);
+ html(" Git repository'>");
+ html_txt(url);
+ html("</a></td></tr>\n");
+}
+
+void cgit_print_summary(void)
+{
+ int columns = 3;
+
+ if (ctx.repo->enable_log_filecount)
+ columns++;
+ if (ctx.repo->enable_log_linecount)
+ columns++;
+
+ cgit_print_layout_start();
+ html("<table summary='repository info' class='list nowrap'>");
+ cgit_print_branches(ctx.cfg.summary_branches);
+ htmlf("<tr class='nohover'><td colspan='%d'>&nbsp;</td></tr>", columns);
+ cgit_print_tags(ctx.cfg.summary_tags);
+ if (ctx.cfg.summary_log > 0) {
+ htmlf("<tr class='nohover'><td colspan='%d'>&nbsp;</td></tr>", columns);
+ cgit_print_log(ctx.qry.head, 0, ctx.cfg.summary_log, NULL,
+ NULL, NULL, 0, 0, 0);
+ }
+ urls = 0;
+ cgit_add_clone_urls(print_url);
+ html("</table>");
+ cgit_print_layout_end();
+}
+
+/* The caller must free the return value. */
+static char* append_readme_path(const char *filename, const char *ref, const char *path)
+{
+ char *file, *base_dir, *full_path, *resolved_base = NULL, *resolved_full = NULL;
+ /* If a subpath is specified for the about page, make it relative
+ * to the directory containing the configured readme. */
+
+ file = xstrdup(filename);
+ base_dir = dirname(file);
+ if (!strcmp(base_dir, ".") || !strcmp(base_dir, "..")) {
+ if (!ref) {
+ free(file);
+ return NULL;
+ }
+ full_path = xstrdup(path);
+ } else
+ full_path = fmtalloc("%s/%s", base_dir, path);
+
+ if (!ref) {
+ resolved_base = realpath(base_dir, NULL);
+ resolved_full = realpath(full_path, NULL);
+ if (!resolved_base || !resolved_full || !starts_with(resolved_full, resolved_base)) {
+ free(full_path);
+ full_path = NULL;
+ }
+ }
+
+ free(file);
+ free(resolved_base);
+ free(resolved_full);
+
+ return full_path;
+}
+
+void cgit_print_repo_readme(const char *path)
+{
+ char *filename, *ref, *mimetype;
+ int free_filename = 0;
+
+ mimetype = get_mimetype_for_filename(path);
+ if (mimetype && (!strncmp(mimetype, "image/", 6) || !strncmp(mimetype, "video/", 6))) {
+ ctx.page.mimetype = mimetype;
+ ctx.page.charset = NULL;
+ cgit_print_plain();
+ free(mimetype);
+ return;
+ }
+ free(mimetype);
+
+ cgit_print_layout_start();
+ if (ctx.repo->readme.nr == 0)
+ goto done;
+
+ filename = ctx.repo->readme.items[0].string;
+ ref = ctx.repo->readme.items[0].util;
+
+ if (path) {
+ free_filename = 1;
+ filename = append_readme_path(filename, ref, path);
+ if (!filename)
+ goto done;
+ }
+
+ /* Print the calculated readme from the git repo or filesystem as text. */
+ html("<div id='summary'><pre>");
+ if (ref)
+ cgit_print_file(filename, ref, 1);
+ else
+ html_include_text(filename);
+
+ html("</pre></div>");
+ if (free_filename)
+ free(filename);
+
+done:
+ cgit_print_layout_end();
+}
diff --git a/cgit/ui-summary.h b/cgit/ui-summary.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-summary.h
@@ -0,0 +1,11 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_SUMMARY_H
+#define UI_SUMMARY_H
+
+extern void cgit_print_summary(void);
+extern void cgit_print_repo_readme(const char *path);
+
+#endif /* UI_SUMMARY_H */
diff --git a/cgit/ui-tag.c b/cgit/ui-tag.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-tag.c
@@ -0,0 +1,124 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-tag.c: display a tag
+ *
+ * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-tag.h"
+#include "html.h"
+#include "ui-shared.h"
+
+static void print_tag_content(char *buf)
+{
+ char *p;
+
+ if (!buf)
+ return;
+
+ html("<div class='commit-subject'>");
+ p = strchr(buf, '\n');
+ if (p)
+ *p = '\0';
+ html_txt(buf);
+ html("</div>");
+ if (p) {
+ html("<div class='commit-msg'>");
+ html_txt(++p);
+ html("</div>");
+ }
+}
+
+static void print_download_links(char *revname)
+{
+ html("<tr><th>download</th><td class='oid'>");
+ cgit_print_snapshot_links(ctx.repo, revname, "<br/>");
+ html("</td></tr>");
+}
+
+void cgit_print_tag(char *revname)
+{
+ struct strbuf fullref = STRBUF_INIT;
+ struct object_id oid;
+ struct object *obj;
+
+ if (!revname)
+ revname = ctx.qry.head;
+
+ strbuf_addf(&fullref, "refs/tags/%s", revname);
+ if (repo_get_oid(the_repository, fullref.buf, &oid)) {
+ cgit_print_error_page(404, "Not found",
+ "Bad tag reference: %s", revname);
+ goto cleanup;
+ }
+ obj = parse_object(the_repository, &oid);
+ if (!obj) {
+ cgit_print_error_page(500, "Internal server error",
+ "Bad object id: %s", oid_to_hex(&oid));
+ goto cleanup;
+ }
+ if (obj->type == OBJ_TAG) {
+ struct tag *tag;
+ struct taginfo *info;
+
+ tag = lookup_tag(the_repository, &oid);
+ if (!tag || parse_tag(the_repository, tag) || !(info = cgit_parse_tag(tag))) {
+ cgit_print_error_page(500, "Internal server error",
+ "Bad tag object: %s", revname);
+ goto cleanup;
+ }
+ cgit_print_layout_start();
+ html("<table class='commit-info'>\n");
+ html("<tr><td>tag name</td><td>");
+ html_txt(revname);
+ htmlf(" (%s)</td></tr>\n", oid_to_hex(&oid));
+ if (info->tagger_date > 0) {
+ html("<tr><td>tag date</td><td>");
+ html_txt(show_date(info->tagger_date, info->tagger_tz,
+ cgit_date_mode(DATE_ISO8601)));
+ html("</td></tr>\n");
+ }
+ if (info->tagger) {
+ html("<tr><td>tagged by</td><td>");
+ html_txt(info->tagger);
+ if (info->tagger_email && !ctx.cfg.noplainemail) {
+ html(" ");
+ html_txt(info->tagger_email);
+ }
+ html("</td></tr>\n");
+ }
+ html("<tr><td>tagged object</td><td class='oid'>");
+ cgit_object_link(tag->tagged);
+ html("</td></tr>\n");
+ if (ctx.repo->snapshots)
+ print_download_links(revname);
+ html("</table>\n");
+ print_tag_content(info->msg);
+ cgit_print_layout_end();
+ cgit_free_taginfo(info);
+ } else {
+ cgit_print_layout_start();
+ html("<table class='commit-info'>\n");
+ html("<tr><td>tag name</td><td>");
+ html_txt(revname);
+ html("</td></tr>\n");
+ html("<tr><td>tagged object</td><td class='oid'>");
+ cgit_object_link(obj);
+ html("</td></tr>\n");
+ if (ctx.repo->snapshots)
+ print_download_links(revname);
+ html("</table>\n");
+ cgit_print_layout_end();
+ }
+
+cleanup:
+ strbuf_release(&fullref);
+}
diff --git a/cgit/ui-tag.h b/cgit/ui-tag.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-tag.h
@@ -0,0 +1,10 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_TAG_H
+#define UI_TAG_H
+
+extern void cgit_print_tag(char *revname);
+
+#endif /* UI_TAG_H */
diff --git a/cgit/ui-tree.c b/cgit/ui-tree.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-tree.c
@@ -0,0 +1,400 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+/* ui-tree.c: functions for tree output
+ *
+ * Copyright (C) 2006-2017 cgit Development Team <cgit@lists.zx2c4.com>
+ *
+ * Licensed under GNU General Public License v2
+ * (see COPYING for full license text)
+ */
+
+ #define USE_THE_REPOSITORY_VARIABLE
+
+#include "cgit.h"
+#include "ui-tree.h"
+#include "html.h"
+#include "ui-shared.h"
+
+struct walk_tree_context {
+ char *curr_rev;
+ char *match_path;
+ int state;
+};
+
+static void print_text_buffer(char *buf, unsigned long size)
+{
+ unsigned long lineno, idx;
+ const char *numberfmt = "<a id='n%1$d' href='#n%1$d'>%1$d</a>\n";
+
+ html("<table summary='blob content' class='blob'>\n");
+
+ if (ctx.cfg.enable_tree_linenumbers) {
+ html("<tr><td class='linenumbers'><pre>");
+ idx = 0;
+ lineno = 0;
+
+ if (size) {
+ htmlf(numberfmt, ++lineno);
+ while (idx < size - 1) { // skip absolute last newline
+ if (buf[idx] == '\n')
+ htmlf(numberfmt, ++lineno);
+ idx++;
+ }
+ }
+ html("</pre></td>\n");
+ }
+ else {
+ html("<tr>\n");
+ }
+
+ html("<td class='lines'><pre><code>");
+ html_txt(buf);
+ html("</code></pre></td></tr></table>\n");
+}
+
+#define ROWLEN 32
+
+static void print_binary_buffer(char *buf, unsigned long size)
+{
+ unsigned long ofs, idx;
+ static char ascii[ROWLEN + 1];
+
+ html("<table summary='blob content' class='bin-blob'>\n");
+ html("<tr><th>ofs</th><th>hex dump</th><th>ascii</th></tr>");
+ for (ofs = 0; ofs < size; ofs += ROWLEN, buf += ROWLEN) {
+ htmlf("<tr><td class='right'>%04lx</td><td class='hex'>", ofs);
+ for (idx = 0; idx < ROWLEN && ofs + idx < size; idx++)
+ htmlf("%*s%02x",
+ idx == 16 ? 4 : 1, "",
+ buf[idx] & 0xff);
+ html(" </td><td class='hex'>");
+ for (idx = 0; idx < ROWLEN && ofs + idx < size; idx++)
+ ascii[idx] = isgraph(buf[idx]) ? buf[idx] : '.';
+ ascii[idx] = '\0';
+ html_txt(ascii);
+ html("</td></tr>\n");
+ }
+ html("</table>\n");
+}
+
+static void print_object(const struct object_id *oid, const char *path, const char *rev)
+{
+ enum object_type type;
+ char *buf;
+ unsigned long size;
+ bool is_binary;
+
+ type = odb_read_object_info(the_repository->objects, oid, &size);
+ if (type == OBJ_BAD) {
+ cgit_print_error_page(404, "Not found",
+ "Bad object name: %s", oid_to_hex(oid));
+ return;
+ }
+
+ buf = odb_read_object(the_repository->objects, oid, &type, &size);
+ if (!buf) {
+ cgit_print_error_page(500, "Internal server error",
+ "Error reading object %s", oid_to_hex(oid));
+ return;
+ }
+ is_binary = buffer_is_binary(buf, size);
+
+ cgit_set_title_from_path(path);
+
+ cgit_print_layout_start();
+ htmlf("blob: %s (", oid_to_hex(oid));
+ cgit_plain_link("plain", NULL, NULL, ctx.qry.head,
+ rev, path);
+ if (ctx.repo->enable_blame && !is_binary) {
+ html(") (");
+ cgit_blame_link("blame", NULL, NULL, ctx.qry.head,
+ rev, path);
+ }
+ html(")\n");
+
+ if (ctx.cfg.max_blob_size && size / 1024 > ctx.cfg.max_blob_size) {
+ htmlf("<div class='error'>blob size (%ldKB) exceeds display size limit (%dKB).</div>",
+ size / 1024, ctx.cfg.max_blob_size);
+ return;
+ }
+
+ if (is_binary)
+ print_binary_buffer(buf, size);
+ else
+ print_text_buffer(buf, size);
+
+ free(buf);
+}
+
+struct single_tree_ctx {
+ struct strbuf *path;
+ struct object_id oid;
+ char *name;
+ size_t count;
+};
+
+static int single_tree_cb(const struct object_id *oid, struct strbuf *base,
+ const char *pathname, unsigned mode, void *cbdata)
+{
+ struct single_tree_ctx *ctx = cbdata;
+
+ if (++ctx->count > 1)
+ return -1;
+
+ if (!S_ISDIR(mode)) {
+ ctx->count = 2;
+ return -1;
+ }
+
+ ctx->name = xstrdup(pathname);
+ oidcpy(&ctx->oid, oid);
+ strbuf_addf(ctx->path, "/%s", pathname);
+ return 0;
+}
+
+static void write_tree_link(const struct object_id *oid, char *name,
+ char *rev, struct strbuf *fullpath)
+{
+ size_t initial_length = fullpath->len;
+ struct tree *tree;
+ struct single_tree_ctx tree_ctx = {
+ .path = fullpath,
+ .count = 1,
+ };
+ struct pathspec paths = {
+ .nr = 0
+ };
+
+ oidcpy(&tree_ctx.oid, oid);
+
+ while (tree_ctx.count == 1) {
+ cgit_tree_link(name, NULL, "ls-dir", ctx.qry.head, rev,
+ fullpath->buf);
+
+ tree = lookup_tree(the_repository, &tree_ctx.oid);
+ if (!tree)
+ return;
+
+ free(tree_ctx.name);
+ tree_ctx.name = NULL;
+ tree_ctx.count = 0;
+
+ read_tree(the_repository, tree, &paths, single_tree_cb, &tree_ctx);
+
+ if (tree_ctx.count != 1)
+ break;
+
+ html(" / ");
+ name = tree_ctx.name;
+ }
+
+ strbuf_setlen(fullpath, initial_length);
+}
+
+static int ls_item(const struct object_id *oid, struct strbuf *base,
+ const char *pathname, unsigned mode, void *cbdata)
+{
+ struct walk_tree_context *walk_tree_ctx = cbdata;
+ char *name;
+ struct strbuf fullpath = STRBUF_INIT;
+ struct strbuf linkpath = STRBUF_INIT;
+ struct strbuf class = STRBUF_INIT;
+ enum object_type type;
+ unsigned long size = 0;
+ char *buf;
+
+ name = xstrdup(pathname);
+ strbuf_addf(&fullpath, "%s%s%s", ctx.qry.path ? ctx.qry.path : "",
+ ctx.qry.path ? "/" : "", name);
+
+ if (!S_ISGITLINK(mode)) {
+ type = odb_read_object_info(the_repository->objects, oid, &size);
+ if (type == OBJ_BAD) {
+ htmlf("<tr><td colspan='3'>Bad object: %s %s</td></tr>",
+ name,
+ oid_to_hex(oid));
+ goto cleanup;
+ }
+ }
+
+ html("<tr><td class='ls-mode'>");
+ cgit_print_filemode(mode);
+ html("</td><td>");
+ if (S_ISGITLINK(mode)) {
+ cgit_submodule_link("ls-mod", fullpath.buf, oid_to_hex(oid));
+ } else if (S_ISDIR(mode)) {
+ write_tree_link(oid, name, walk_tree_ctx->curr_rev,
+ &fullpath);
+ } else {
+ char *ext = strrchr(name, '.');
+ strbuf_addstr(&class, "ls-blob");
+ if (ext)
+ strbuf_addf(&class, " %s", ext + 1);
+ cgit_tree_link(name, NULL, class.buf, ctx.qry.head,
+ walk_tree_ctx->curr_rev, fullpath.buf);
+ }
+ if (S_ISLNK(mode)) {
+ html(" -> ");
+ buf = odb_read_object(the_repository->objects, oid, &type, &size);
+ if (!buf) {
+ htmlf("Error reading object: %s", oid_to_hex(oid));
+ goto cleanup;
+ }
+ strbuf_addbuf(&linkpath, &fullpath);
+ strbuf_addf(&linkpath, "/../%s", buf);
+ strbuf_normalize_path(&linkpath);
+ cgit_tree_link(buf, NULL, class.buf, ctx.qry.head,
+ walk_tree_ctx->curr_rev, linkpath.buf);
+ free(buf);
+ strbuf_release(&linkpath);
+ }
+ htmlf("</td><td class='ls-size'>%li</td>", size);
+
+ html("<td>");
+ cgit_log_link("log", NULL, "button", ctx.qry.head,
+ walk_tree_ctx->curr_rev, fullpath.buf, 0, NULL, NULL,
+ ctx.qry.showmsg, 0);
+ if (ctx.repo->max_stats)
+ cgit_stats_link("stats", NULL, "button", ctx.qry.head,
+ fullpath.buf);
+ if (!S_ISGITLINK(mode))
+ cgit_plain_link("plain", NULL, "button", ctx.qry.head,
+ walk_tree_ctx->curr_rev, fullpath.buf);
+ if (!S_ISDIR(mode) && ctx.repo->enable_blame)
+ cgit_blame_link("blame", NULL, "button", ctx.qry.head,
+ walk_tree_ctx->curr_rev, fullpath.buf);
+ html("</td></tr>\n");
+
+cleanup:
+ free(name);
+ strbuf_release(&fullpath);
+ strbuf_release(&class);
+ return 0;
+}
+
+static void ls_head(void)
+{
+ cgit_print_layout_start();
+ html("<table summary='tree listing' class='list'>\n");
+ html("<tr class='nohover'>");
+ html("<th class='left'>Mode</th>");
+ html("<th class='left'>Name</th>");
+ html("<th class='right'>Size</th>");
+ html("<th/>");
+ html("</tr>\n");
+}
+
+static void ls_tail(void)
+{
+ html("</table>\n");
+ cgit_print_layout_end();
+}
+
+static void ls_tree(const struct object_id *oid, const char *path, struct walk_tree_context *walk_tree_ctx)
+{
+ struct tree *tree;
+ struct pathspec paths = {
+ .nr = 0
+ };
+
+ tree = parse_tree_indirect(oid);
+ if (!tree) {
+ cgit_print_error_page(404, "Not found",
+ "Not a tree object: %s", oid_to_hex(oid));
+ return;
+ }
+
+ ls_head();
+ read_tree(the_repository, tree, &paths, ls_item, walk_tree_ctx);
+ ls_tail();
+}
+
+
+static int walk_tree(const struct object_id *oid, struct strbuf *base,
+ const char *pathname, unsigned mode, void *cbdata)
+{
+ struct walk_tree_context *walk_tree_ctx = cbdata;
+
+ if (walk_tree_ctx->state == 0) {
+ struct strbuf buffer = STRBUF_INIT;
+
+ strbuf_addbuf(&buffer, base);
+ strbuf_addstr(&buffer, pathname);
+ if (strcmp(walk_tree_ctx->match_path, buffer.buf))
+ return READ_TREE_RECURSIVE;
+
+ if (S_ISDIR(mode)) {
+ walk_tree_ctx->state = 1;
+ cgit_set_title_from_path(buffer.buf);
+ strbuf_release(&buffer);
+ ls_head();
+ return READ_TREE_RECURSIVE;
+ } else {
+ walk_tree_ctx->state = 2;
+ print_object(oid, buffer.buf, walk_tree_ctx->curr_rev);
+ strbuf_release(&buffer);
+ return 0;
+ }
+ }
+ ls_item(oid, base, pathname, mode, walk_tree_ctx);
+ return 0;
+}
+
+/*
+ * Show a tree or a blob
+ * rev: the commit pointing at the root tree object
+ * path: path to tree or blob
+ */
+void cgit_print_tree(const char *rev, char *path)
+{
+ struct object_id oid;
+ struct commit *commit;
+ struct pathspec_item path_items = {
+ .match = path,
+ .len = path ? strlen(path) : 0
+ };
+ struct pathspec paths = {
+ .nr = path ? 1 : 0,
+ .items = &path_items
+ };
+ struct walk_tree_context walk_tree_ctx = {
+ .match_path = path,
+ .state = 0
+ };
+
+ if (!rev)
+ rev = ctx.qry.head;
+
+ if (repo_get_oid(the_repository, rev, &oid)) {
+ cgit_print_error_page(404, "Not found",
+ "Invalid revision name: %s", rev);
+ return;
+ }
+ commit = lookup_commit_reference(the_repository, &oid);
+ if (!commit || repo_parse_commit(the_repository, commit)) {
+ cgit_print_error_page(404, "Not found",
+ "Invalid commit reference: %s", rev);
+ return;
+ }
+
+ walk_tree_ctx.curr_rev = xstrdup(rev);
+
+ if (path == NULL) {
+ ls_tree(get_commit_tree_oid(commit), NULL, &walk_tree_ctx);
+ goto cleanup;
+ }
+
+ read_tree(the_repository, repo_get_commit_tree(the_repository, commit),
+ &paths, walk_tree, &walk_tree_ctx);
+ if (walk_tree_ctx.state == 1)
+ ls_tail();
+ else if (walk_tree_ctx.state == 2)
+ cgit_print_layout_end();
+ else
+ cgit_print_error_page(404, "Not found", "Path not found");
+
+cleanup:
+ free(walk_tree_ctx.curr_rev);
+}
diff --git a/cgit/ui-tree.h b/cgit/ui-tree.h
new file mode 100644
--- /dev/null
+++ b/cgit/ui-tree.h
@@ -0,0 +1,10 @@
+/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com>
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef UI_TREE_H
+#define UI_TREE_H
+
+extern void cgit_print_tree(const char *rev, char *path);
+
+#endif /* UI_TREE_H */
diff --git a/charts/gilti/Chart.yaml b/charts/gilti/Chart.yaml
index 9a4b918..34cd2c0 100644
--- a/charts/gilti/Chart.yaml
+++ b/charts/gilti/Chart.yaml
@@ -7,5 +7,5 @@ version: 0.1.0
appVersion: 0.1.0
name: gilti
-home: https://git.dimidiumlabs.io/gilti
-description: Boxed tiny Git server powered by Rust and OpenSSH
+home: https://github.com/dimidiumlabs/gilti
+description: Boxed tiny Git server powered by cgit and OpenSSH
diff --git a/charts/gilti/README.md b/charts/gilti/README.md
index 71f45ea..14a31f4 100644
--- a/charts/gilti/README.md
+++ b/charts/gilti/README.md
@@ -31,11 +31,9 @@ git push -u origin main
## Repository visibility
-Repository browsing, smart HTTP cloning, archives, and LFS downloads are
-anonymous and read-only. Consequently every repository available over SSH is
-also publicly visible over HTTP. HTTP pushes and LFS uploads remain disabled by
-default; setting `http.write: true` enables them without authentication and is
-appropriate only behind a trusted access-control proxy.
+cgit is anonymous and read-only and scans the complete repository directory.
+Consequently every repository available over SSH is also publicly visible over
+HTTP. Web cloning and snapshots are disabled in the default policy.
## Networking
@@ -53,11 +51,11 @@ Example for the first Dimidium Labs installation:
ssh:
authorizedKeys:
- ssh-ed25519 AAAA... operator@example
-web:
- clonePrefix: ssh://git@git.dimidiumlabs.io/
+cgit:
+ clonePrefix: ssh://git@vcs.dimidiumlabs.io/
httpRoute:
enabled: true
- hostnames: [git.dimidiumlabs.io]
+ hostnames: [vcs.dimidiumlabs.io]
parentRefs:
- name: public
namespace: network
@@ -76,9 +74,11 @@ are stored in the generated ConfigMap and must be supplied on every deployment.
## Security context
-The Rust HTTP gateway and its native views run without privileges. OpenSSH
+The Rust HTTP gateway and its cgit children run without privileges. OpenSSH
intentionally keeps a root master so it can enter the `git` account (UID/GID
10000). The chart drops all capabilities and restores only `CHOWN`,
`DAC_OVERRIDE`, `FOWNER`, `SETGID`, `SETUID`, and `SYS_CHROOT`. The root
filesystem is read-only; state, `/run`, and `/tmp` are explicit writable
-mounts.
+mounts. CGI responses use an in-memory LRU cache bounded to 64 entries and
+4 MiB; configure its lifetime with `cgit.cache` in seconds, or set it to zero
+to disable caching.
diff --git a/charts/gilti/templates/configmap.yaml b/charts/gilti/templates/configmap.yaml
index aa6ab25..1376076 100644
--- a/charts/gilti/templates/configmap.yaml
+++ b/charts/gilti/templates/configmap.yaml
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2026 Nikolay Govorov
# SPDX-License-Identifier: AGPL-3.0-or-later
-{{- range $name, $value := dict "web.rootTitle" .Values.web.rootTitle "web.rootDescription" .Values.web.rootDescription "web.clonePrefix" .Values.web.clonePrefix }}
+{{- range $name, $value := dict "cgit.rootTitle" .Values.cgit.rootTitle "cgit.rootDescription" .Values.cgit.rootDescription "cgit.clonePrefix" .Values.cgit.clonePrefix }}
{{- if regexMatch "[\\r\\n]" $value }}
{{- fail (printf "%s must be a single line" $name) }}
{{- end }}
@@ -18,6 +18,30 @@ metadata:
labels:
{{- include "gilti.labels" . | nindent 4 }}
data:
+ cgitrc: |
+ root-title={{ .Values.cgit.rootTitle }}
+ root-desc={{ .Values.cgit.rootDescription }}
+ virtual-root=/
+ css=/cgit.css
+ logo=/cgit.png
+ favicon=/favicon.ico
+ # Gilti consumes this setting before invoking cgit.
+ cache={{ .Values.cgit.cache }}
+ enable-http-clone=0
+ enable-index-owner=1
+ enable-index-links=1
+ enable-commit-graph=1
+ enable-log-filecount=1
+ enable-log-linecount=1
+ enable-tree-linenumbers=1
+ snapshots=
+ remove-suffix=1
+ readme=:README.md
+ readme=:README
+ {{- with .Values.cgit.clonePrefix }}
+ clone-prefix={{ . }}
+ {{- end }}
+ scan-path=/var/lib/gilti/git/repositories
authorized_keys: |
{{- range .Values.ssh.authorizedKeys }}
{{ . }}
diff --git a/charts/gilti/templates/deployment.yaml b/charts/gilti/templates/deployment.yaml
index 08bd3c7..19eab1b 100644
--- a/charts/gilti/templates/deployment.yaml
+++ b/charts/gilti/templates/deployment.yaml
@@ -47,15 +47,6 @@ spec:
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
- env:
- - name: GILTI_ROOT_TITLE
- value: {{ .Values.web.rootTitle | quote }}
- - name: GILTI_ROOT_DESCRIPTION
- value: {{ .Values.web.rootDescription | quote }}
- - name: GILTI_CLONE_PREFIX
- value: {{ .Values.web.clonePrefix | quote }}
- - name: GILTI_HTTP_WRITE
- value: {{ ternary "1" "0" .Values.http.write | quote }}
ports:
- name: http
containerPort: 8080
@@ -65,18 +56,18 @@ spec:
protocol: TCP
startupProbe:
httpGet:
- path: /-/health
+ path: /healthz
port: http
failureThreshold: 60
periodSeconds: 5
readinessProbe:
httpGet:
- path: /-/health
+ path: /healthz
port: http
periodSeconds: 10
livenessProbe:
httpGet:
- path: /-/health
+ path: /healthz
port: http
periodSeconds: 20
resources:
@@ -85,11 +76,15 @@ spec:
- name: state
mountPath: /var/lib/gilti
- name: config
+ mountPath: /etc/cgitrc
+ subPath: cgitrc
+ readOnly: true
+ - name: config
mountPath: /etc/gilti/authorized_keys
subPath: authorized_keys
readOnly: true
- name: run
- mountPath: /run/gilti
+ mountPath: /run
- name: tmp
mountPath: /tmp
volumes:
diff --git a/charts/gilti/values.schema.json b/charts/gilti/values.schema.json
index 3fc6618..c15674a 100644
--- a/charts/gilti/values.schema.json
+++ b/charts/gilti/values.schema.json
@@ -22,18 +22,13 @@
}
}
},
- "web": {
+ "cgit": {
"type": "object",
"properties": {
"rootTitle": { "type": "string", "pattern": "^[^\\r\\n]*$" },
"rootDescription": { "type": "string", "pattern": "^[^\\r\\n]*$" },
- "clonePrefix": { "type": "string", "pattern": "^[^\\r\\n]*$" }
- }
- },
- "http": {
- "type": "object",
- "properties": {
- "write": { "type": "boolean" }
+ "clonePrefix": { "type": "string", "pattern": "^[^\\r\\n]*$" },
+ "cache": { "type": "integer", "minimum": 0, "maximum": 3600 }
}
},
"persistence": {
diff --git a/charts/gilti/values.yaml b/charts/gilti/values.yaml
index 2924782..03c480b 100644
--- a/charts/gilti/values.yaml
+++ b/charts/gilti/values.yaml
@@ -17,14 +17,12 @@ ssh:
# Every listed key has read/write access to every repository.
authorizedKeys: []
-web:
+cgit:
rootTitle: Gilti
rootDescription: A tiny Git server
clonePrefix: ""
-
-http:
- # Allow unauthenticated Git receive-pack and LFS uploads.
- write: false
+ # In-memory CGI response lifetime in seconds; zero disables the cache.
+ cache: 5
persistence:
enabled: true
diff --git a/config/cgitrc b/config/cgitrc
new file mode 100644
--- /dev/null
+++ b/config/cgitrc
@@ -0,0 +1,27 @@
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+root-title=Gilti
+root-desc=A tiny Git server
+virtual-root=/
+
+css=/cgit.css
+logo=/cgit.png
+favicon=/favicon.ico
+
+# In-memory CGI response lifetime in seconds; zero disables the cache.
+cache=5
+
+enable-http-clone=0
+enable-index-owner=1
+enable-index-links=1
+enable-commit-graph=1
+enable-log-filecount=1
+enable-log-linecount=1
+enable-tree-linenumbers=1
+snapshots=
+
+remove-suffix=1
+readme=:README.md
+readme=:README
+scan-path=/var/lib/gilti/git/repositories
diff --git a/crates/gilti-ssh/Cargo.toml b/crates/gilti-ssh/Cargo.toml
index 1fc17cd..bbebe68 100644
--- a/crates/gilti-ssh/Cargo.toml
+++ b/crates/gilti-ssh/Cargo.toml
@@ -12,7 +12,6 @@ authors.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
-rust-version.workspace = true
[dependencies]
libc.workspace = true
diff --git a/crates/gilti/Cargo.toml b/crates/gilti/Cargo.toml
index 8296cd9..5752260 100644
--- a/crates/gilti/Cargo.toml
+++ b/crates/gilti/Cargo.toml
@@ -3,7 +3,7 @@
[package]
name = "gilti"
-description = "Gilti daemon"
+description = "Gilti web-server and daemon"
version.workspace = true
authors.workspace = true
@@ -12,16 +12,12 @@ license.workspace = true
publish.workspace = true
homepage.workspace = true
repository.workspace = true
-rust-version.workspace = true
[dependencies]
axum.workspace = true
-git2.workspace = true
-libc.workspace = true
percent-encoding.workspace = true
tokio.workspace = true
tower.workspace = true
maud.workspace = true
serde.workspace = true
serde_json.workspace = true
-sha2.workspace = true
diff --git a/crates/gilti/src/cgi.rs b/crates/gilti/src/cgi.rs
index ce5989a..ffcc081 100644
--- a/crates/gilti/src/cgi.rs
+++ b/crates/gilti/src/cgi.rs
@@ -3,8 +3,187 @@
//! HTTP-to-CGI Tower service.
-#[derive(Clone, Default)]
-pub struct Environment(pub Vec<(std::ffi::OsString, std::ffi::OsString)>);
+const CACHE_MAX_ENTRIES: usize = 64;
+const CACHE_MAX_BYTES: usize = 4 * 1024 * 1024;
+
+fn has_cache_directive(headers: &axum::http::HeaderMap, expected: &[&str]) -> bool {
+ headers
+ .get_all(axum::http::header::CACHE_CONTROL)
+ .iter()
+ .filter_map(|value| value.to_str().ok())
+ .flat_map(|value| value.split(','))
+ .map(str::trim)
+ .any(|value| {
+ let name = value.split_once('=').map_or(value, |(name, _)| name);
+ expected
+ .iter()
+ .any(|expected| name.eq_ignore_ascii_case(expected))
+ })
+}
+
+#[derive(Clone, Eq, Hash, PartialEq)]
+struct CacheKey {
+ uri: String,
+ host: Option<Vec<u8>>,
+ cookie: Option<Vec<u8>>,
+ referer: Option<Vec<u8>>,
+}
+
+impl CacheKey {
+ fn from_request(parts: &axum::http::request::Parts, body: &[u8]) -> Option<Self> {
+ if parts.method != axum::http::Method::GET
+ || !body.is_empty()
+ || parts
+ .headers
+ .contains_key(axum::http::header::AUTHORIZATION)
+ || parts.headers.contains_key(axum::http::header::RANGE)
+ || has_cache_directive(&parts.headers, &["no-cache", "no-store"])
+ || parts
+ .headers
+ .get(axum::http::header::PRAGMA)
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|value| value.eq_ignore_ascii_case("no-cache"))
+ {
+ return None;
+ }
+ let header = |name| {
+ parts
+ .headers
+ .get(name)
+ .map(|value| value.as_bytes().to_vec())
+ };
+ Some(Self {
+ uri: parts.uri.to_string(),
+ host: header(axum::http::header::HOST),
+ cookie: header(axum::http::header::COOKIE),
+ referer: header(axum::http::header::REFERER),
+ })
+ }
+}
+
+#[derive(Clone)]
+struct CachedResponse {
+ status: axum::http::StatusCode,
+ headers: axum::http::HeaderMap,
+ body: Vec<u8>,
+}
+
+impl CachedResponse {
+ fn is_cacheable(&self) -> bool {
+ if self.status != axum::http::StatusCode::OK
+ || self.headers.contains_key(axum::http::header::SET_COOKIE)
+ || self.headers.contains_key(axum::http::header::VARY)
+ {
+ return false;
+ }
+ !has_cache_directive(&self.headers, &["no-cache", "no-store", "private"])
+ }
+
+ fn into_response(self) -> std::io::Result<axum::http::Response<axum::body::Body>> {
+ let mut response = axum::http::Response::builder()
+ .status(self.status)
+ .body(axum::body::Body::from(self.body))
+ .map_err(std::io::Error::other)?;
+ *response.headers_mut() = self.headers;
+ Ok(response)
+ }
+}
+
+struct CacheEntry {
+ response: CachedResponse,
+ expires: std::time::Instant,
+}
+
+#[derive(Default)]
+struct CacheState {
+ entries: std::collections::VecDeque<(CacheKey, CacheEntry)>,
+ bytes: usize,
+}
+
+impl CacheState {
+ fn prune_expired(&mut self, now: std::time::Instant) {
+ let mut bytes = 0;
+ self.entries.retain(|(_, entry)| {
+ if entry.expires <= now {
+ false
+ } else {
+ bytes += entry.response.body.len();
+ true
+ }
+ });
+ self.bytes = bytes;
+ }
+}
+
+#[derive(Clone)]
+struct ResponseCache {
+ ttl: std::time::Duration,
+ state: std::sync::Arc<std::sync::Mutex<CacheState>>,
+}
+
+impl ResponseCache {
+ fn new(ttl: std::time::Duration) -> Self {
+ Self {
+ ttl,
+ state: std::sync::Arc::new(std::sync::Mutex::new(CacheState::default())),
+ }
+ }
+
+ fn get(&self, key: &CacheKey) -> Option<CachedResponse> {
+ self.get_at(key, std::time::Instant::now())
+ }
+
+ fn get_at(&self, key: &CacheKey, now: std::time::Instant) -> Option<CachedResponse> {
+ let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
+ state.prune_expired(now);
+ let index = state
+ .entries
+ .iter()
+ .position(|(entry_key, _)| entry_key == key)?;
+ let entry = state.entries.remove(index)?;
+ let response = entry.1.response.clone();
+ state.entries.push_back(entry);
+ Some(response)
+ }
+
+ fn insert(&self, key: CacheKey, response: CachedResponse) {
+ self.insert_at(key, response, std::time::Instant::now());
+ }
+
+ fn insert_at(&self, key: CacheKey, response: CachedResponse, now: std::time::Instant) {
+ let size = response.body.len();
+ if !response.is_cacheable() || size > CACHE_MAX_BYTES {
+ return;
+ }
+
+ let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
+ state.prune_expired(now);
+ let previous = state
+ .entries
+ .iter()
+ .position(|(entry_key, _)| entry_key == &key)
+ .and_then(|index| state.entries.remove(index));
+ if let Some(previous) = previous {
+ state.bytes -= previous.1.response.body.len();
+ }
+ while state.entries.len() >= CACHE_MAX_ENTRIES
+ || state.bytes.saturating_add(size) > CACHE_MAX_BYTES
+ {
+ let Some(previous) = state.entries.pop_front() else {
+ break;
+ };
+ state.bytes -= previous.1.response.body.len();
+ }
+ state.bytes += size;
+ state.entries.push_back((
+ key,
+ CacheEntry {
+ response,
+ expires: now + self.ttl,
+ },
+ ));
+ }
+}
#[derive(Clone, Copy)]
pub struct RemoteAddr(pub std::net::SocketAddr);
@@ -15,6 +194,7 @@ pub struct Cgi {
current_dir: std::path::PathBuf,
environment: Vec<(std::ffi::OsString, std::ffi::OsString)>,
server_addr: std::net::SocketAddr,
+ cache: Option<ResponseCache>,
}
impl Cgi {
@@ -28,9 +208,17 @@ impl Cgi {
current_dir: current_dir.into(),
environment: Vec::new(),
server_addr,
+ cache: None,
}
}
+ pub fn cache(mut self, ttl: std::time::Duration) -> Self {
+ if !ttl.is_zero() {
+ self.cache = Some(ResponseCache::new(ttl));
+ }
+ self
+ }
+
pub fn env(
mut self,
name: impl Into<std::ffi::OsString>,
@@ -49,9 +237,18 @@ impl Cgi {
|value| value.0,
);
let (parts, body) = request.into_parts();
- let body = axum::body::to_bytes(body, 1024 * 1024 * 1024)
+ let body = axum::body::to_bytes(body, 1024 * 1024)
.await
.map_err(std::io::Error::other)?;
+ let cache_key = CacheKey::from_request(&parts, &body);
+ if let Some(response) = self
+ .cache
+ .as_ref()
+ .zip(cache_key.as_ref())
+ .and_then(|(cache, key)| cache.get(key))
+ {
+ return response.into_response();
+ }
let path = percent_encoding::percent_decode_str(parts.uri.path()).collect::<Vec<_>>();
if path.contains(&0) {
return Err(invalid("request path contains a null byte"));
@@ -116,10 +313,6 @@ impl Cgi {
}
}
- if let Some(environment) = parts.extensions.get::<Environment>() {
- command.envs(environment.0.iter().cloned());
- }
-
let mut child = command.spawn()?;
let mut stdin = child
.stdin
@@ -140,12 +333,15 @@ impl Cgi {
}
let (status, headers, body) = parse_response(&output.stdout)?;
- let mut response = axum::http::Response::builder()
- .status(status)
- .body(axum::body::Body::from(body.to_vec()))
- .map_err(std::io::Error::other)?;
- *response.headers_mut() = headers;
- Ok(response)
+ let response = CachedResponse {
+ status,
+ headers,
+ body: body.to_vec(),
+ };
+ if let (Some(cache), Some(key)) = (&self.cache, cache_key) {
+ cache.insert(key, response.clone());
+ }
+ response.into_response()
}
}
@@ -219,6 +415,67 @@ fn invalid(message: &'static str) -> std::io::Error {
#[cfg(test)]
mod tests {
+ fn cache_key(uri: impl Into<String>) -> super::CacheKey {
+ super::CacheKey {
+ uri: uri.into(),
+ host: None,
+ cookie: None,
+ referer: None,
+ }
+ }
+
+ fn cached_response(body: impl Into<Vec<u8>>) -> super::CachedResponse {
+ super::CachedResponse {
+ status: axum::http::StatusCode::OK,
+ headers: axum::http::HeaderMap::new(),
+ body: body.into(),
+ }
+ }
+
+ #[test]
+ fn memory_cache_expires_entries() {
+ let cache = super::ResponseCache::new(std::time::Duration::from_secs(5));
+ let now = std::time::Instant::now();
+ cache.insert_at(cache_key("/repo/"), cached_response(b"first".to_vec()), now);
+ assert_eq!(
+ cache.get_at(&cache_key("/repo/"), now).unwrap().body,
+ b"first"
+ );
+ assert!(
+ cache
+ .get_at(
+ &cache_key("/repo/"),
+ now + std::time::Duration::from_secs(5)
+ )
+ .is_none()
+ );
+ }
+
+ #[test]
+ fn memory_cache_is_bounded_and_honors_response_directives() {
+ let cache = super::ResponseCache::new(std::time::Duration::from_secs(5));
+ let now = std::time::Instant::now();
+ for index in 0..super::CACHE_MAX_ENTRIES {
+ cache.insert_at(
+ cache_key(format!("/{index}")),
+ cached_response(vec![index as u8]),
+ now,
+ );
+ }
+ assert!(cache.get_at(&cache_key("/0"), now).is_some());
+ cache.insert_at(cache_key("/new"), cached_response(b"new".to_vec()), now);
+ assert!(cache.get_at(&cache_key("/1"), now).is_none());
+ assert!(cache.get_at(&cache_key("/0"), now).is_some());
+
+ let mut private = cached_response(b"private".to_vec());
+ private.headers.insert(
+ axum::http::header::CACHE_CONTROL,
+ axum::http::HeaderValue::from_static("private"),
+ );
+ cache.insert_at(cache_key("/private"), private, now);
+ assert!(cache.get_at(&cache_key("/private"), now).is_none());
+ }
+
#[test]
fn parses_cgi_response() {
let (status, headers, body) =
diff --git a/crates/gilti/src/lfs.rs b/crates/gilti/src/lfs.rs
deleted file mode 100644
--- a/crates/gilti/src/lfs.rs
+++ /dev/null
@@ -1,389 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-const MAX_OBJECT_SIZE: usize = 1024 * 1024 * 1024;
-
-#[derive(serde::Deserialize)]
-struct BatchRequest {
- operation: String,
- objects: Vec<ObjectRequest>,
-}
-
-#[derive(serde::Deserialize)]
-struct ObjectRequest {
- oid: String,
- size: u64,
-}
-
-#[derive(serde::Deserialize)]
-struct VerifyRequest {
- oid: String,
- size: u64,
-}
-
-#[derive(serde::Serialize)]
-struct BatchResponse {
- transfer: &'static str,
- objects: Vec<ObjectResponse>,
-}
-
-#[derive(serde::Serialize)]
-struct ObjectResponse {
- oid: String,
- size: u64,
- #[serde(skip_serializing_if = "Option::is_none")]
- actions: Option<Actions>,
- #[serde(skip_serializing_if = "Option::is_none")]
- error: Option<ObjectError>,
-}
-
-#[derive(serde::Serialize)]
-struct Actions {
- #[serde(skip_serializing_if = "Option::is_none")]
- download: Option<Action>,
- #[serde(skip_serializing_if = "Option::is_none")]
- upload: Option<Action>,
- #[serde(skip_serializing_if = "Option::is_none")]
- verify: Option<Action>,
-}
-
-#[derive(serde::Serialize)]
-struct Action {
- href: String,
-}
-
-#[derive(serde::Serialize)]
-struct ObjectError {
- code: u16,
- message: &'static str,
-}
-
-pub async fn serve(
- repositories: &std::path::Path,
- repo: &str,
- path: &str,
- write_enabled: bool,
- request: axum::extract::Request,
-) -> axum::response::Response {
- let Some(repository) = repository(repositories, repo) else {
- return plain(axum::http::StatusCode::NOT_FOUND, "repository not found\n");
- };
- let objects = repository.join("lfs/objects");
- let method = request.method().clone();
-
- if path == "objects/batch" && method == axum::http::Method::POST {
- return batch(objects, write_enabled, request).await;
- }
- if method == axum::http::Method::POST
- && let Some(oid) = path
- .strip_prefix("objects/")
- .and_then(|value| value.strip_suffix("/verify"))
- {
- return verify(&objects, oid, request).await;
- }
- if let Some(oid) = path.strip_prefix("objects/") {
- if !valid_oid(oid) {
- return plain(axum::http::StatusCode::BAD_REQUEST, "invalid object id\n");
- }
- return match method {
- axum::http::Method::GET | axum::http::Method::HEAD => {
- download(&objects, oid, method == axum::http::Method::HEAD).await
- }
- axum::http::Method::PUT if write_enabled => upload(&objects, oid, request).await,
- axum::http::Method::PUT => plain(
- axum::http::StatusCode::FORBIDDEN,
- "LFS uploads are disabled\n",
- ),
- _ => plain(
- axum::http::StatusCode::METHOD_NOT_ALLOWED,
- "method not allowed\n",
- ),
- };
- }
- plain(axum::http::StatusCode::NOT_FOUND, "not found\n")
-}
-
-async fn batch(
- objects: std::path::PathBuf,
- write_enabled: bool,
- request: axum::extract::Request,
-) -> axum::response::Response {
- let base = request
- .uri()
- .path()
- .strip_suffix("/objects/batch")
- .unwrap_or(request.uri().path());
- let scheme = request
- .headers()
- .get("x-forwarded-proto")
- .and_then(|value| value.to_str().ok())
- .filter(|value| matches!(*value, "http" | "https"))
- .unwrap_or("http");
- let host = request
- .headers()
- .get(axum::http::header::HOST)
- .and_then(|value| value.to_str().ok())
- .unwrap_or("localhost");
- let prefix = format!("{scheme}://{host}{base}/objects");
- let body = match axum::body::to_bytes(request.into_body(), 1024 * 1024).await {
- Ok(body) => body,
- Err(_) => {
- return plain(
- axum::http::StatusCode::PAYLOAD_TOO_LARGE,
- "request too large\n",
- );
- }
- };
- let request: BatchRequest = match serde_json::from_slice(&body) {
- Ok(request) => request,
- Err(_) => return plain(axum::http::StatusCode::BAD_REQUEST, "invalid LFS request\n"),
- };
- if !matches!(request.operation.as_str(), "download" | "upload") {
- return plain(
- axum::http::StatusCode::BAD_REQUEST,
- "invalid LFS operation\n",
- );
- }
-
- let objects = request
- .objects
- .into_iter()
- .map(|object| {
- if !valid_oid(&object.oid) {
- return error(object, 422, "invalid object id");
- }
- let stored = object_path(&objects, &object.oid);
- let present = std::fs::metadata(&stored)
- .is_ok_and(|metadata| metadata.is_file() && metadata.len() == object.size);
- match request.operation.as_str() {
- "download" if present => response(
- object,
- Actions {
- download: Some(Action {
- href: format!(
- "{prefix}/{}",
- stored.file_name().unwrap().to_string_lossy()
- ),
- }),
- upload: None,
- verify: None,
- },
- ),
- "download" => error(object, 404, "object not found"),
- "upload" if present => response(
- object,
- Actions {
- download: Some(Action {
- href: format!(
- "{prefix}/{}",
- stored.file_name().unwrap().to_string_lossy()
- ),
- }),
- upload: None,
- verify: None,
- },
- ),
- "upload" if !write_enabled => error(object, 403, "LFS uploads are disabled"),
- "upload" => response(
- object,
- Actions {
- download: None,
- upload: Some(Action {
- href: format!(
- "{prefix}/{}",
- stored.file_name().unwrap().to_string_lossy()
- ),
- }),
- verify: Some(Action {
- href: format!(
- "{prefix}/{}/verify",
- stored.file_name().unwrap().to_string_lossy()
- ),
- }),
- },
- ),
- _ => unreachable!(),
- }
- })
- .collect();
- json(
- axum::http::StatusCode::OK,
- &BatchResponse {
- transfer: "basic",
- objects,
- },
- )
-}
-
-async fn download(objects: &std::path::Path, oid: &str, head: bool) -> axum::response::Response {
- let path = object_path(objects, oid);
- let metadata = match tokio::fs::metadata(&path).await {
- Ok(metadata) if metadata.is_file() => metadata,
- _ => return plain(axum::http::StatusCode::NOT_FOUND, "object not found\n"),
- };
- let body = if head {
- axum::body::Body::empty()
- } else {
- match tokio::fs::read(path).await {
- Ok(body) => axum::body::Body::from(body),
- Err(_) => {
- return plain(
- axum::http::StatusCode::INTERNAL_SERVER_ERROR,
- "read failed\n",
- );
- }
- }
- };
- axum::http::Response::builder()
- .status(axum::http::StatusCode::OK)
- .header(axum::http::header::CONTENT_TYPE, "application/octet-stream")
- .header(axum::http::header::CONTENT_LENGTH, metadata.len())
- .body(body)
- .expect("valid LFS response")
-}
-
-async fn upload(
- objects: &std::path::Path,
- oid: &str,
- request: axum::extract::Request,
-) -> axum::response::Response {
- use sha2::Digest;
-
- let body = match axum::body::to_bytes(request.into_body(), MAX_OBJECT_SIZE).await {
- Ok(body) => body,
- Err(_) => {
- return plain(
- axum::http::StatusCode::PAYLOAD_TOO_LARGE,
- "object too large\n",
- );
- }
- };
- let actual = format!("{:x}", sha2::Sha256::digest(&body));
- if actual != oid {
- return plain(
- axum::http::StatusCode::UNPROCESSABLE_ENTITY,
- "object hash mismatch\n",
- );
- }
- let path = object_path(objects, oid);
- let Some(parent) = path.parent() else {
- return plain(
- axum::http::StatusCode::INTERNAL_SERVER_ERROR,
- "invalid storage path\n",
- );
- };
- if tokio::fs::create_dir_all(parent).await.is_err() {
- return plain(
- axum::http::StatusCode::INTERNAL_SERVER_ERROR,
- "storage failed\n",
- );
- }
- let temporary = parent.join(format!(".{oid}.{}.tmp", std::process::id()));
- if tokio::fs::write(&temporary, &body).await.is_err()
- || tokio::fs::rename(&temporary, &path).await.is_err()
- {
- let _ = tokio::fs::remove_file(temporary).await;
- return plain(
- axum::http::StatusCode::INTERNAL_SERVER_ERROR,
- "storage failed\n",
- );
- }
- plain(axum::http::StatusCode::OK, "")
-}
-
-async fn verify(
- objects: &std::path::Path,
- oid: &str,
- request: axum::extract::Request,
-) -> axum::response::Response {
- if !valid_oid(oid) {
- return plain(axum::http::StatusCode::BAD_REQUEST, "invalid object id\n");
- }
- let body = match axum::body::to_bytes(request.into_body(), 64 * 1024).await {
- Ok(body) => body,
- Err(_) => {
- return plain(
- axum::http::StatusCode::PAYLOAD_TOO_LARGE,
- "request too large\n",
- );
- }
- };
- let request: VerifyRequest = match serde_json::from_slice(&body) {
- Ok(request) => request,
- Err(_) => return plain(axum::http::StatusCode::BAD_REQUEST, "invalid LFS request\n"),
- };
- let valid = request.oid == oid
- && std::fs::metadata(object_path(objects, oid))
- .is_ok_and(|metadata| metadata.is_file() && metadata.len() == request.size);
- if valid {
- plain(axum::http::StatusCode::OK, "")
- } else {
- plain(axum::http::StatusCode::NOT_FOUND, "object not found\n")
- }
-}
-
-fn repository(root: &std::path::Path, repo: &str) -> Option<std::path::PathBuf> {
- let root = std::fs::canonicalize(root).ok()?;
- let repository = std::fs::canonicalize(root.join(format!("{repo}.git"))).ok()?;
- repository
- .is_dir()
- .then_some(repository)
- .filter(|repository| repository.starts_with(root))
-}
-
-fn object_path(root: &std::path::Path, oid: &str) -> std::path::PathBuf {
- root.join(&oid[..2]).join(&oid[2..4]).join(oid)
-}
-
-fn valid_oid(oid: &str) -> bool {
- oid.len() == 64
- && oid
- .bytes()
- .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
-}
-
-fn response(object: ObjectRequest, actions: Actions) -> ObjectResponse {
- ObjectResponse {
- oid: object.oid,
- size: object.size,
- actions: Some(actions),
- error: None,
- }
-}
-
-fn error(object: ObjectRequest, code: u16, message: &'static str) -> ObjectResponse {
- ObjectResponse {
- oid: object.oid,
- size: object.size,
- actions: None,
- error: Some(ObjectError { code, message }),
- }
-}
-
-fn json<T: serde::Serialize>(
- status: axum::http::StatusCode,
- value: &T,
-) -> axum::response::Response {
- match serde_json::to_vec(value) {
- Ok(body) => axum::http::Response::builder()
- .status(status)
- .header(
- axum::http::header::CONTENT_TYPE,
- "application/vnd.git-lfs+json",
- )
- .body(axum::body::Body::from(body))
- .expect("valid LFS response"),
- Err(_) => plain(
- axum::http::StatusCode::INTERNAL_SERVER_ERROR,
- "encoding failed\n",
- ),
- }
-}
-
-fn plain(status: axum::http::StatusCode, message: &'static str) -> axum::response::Response {
- axum::http::Response::builder()
- .status(status)
- .header(axum::http::header::CONTENT_TYPE, "text/plain")
- .body(axum::body::Body::from(message))
- .expect("valid LFS response")
-}
diff --git a/crates/gilti/src/main.rs b/crates/gilti/src/main.rs
index 70a0b3d..1e323b5 100644
--- a/crates/gilti/src/main.rs
+++ b/crates/gilti/src/main.rs
@@ -2,156 +2,134 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
mod cgi;
-mod lfs;
-mod models;
-pub mod router;
-mod views;
+mod ui;
const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8080";
-const DEFAULT_ROOT_TITLE: &str = "Gilti";
-const DEFAULT_ROOT_DESCRIPTION: &str = "A tiny Git server";
-const GIT_HTTP_BACKENDS: &[&str] = &[
- "/usr/libexec/git-core/git-http-backend",
- "/usr/lib/git-core/git-http-backend",
-];
-const GIT: &str = "/usr/bin/git";
+const MAX_CACHE_SECONDS: u64 = 3600;
+
+const CGIT: &str = "/usr/local/bin/gilti-cgit";
+const CGIT_CONFIG: &str = "/etc/cgitrc";
const GIT_HOME: &str = "/var/lib/gilti/git";
-const REPOSITORIES: &str = "/var/lib/gilti/git/repositories";
-const ARCHIVE_COMPRESSORS: &[&str] = &[
- "/usr/bin/bzip2",
- "/usr/bin/lzip",
- "/usr/bin/xz",
- "/usr/bin/zstd",
-];
-
-const ASSET_CSS: &str = "/usr/share/gilti/gilti.css";
-const ASSET_JS: &str = "/usr/share/gilti/gilti.js";
-const ASSET_LOGO: &str = "/usr/share/gilti/gilti.png";
-const ASSET_FAVICON: &str = "/usr/share/gilti/favicon.ico";
+const RUN_DIR: &str = "/run/gilti/http";
+
+const CGIT_CSS: &str = "/usr/share/webapps/cgit/cgit.css";
+const CGIT_JS: &str = "/usr/share/webapps/cgit/cgit.js";
+const CGIT_LOGO: &str = "/usr/share/webapps/cgit/cgit.png";
+const CGIT_FAVICON: &str = "/usr/share/webapps/cgit/favicon.ico";
#[derive(Clone)]
-struct RepositoryService {
- git: cgi::Cgi,
- views: views::shared::Context,
- write_enabled: bool,
+struct AppState {
+ cgit: cgi::Cgi,
}
-struct Config {
- listen_addr: std::net::SocketAddr,
- root_title: String,
- root_description: String,
- clone_prefix: String,
- http_write: bool,
+struct CgitConfig {
+ path: std::path::PathBuf,
+ cache: std::time::Duration,
}
-impl Config {
- fn from_environment() -> std::io::Result<Self> {
- let listen_addr = environment("GILTI_HTTP_ADDR", DEFAULT_LISTEN_ADDR)?
- .parse()
- .map_err(|_| invalid_config("GILTI_HTTP_ADDR must be a socket address"))?;
- Ok(Self {
- listen_addr,
- root_title: environment("GILTI_ROOT_TITLE", DEFAULT_ROOT_TITLE)?,
- root_description: environment("GILTI_ROOT_DESCRIPTION", DEFAULT_ROOT_DESCRIPTION)?,
- clone_prefix: environment("GILTI_CLONE_PREFIX", "")?,
- http_write: parse_bool("GILTI_HTTP_WRITE", &environment("GILTI_HTTP_WRITE", "0")?)?,
- })
+impl CgitConfig {
+ fn create() -> std::io::Result<Self> {
+ let contents = std::fs::read_to_string(CGIT_CONFIG)?;
+ let (contents, cache) = prepare_cgit_config(&contents)?;
+ let path = std::path::PathBuf::from(format!("{RUN_DIR}/cgitrc.{}", std::process::id()));
+ let mut options = std::fs::OpenOptions::new();
+ options.write(true).create_new(true);
+ std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
+ let mut file = options.open(&path)?;
+ let config = Self { path, cache };
+ std::io::Write::write_all(&mut file, contents.as_bytes())?;
+ Ok(config)
}
}
-fn environment(name: &str, default: &str) -> std::io::Result<String> {
- match std::env::var(name) {
- Ok(value) => Ok(value),
- Err(std::env::VarError::NotPresent) => Ok(default.to_owned()),
- Err(std::env::VarError::NotUnicode(_)) => {
- Err(invalid_config(format!("{name} must be valid UTF-8")))
+fn prepare_cgit_config(contents: &str) -> std::io::Result<(String, std::time::Duration)> {
+ let mut output = String::with_capacity(contents.len());
+ let mut cache = None;
+
+ for line in contents.split_inclusive('\n') {
+ let value = line
+ .trim_end_matches(['\r', '\n'])
+ .trim_start()
+ .strip_prefix("cache=");
+ let Some(value) = value else {
+ output.push_str(line);
+ continue;
+ };
+ if cache.is_some() {
+ return Err(invalid_config("cache is configured more than once"));
}
+ let seconds = value
+ .trim()
+ .parse::<u64>()
+ .map_err(|_| invalid_config("cache must be an integer number of seconds"))?;
+ if seconds > MAX_CACHE_SECONDS {
+ return Err(invalid_config(format!(
+ "cache must not exceed {MAX_CACHE_SECONDS} seconds"
+ )));
+ }
+ cache = Some(std::time::Duration::from_secs(seconds));
}
-}
-fn parse_bool(name: &str, value: &str) -> std::io::Result<bool> {
- match value {
- "0" => Ok(false),
- "1" => Ok(true),
- _ => Err(invalid_config(format!("{name} must be 0 or 1"))),
- }
+ Ok((output, cache.unwrap_or_default()))
}
fn invalid_config(message: impl Into<String>) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, message.into())
}
+impl Drop for CgitConfig {
+ fn drop(&mut self) {
+ match std::fs::remove_file(&self.path) {
+ Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
+ eprintln!("gilti: cannot remove {}: {error}", self.path.display());
+ }
+ _ => {}
+ }
+ }
+}
+
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
- let config = Config::from_environment()?;
- let listen_addr = config.listen_addr;
- let git_http_backend = git_http_backend()?;
- check_files(&git_http_backend)?;
+ let listen_addr = std::env::var("GILTI_HTTP_ADDR")
+ .unwrap_or_else(|_| DEFAULT_LISTEN_ADDR.to_owned())
+ .parse::<std::net::SocketAddr>()?;
+ check_files()?;
+ let cgit_config = CgitConfig::create()?;
if std::env::args().nth(1).as_deref() == Some("--check") {
return Ok(());
}
- let views = views::shared::Context {
- repositories: REPOSITORIES,
- root_title: std::sync::Arc::from(config.root_title.clone()),
- root_description: std::sync::Arc::from(config.root_description.clone()),
- clone_prefix: std::sync::Arc::from(config.clone_prefix.clone()),
- };
- let git = cgi::Cgi::new(git_http_backend, GIT_HOME, config.listen_addr)
- .env("GIT_PROJECT_ROOT", REPOSITORIES)
- .env("GIT_HTTP_EXPORT_ALL", "1")
- .env("HOME", GIT_HOME)
- .env("USER", "git")
- .env("LOGNAME", "git")
- .env("GIT_CONFIG_GLOBAL", "/dev/null")
- .env("GIT_CONFIG_NOSYSTEM", "1")
- .env("PATH", "/usr/bin:/bin");
- let repositories = RepositoryService {
- git,
- views,
- write_enabled: config.http_write,
+ let state = AppState {
+ cgit: cgi::Cgi::new(CGIT, GIT_HOME, listen_addr)
+ .cache(cgit_config.cache)
+ .env("CGIT_CONFIG", cgit_config.path.as_os_str())
+ .env("HOME", GIT_HOME)
+ .env("PATH", "/usr/bin:/bin"),
};
let app = axum::Router::new()
.route(
- "/-/health",
- axum::routing::get(async || {
- response(
- axum::http::StatusCode::OK,
- "application/json",
- b"{\"status\":\"ok\"}\n".to_vec(),
- )
- }),
+ "/healthz",
+ axum::routing::get(async || plain_response(axum::http::StatusCode::OK, "ok\n")),
)
.route(
- "/-/about",
- axum::routing::get(async || {
- plain_response(axum::http::StatusCode::OK, "Gilti Git server\n")
- }),
+ "/cgit.css",
+ axum::routing::get(async || static_file(CGIT_CSS, "text/css")),
)
.route(
- "/-/terms",
- axum::routing::get(async || {
- plain_response(axum::http::StatusCode::OK, "No additional terms of use.\n")
- }),
+ "/cgit.js",
+ axum::routing::get(async || static_file(CGIT_JS, "text/javascript")),
)
.route(
- "/-/assets/gilti.css",
- axum::routing::get(async || static_file(ASSET_CSS, "text/css")),
+ "/cgit.png",
+ axum::routing::get(async || static_file(CGIT_LOGO, "image/png")),
)
.route(
- "/-/assets/gilti.js",
- axum::routing::get(async || static_file(ASSET_JS, "text/javascript")),
+ "/favicon.ico",
+ axum::routing::get(async || static_file(CGIT_FAVICON, "image/x-icon")),
)
- .route(
- "/-/assets/gilti.png",
- axum::routing::get(async || static_file(ASSET_LOGO, "image/png")),
- )
- .route(
- "/-/assets/favicon.ico",
- axum::routing::get(async || static_file(ASSET_FAVICON, "image/x-icon")),
- )
- .fallback_service(repositories);
+ .fallback(proxy_to_cgit)
+ .with_state(state);
let listener = tokio::net::TcpListener::bind(listen_addr).await?;
eprintln!("gilti: listening on {listen_addr}");
@@ -166,32 +144,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
-fn git_http_backend() -> std::io::Result<std::path::PathBuf> {
- GIT_HTTP_BACKENDS
- .iter()
- .map(std::path::PathBuf::from)
- .find(|path| path.is_file())
- .ok_or_else(|| {
- std::io::Error::new(std::io::ErrorKind::NotFound, "git-http-backend not found")
- })
-}
-
-fn check_files(git_http_backend: &std::path::Path) -> std::io::Result<()> {
- for path in std::iter::once(git_http_backend)
- .chain(std::iter::once(std::path::Path::new(GIT)))
- .chain(ARCHIVE_COMPRESSORS.iter().map(std::path::Path::new))
+fn check_files() -> std::io::Result<()> {
+ let metadata = std::fs::metadata(CGIT)?;
+ if !metadata.is_file()
+ || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0
{
- let metadata = std::fs::metadata(path)?;
- if !metadata.is_file()
- || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0
- {
- return Err(std::io::Error::other(format!(
- "{} is not executable",
- path.display()
- )));
- }
+ return Err(std::io::Error::other(format!("{CGIT} is not executable")));
}
- for path in [ASSET_CSS, ASSET_JS, ASSET_LOGO, ASSET_FAVICON] {
+ for path in [CGIT_CONFIG, CGIT_CSS, CGIT_JS, CGIT_LOGO, CGIT_FAVICON] {
if !std::fs::metadata(path)?.is_file() {
return Err(std::io::Error::other(format!(
"{path} is not a regular file"
@@ -222,376 +182,64 @@ fn static_file(path: &str, content_type: &'static str) -> axum::response::Respon
}
}
-impl tower::Service<axum::extract::Request> for RepositoryService {
- type Response = axum::response::Response;
- type Error = std::convert::Infallible;
- type Future = std::pin::Pin<
- Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
- >;
-
- fn poll_ready(
- &mut self,
- _context: &mut std::task::Context<'_>,
- ) -> std::task::Poll<Result<(), Self::Error>> {
- std::task::Poll::Ready(Ok(()))
- }
-
- fn call(&mut self, request: axum::extract::Request) -> Self::Future {
- let service = self.clone();
- Box::pin(async move { Ok(service.execute(request).await) })
- }
-}
-
-impl RepositoryService {
- async fn execute(&self, mut request: axum::extract::Request) -> axum::response::Response {
- let route = match router::parse(request.uri().path()) {
- Ok(route) => route,
- Err(_) => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"),
- };
- if let Some(axum::extract::ConnectInfo(remote)) = request
- .extensions()
- .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
- .copied()
- {
- request.extensions_mut().insert(cgi::RemoteAddr(remote));
- }
- let browser_view = matches!(
- &route,
- router::Route::Repositories
- | router::Route::Overview(_)
- | router::Route::About(_)
- | router::Route::Stats(_)
- | router::Route::Object(_)
- | router::Route::Refs(_)
- | router::Route::Revision(_)
- | router::Route::Tree(_)
- | router::Route::Blame(_)
- | router::Route::Archive(_)
- | router::Route::ArchiveSignature(_)
- | router::Route::Diff(_)
- | router::Route::Patch(_)
- | router::Route::Log(_)
- | router::Route::AtomFeed(_)
- );
- let query = if browser_view {
- let query = match request_query(request.uri().query()) {
- Ok(query) => query,
- Err(()) => {
- return plain_response(axum::http::StatusCode::BAD_REQUEST, "bad query\n");
- }
- };
- if !valid_format(&route, query.format.as_deref()) {
- return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n");
- }
- Some(query)
- } else {
- None
- };
- let format = query.as_ref().and_then(|query| query.format.as_deref());
-
- match route {
- router::Route::Repositories => {
- views::repositories::serve(
- &self.views,
- views::repositories::Query::from_request(query.as_ref().expect("query parsed")),
- request.method().clone(),
- )
- .await
- }
- router::Route::Summary(route) => redirect(&route.repo),
- router::Route::GitClone(route) => redirect(&route.repo),
- router::Route::Overview(route) => {
- views::overview::serve(
- &self.views,
- route,
- request.headers(),
- request.method().clone(),
- )
- .await
- }
- router::Route::About(route) => {
- views::about::serve(&self.views, route, request.method().clone()).await
- }
- router::Route::Stats(route) => {
- let query = match views::stats::Query::from_request(
- query.as_ref().expect("query parsed"),
- ) {
- Ok(query) => query,
- Err(views::stats::QueryError::BadRequest) => {
- return views::bad_request("bad query\n");
- }
- Err(views::stats::QueryError::NotFound) => {
- return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n");
- }
- };
- views::stats::serve(&self.views, route, query, request.method().clone()).await
- }
- router::Route::Object(route) => {
- views::object::serve(REPOSITORIES, route, request.method().clone()).await
- }
- router::Route::Refs(route) => {
- views::refs::serve(&self.views, route, request.method().clone()).await
- }
- router::Route::Tree(route) => {
- views::tree::serve(&self.views, route, format, request.method().clone()).await
- }
- router::Route::Blame(route) => {
- views::blame::serve(&self.views, route, request.method().clone()).await
- }
- router::Route::Archive(route) => {
- let Some(format) = views::archive::Format::parse(format) else {
- return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n");
- };
- views::archive::serve(&self.views, route, format, request.method().clone()).await
- }
- router::Route::ArchiveSignature(route) => {
- views::archive_signature::serve(
- &self.views,
- route,
- format,
- request.method().clone(),
- )
- .await
- }
- router::Route::Revision(route)
- if matches!(
- &route.params,
- router::Revision::Ref(reference) if reference.starts_with("refs/tags/")
- ) =>
- {
- views::tag::serve(&self.views, route, request.method().clone()).await
- }
- router::Route::Revision(route) => {
- let query =
- match views::diff::Query::from_request(query.as_ref().expect("query parsed")) {
- Ok(query) => query,
- Err(()) => return views::bad_request("bad query\n"),
- };
- views::revision::serve(&self.views, route, query, request.method().clone()).await
- }
- router::Route::Diff(route) => {
- let query =
- match views::diff::Query::from_request(query.as_ref().expect("query parsed")) {
- Ok(query) => query,
- Err(()) => return views::bad_request("bad query\n"),
- };
- views::diff::serve(
- &self.views,
- route,
- query,
- format == Some("raw"),
- request.method().clone(),
- )
- .await
- }
- router::Route::Patch(route) => {
- views::patch::serve(&self.views, route, request.method().clone()).await
- }
- router::Route::Log(route) => {
- let query =
- match views::log::Query::from_request(query.as_ref().expect("query parsed")) {
- Ok(query) => query,
- Err(()) => return views::bad_request("bad query\n"),
- };
- views::log::serve(&self.views, route, query, request.method().clone()).await
- }
- router::Route::AtomFeed(route) => {
- views::atom::serve(
- &self.views,
- route,
- request.headers().get(axum::http::header::HOST),
- request.method().clone(),
- )
- .await
- }
- router::Route::GitLfs(route) => {
- lfs::serve(
- std::path::Path::new(REPOSITORIES),
- &route.repo,
- &route.params,
- self.write_enabled,
- request,
- )
- .await
- }
- router::Route::GitInfoRefs(route) => {
- self.git(request, route.repo, "info/refs", self.write_enabled)
- .await
- }
- router::Route::GitUploadPack(route) => {
- self.git(request, route.repo, "git-upload-pack", false)
- .await
- }
- router::Route::GitReceivePack(route) if self.write_enabled => {
- self.git(request, route.repo, "git-receive-pack", true)
- .await
- }
- router::Route::GitReceivePack(_) => {
- plain_response(axum::http::StatusCode::FORBIDDEN, "HTTP push is disabled\n")
- }
- router::Route::GitHead(route) => self.git(request, route.repo, "HEAD", false).await,
- router::Route::GitObjects(route) => {
- self.git(
- request,
- route.repo,
- &format!("objects/{}", route.params),
- false,
- )
- .await
- }
- }
+async fn proxy_to_cgit(
+ axum::extract::State(state): axum::extract::State<AppState>,
+ axum::extract::ConnectInfo(remote): axum::extract::ConnectInfo<std::net::SocketAddr>,
+ mut request: axum::extract::Request,
+) -> axum::response::Response {
+ if request.method() != axum::http::Method::GET && request.method() != axum::http::Method::HEAD {
+ return plain_response(axum::http::StatusCode::FORBIDDEN, "forbidden\n");
}
+ request.extensions_mut().insert(cgi::RemoteAddr(remote));
- async fn git(
- &self,
- mut request: axum::extract::Request,
- repo: String,
- endpoint: &str,
- authenticated: bool,
- ) -> axum::response::Response {
- if !safe_repository(&repo) {
- return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n");
- }
- let mut environment = vec![
- ("PATH_INFO".into(), format!("/{repo}.git/{endpoint}").into()),
- ("GIT_PROJECT_ROOT".into(), REPOSITORIES.into()),
- ];
- if authenticated {
- environment.push(("REMOTE_USER".into(), "gilti".into()));
- }
- request
- .extensions_mut()
- .insert(cgi::Environment(environment));
- match tower::ServiceExt::oneshot(self.git.clone(), request).await {
- Ok(response) => response,
- Err(error) => internal_error("git-http-backend", error),
+ let method = request.method().clone();
+ match tower::ServiceExt::oneshot(state.cgit.clone(), request).await {
+ Ok(response) => render_private_page(method, response).await,
+ Err(error) => {
+ eprintln!("gilti: cgit request failed: {error}");
+ plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n")
}
}
}
-#[derive(Default)]
-struct RequestQuery {
- format: Option<String>,
- environment: Vec<(std::ffi::OsString, std::ffi::OsString)>,
-}
-
-impl RequestQuery {
- fn value(&self, name: &str) -> Option<&str> {
- self.environment
- .iter()
- .find(|(key, _)| key == std::ffi::OsStr::new(name))
- .and_then(|(_, value)| value.to_str())
+async fn render_private_page(
+ method: axum::http::Method,
+ response: axum::response::Response,
+) -> axum::response::Response {
+ let private_page = response
+ .headers()
+ .get(axum::http::header::CONTENT_TYPE)
+ .is_some_and(|value| value.as_bytes() == ui::PRIVATE_CONTENT_TYPE.as_bytes());
+ if !private_page {
+ return response;
}
-}
-fn request_query(query: Option<&str>) -> Result<RequestQuery, ()> {
- let mut result = RequestQuery::default();
- for pair in query
- .unwrap_or("")
- .split('&')
- .filter(|pair| !pair.is_empty())
- {
- let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
- let name = decode_query(name)?;
- let value = decode_query(value)?;
- if name == "format" {
- if result.format.replace(value).is_some() {
- return Err(());
- }
- continue;
- }
- let environment = match name.as_str() {
- "q" => "GILTI_QUERY_SEARCH",
- "qt" => "GILTI_QUERY_GREP",
- "ofs" => "GILTI_QUERY_OFFSET",
- "s" => "GILTI_QUERY_SORT",
- "showmsg" => "GILTI_QUERY_SHOWMSG",
- "period" => "GILTI_QUERY_PERIOD",
- "dt" => "GILTI_QUERY_DIFFTYPE",
- "context" => "GILTI_QUERY_CONTEXT",
- "ignorews" => "GILTI_QUERY_IGNOREWS",
- "follow" => "GILTI_QUERY_FOLLOW",
- "view" => "GILTI_QUERY_VIEW",
- _ => return Err(()),
- };
- if result
- .environment
- .iter()
- .any(|(existing, _)| existing == std::ffi::OsStr::new(environment))
- {
- return Err(());
- }
- result.environment.push((environment.into(), value.into()));
+ let (mut parts, body) = response.into_parts();
+ parts.headers.insert(
+ axum::http::header::CONTENT_TYPE,
+ axum::http::HeaderValue::from_static("text/html; charset=UTF-8"),
+ );
+ parts.headers.remove(axum::http::header::CONTENT_LENGTH);
+ if method == axum::http::Method::HEAD {
+ return axum::http::response::Response::from_parts(parts, axum::body::Body::empty());
}
- Ok(result)
-}
-
-fn decode_query(value: &str) -> Result<String, ()> {
- let value = value.replace('+', " ");
- let value = percent_encoding::percent_decode_str(&value)
- .decode_utf8()
- .map_err(|_| ())?
- .into_owned();
- (!value.contains('\0')).then_some(value).ok_or(())
-}
-
-fn valid_format(route: &router::Route, format: Option<&str>) -> bool {
- let Some(format) = format else {
- return true;
+ let body = match axum::body::to_bytes(body, usize::MAX).await {
+ Ok(body) => body,
+ Err(error) => {
+ eprintln!("gilti: cannot read private page response: {error}");
+ return plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n");
+ }
};
- match route {
- router::Route::Tree(_) | router::Route::Diff(_) => matches!(format, "html" | "raw"),
- router::Route::Object(_) => format == "raw",
- router::Route::Patch(_) => matches!(format, "patch" | "raw"),
- router::Route::Archive(_) | router::Route::ArchiveSignature(_) => matches!(
- format,
- "tar" | "tar.gz" | "tar.bz2" | "tar.lz" | "tar.xz" | "tar.zst" | "zip"
+ match ui::render(&body) {
+ Ok(markup) => axum::http::response::Response::from_parts(
+ parts,
+ axum::body::Body::from(markup.into_string()),
),
- router::Route::AtomFeed(_) => format == "atom",
- _ => format == "html",
- }
-}
-
-fn safe_repository(repo: &str) -> bool {
- !repo.is_empty()
- && !repo.starts_with('/')
- && !repo.chars().any(char::is_control)
- && !repo.contains('\\')
- && repo
- .split('/')
- .all(|component| !component.is_empty() && !matches!(component, "." | ".."))
-}
-
-fn redirect(repo: &str) -> axum::response::Response {
- axum::http::Response::builder()
- .status(axum::http::StatusCode::PERMANENT_REDIRECT)
- .header(
- axum::http::header::LOCATION,
- format!("/{}", encode_path(repo)),
- )
- .body(axum::body::Body::empty())
- .expect("valid redirect")
-}
-
-fn encode_path(value: &str) -> String {
- let mut encoded = String::new();
- for byte in value.bytes() {
- if byte.is_ascii_alphanumeric() || byte == b'/' || byte == b'_' {
- encoded.push(char::from(byte));
- } else {
- use std::fmt::Write;
- write!(encoded, "%{byte:02X}").expect("writing to String cannot fail");
+ Err(error) => {
+ eprintln!("gilti: invalid private page response: {error}");
+ plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n")
}
}
- encoded
-}
-
-fn internal_error(context: &str, error: std::io::Error) -> axum::response::Response {
- eprintln!("gilti: {context} request failed: {error}");
- plain_response(
- axum::http::StatusCode::INTERNAL_SERVER_ERROR,
- "internal server error\n",
- )
}
fn response(
@@ -616,9 +264,93 @@ fn plain_response(
#[cfg(test)]
mod tests {
#[test]
- fn structural_query_parameters_are_rejected() {
- assert!(super::request_query(Some("id=HEAD")).is_err());
- assert!(super::request_query(Some("path=README.md")).is_err());
- assert!(super::request_query(Some("format=raw&format=html")).is_err());
+ fn extracts_cache_from_cgit_config() {
+ let (contents, cache) =
+ super::prepare_cgit_config("# comment\r\ncache=5\r\nroot-title=Gilti\r\n").unwrap();
+ assert_eq!(contents, "# comment\r\nroot-title=Gilti\r\n");
+ assert_eq!(cache, std::time::Duration::from_secs(5));
+ }
+
+ #[test]
+ fn cache_is_optional_and_bounded() {
+ let (_, cache) = super::prepare_cgit_config("root-title=Gilti\n").unwrap();
+ assert!(cache.is_zero());
+ assert!(super::prepare_cgit_config("cache=1\ncache=2\n").is_err());
+ assert!(super::prepare_cgit_config("cache=3601\n").is_err());
+ assert!(super::prepare_cgit_config("cache=forever\n").is_err());
+ }
+
+ #[tokio::test]
+ async fn private_pages_become_html_and_head_keeps_headers() {
+ let model = br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/?p=about","noheader":true,"search":"","current_url":"/","root_readme":false,"owner_enabled":false,"links_enabled":false,"section_grouping":false,"shell":{"embedded":false,"robots":"","css":[],"js":[],"favicon":"","head_include":null,"header":null,"footer_configured":false,"footer":null,"logo":"","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[],"pager":[]}"#;
+ let response = axum::http::Response::builder()
+ .status(axum::http::StatusCode::OK)
+ .header(
+ axum::http::header::CONTENT_TYPE,
+ super::ui::PRIVATE_CONTENT_TYPE,
+ )
+ .header(axum::http::header::CACHE_CONTROL, "max-age=60")
+ .header(axum::http::header::CONTENT_LENGTH, model.len())
+ .body(axum::body::Body::from(model.as_slice()))
+ .unwrap();
+ let response = super::render_private_page(axum::http::Method::GET, response).await;
+ assert_eq!(
+ response.headers()[axum::http::header::CONTENT_TYPE],
+ "text/html; charset=UTF-8"
+ );
+ assert_eq!(
+ response.headers()[axum::http::header::CACHE_CONTROL],
+ "max-age=60"
+ );
+ assert!(
+ !response
+ .headers()
+ .contains_key(axum::http::header::CONTENT_LENGTH)
+ );
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .unwrap();
+ assert!(
+ std::str::from_utf8(&body)
+ .unwrap()
+ .contains("repository list")
+ );
+
+ let response = axum::http::Response::builder()
+ .header(
+ axum::http::header::CONTENT_TYPE,
+ super::ui::PRIVATE_CONTENT_TYPE,
+ )
+ .header(axum::http::header::CACHE_CONTROL, "max-age=60")
+ .body(axum::body::Body::empty())
+ .unwrap();
+ let response = super::render_private_page(axum::http::Method::HEAD, response).await;
+ assert_eq!(
+ response.headers()[axum::http::header::CONTENT_TYPE],
+ "text/html; charset=UTF-8"
+ );
+ assert!(
+ axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .unwrap()
+ .is_empty()
+ );
+ }
+
+ #[tokio::test]
+ async fn malformed_private_page_fails_closed() {
+ let response = axum::http::Response::builder()
+ .header(
+ axum::http::header::CONTENT_TYPE,
+ super::ui::PRIVATE_CONTENT_TYPE,
+ )
+ .body(axum::body::Body::from("not json"))
+ .unwrap();
+ assert_eq!(
+ super::render_private_page(axum::http::Method::GET, response)
+ .await
+ .status(),
+ axum::http::StatusCode::BAD_GATEWAY
+ );
}
}
diff --git a/crates/gilti/src/models/about.rs b/crates/gilti/src/models/about.rs
deleted file mode 100644
--- a/crates/gilti/src/models/about.rs
+++ /dev/null
@@ -1,31 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::Path;
-
-pub struct About {
- pub repository: super::repository::Info,
- pub bytes: Vec<u8>,
-}
-
-impl About {
- pub fn load(root: &Path, name: &str) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, name)?;
- let info = super::repository::info(&repository, name);
- let commit = super::revision::commit(&repository, &crate::router::Revision::Head)?;
- let tree = commit.tree().map_err(super::Error::from_git)?;
- let blob = ["README.md", "README"]
- .iter()
- .find_map(|path| {
- tree.get_path(Path::new(path))
- .ok()
- .filter(|entry| entry.kind() == Some(git2::ObjectType::Blob))
- .and_then(|entry| repository.find_blob(entry.id()).ok())
- })
- .ok_or(super::Error::NotFound)?;
- Ok(Self {
- repository: info,
- bytes: blob.content().to_vec(),
- })
- }
-}
diff --git a/crates/gilti/src/models/archive.rs b/crates/gilti/src/models/archive.rs
deleted file mode 100644
--- a/crates/gilti/src/models/archive.rs
+++ /dev/null
@@ -1,41 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::{Path, PathBuf};
-
-pub struct Archive {
- pub repository_path: PathBuf,
- pub oid: String,
- pub prefix: String,
-}
-
-impl Archive {
- pub fn load(
- root: &Path,
- name: &str,
- revision: &crate::router::Revision,
- path: Option<&str>,
- ) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, name)?;
- let commit = super::revision::commit(&repository, revision)?;
- if let Some(path) = path {
- commit
- .tree()
- .and_then(|tree| tree.get_path(Path::new(path)))
- .map_err(|_| super::Error::NotFound)?;
- }
- let repository_path = repository.path().to_owned();
- let prefix = name
- .rsplit('/')
- .next()
- .unwrap_or(name)
- .strip_suffix(".git")
- .unwrap_or_else(|| name.rsplit('/').next().unwrap_or(name))
- .to_owned();
- Ok(Self {
- repository_path,
- oid: commit.id().to_string(),
- prefix,
- })
- }
-}
diff --git a/crates/gilti/src/models/archive_signature.rs b/crates/gilti/src/models/archive_signature.rs
deleted file mode 100644
--- a/crates/gilti/src/models/archive_signature.rs
+++ /dev/null
@@ -1,31 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::Path;
-
-pub struct ArchiveSignature {
- pub bytes: Vec<u8>,
- pub oid: String,
- pub filename: String,
-}
-
-impl ArchiveSignature {
- pub fn load(
- root: &Path,
- name: &str,
- revision: crate::router::Revision,
- format: &str,
- ) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, name)?;
- let commit = super::revision::commit(&repository, &revision)?;
- let notes_ref = format!("refs/notes/signatures/{format}");
- let note = repository
- .find_note(Some(&notes_ref), commit.id())
- .map_err(super::Error::from_git)?;
- Ok(Self {
- bytes: note.message_bytes().to_vec(),
- oid: note.id().to_string(),
- filename: format!("{}.{}.asc", name.rsplit('/').next().unwrap_or(name), format),
- })
- }
-}
diff --git a/crates/gilti/src/models/blame.rs b/crates/gilti/src/models/blame.rs
deleted file mode 100644
--- a/crates/gilti/src/models/blame.rs
+++ /dev/null
@@ -1,114 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::Path;
-
-pub struct Blame {
- pub repository: super::repository::Info,
- pub revision: String,
- pub path: String,
- pub oid: String,
- pub bytes: Vec<u8>,
- pub binary: bool,
- pub hunks: Vec<Hunk>,
-}
-
-pub struct Hunk {
- pub oid: String,
- pub short_oid: String,
- pub start: usize,
- pub lines: usize,
- pub original_path: String,
- pub author: String,
- pub author_email: String,
- pub committer: String,
- pub committer_email: String,
- pub timestamp: i64,
- pub summary: String,
- pub parent: Option<String>,
-}
-
-impl Blame {
- pub fn load(
- root: &Path,
- name: &str,
- revision: crate::router::Revision,
- path: String,
- ) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, name)?;
- let info = super::repository::info(&repository, name);
- let selector = super::revision::selector(&revision);
- let commit = super::revision::commit(&repository, &revision)?;
- let tree = commit.tree().map_err(super::Error::from_git)?;
- let entry = tree
- .get_path(Path::new(&path))
- .map_err(super::Error::from_git)?;
- if !matches!(entry.filemode_raw(), 0o100644 | 0o100755) {
- return Err(super::Error::NotFound);
- }
- let blob = repository
- .find_blob(entry.id())
- .map_err(super::Error::from_git)?;
- let bytes = blob.content().to_vec();
- let binary = bytes.iter().take(8000).any(|byte| *byte == 0);
- let mut hunks = Vec::new();
- if !binary {
- let mut options = git2::BlameOptions::new();
- options.newest_commit(commit.id());
- let blame = repository
- .blame_file(Path::new(&path), Some(&mut options))
- .map_err(super::Error::from_git)?;
- for hunk in blame.iter() {
- let oid = hunk.final_commit_id();
- let commit = repository
- .find_commit(oid)
- .map_err(super::Error::from_git)?;
- let author = hunk.final_signature().unwrap_or_else(|| commit.author());
- let committer = hunk.final_committer().unwrap_or_else(|| commit.committer());
- hunks.push(Hunk {
- oid: oid.to_string(),
- short_oid: oid.to_string().chars().take(7).collect(),
- start: hunk.final_start_line(),
- lines: hunk.lines_in_hunk(),
- original_path: hunk
- .path()
- .map_or_else(|| path.clone(), |path| path.to_string_lossy().into_owned()),
- author: signature_name(&author),
- author_email: signature_email(&author),
- committer: signature_name(&committer),
- committer_email: signature_email(&committer),
- timestamp: author.when().seconds(),
- summary: String::from_utf8_lossy(
- hunk.summary_bytes()
- .unwrap_or(commit.summary_bytes().unwrap_or_default()),
- )
- .into_owned(),
- parent: commit.parent_id(0).ok().map(|oid| oid.to_string()),
- });
- }
- }
- Ok(Self {
- repository: info,
- revision: selector,
- path,
- oid: entry.id().to_string(),
- bytes,
- binary,
- hunks,
- })
- }
-}
-
-fn signature_name(signature: &git2::Signature<'_>) -> String {
- signature
- .name()
- .map(str::to_owned)
- .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned())
-}
-
-fn signature_email(signature: &git2::Signature<'_>) -> String {
- signature
- .email()
- .map(str::to_owned)
- .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned())
-}
diff --git a/crates/gilti/src/models/commit.rs b/crates/gilti/src/models/commit.rs
deleted file mode 100644
--- a/crates/gilti/src/models/commit.rs
+++ /dev/null
@@ -1,151 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::Path;
-
-pub struct Commit {
- pub repository: super::repository::Info,
- pub revision: String,
- pub oid: String,
- pub tree: String,
- pub author: Identity,
- pub committer: Identity,
- pub subject: String,
- pub message: String,
- pub parents: Vec<String>,
- pub decorations: Vec<Decoration>,
- pub notes: Option<String>,
- pub diff: Option<super::diff::Diff>,
-}
-
-pub struct Identity {
- pub name: String,
- pub email: String,
- pub timestamp: i64,
- pub offset_minutes: i32,
-}
-
-pub struct Decoration {
- pub label: String,
- pub reference: Option<String>,
-}
-
-impl Commit {
- pub fn load(
- root: &Path,
- name: &str,
- revision: crate::router::Revision,
- diff_options: super::diff::Options,
- ) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, name)?;
- let info = super::repository::info(&repository, name);
- let commit = super::revision::commit(&repository, &revision)?;
- let oid = commit.id();
- let parents = commit
- .parent_ids()
- .map(|id| id.to_string())
- .collect::<Vec<_>>();
- let author = identity(commit.author());
- let committer = identity(commit.committer());
- let message = String::from_utf8_lossy(commit.message_bytes());
- let subject =
- String::from_utf8_lossy(commit.summary_bytes().unwrap_or_default()).into_owned();
- let body = message
- .split_once('\n')
- .map_or("", |(_, body)| body.trim_start_matches('\n'))
- .to_owned();
- let notes = repository
- .find_note(None, oid)
- .ok()
- .map(|note| String::from_utf8_lossy(note.message_bytes()).into_owned());
- let refs = super::refs::Refs::load(root, name)?;
- let mut decorations = Vec::new();
- if repository
- .head()
- .ok()
- .and_then(|head| head.peel_to_commit().ok())
- .is_some_and(|head| head.id() == oid)
- {
- decorations.push(Decoration {
- label: "HEAD".to_owned(),
- reference: None,
- });
- }
- for branch in refs.branches {
- if repository
- .find_reference(&branch.reference)
- .ok()
- .and_then(|reference| reference.peel_to_commit().ok())
- .is_some_and(|target| target.id() == oid)
- {
- decorations.push(Decoration {
- label: branch.name,
- reference: Some(branch.reference),
- });
- }
- }
- for tag in refs.tags {
- if repository
- .find_reference(&tag.reference)
- .ok()
- .and_then(|reference| reference.peel_to_commit().ok())
- .is_some_and(|target| target.id() == oid)
- {
- decorations.push(Decoration {
- label: tag.name,
- reference: Some(tag.reference),
- });
- }
- }
- let old_revision = (parents.len() < 3)
- .then(|| parents.first().cloned())
- .flatten()
- .map(crate::router::Revision::Commit);
- let show_diff = parents.len() < 3;
- let revision_name = super::revision::selector(&revision);
- let tree = commit.tree_id().to_string();
- drop(commit);
- drop(repository);
- let diff = show_diff
- .then(|| {
- super::diff::Diff::load(
- root,
- name,
- old_revision,
- revision.clone(),
- None,
- diff_options,
- )
- })
- .transpose()?;
- Ok(Self {
- repository: info,
- revision: revision_name,
- oid: oid.to_string(),
- tree,
- author,
- committer,
- subject,
- message: body,
- parents,
- decorations,
- notes,
- diff,
- })
- }
-}
-
-fn identity(signature: git2::Signature<'_>) -> Identity {
- Identity {
- name: signature
- .name()
- .map(str::to_owned)
- .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()),
- email: signature
- .email()
- .map(str::to_owned)
- .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned()),
- timestamp: signature.when().seconds(),
- offset_minutes: signature.when().offset_minutes(),
- }
-}
diff --git a/crates/gilti/src/models/diff.rs b/crates/gilti/src/models/diff.rs
deleted file mode 100644
--- a/crates/gilti/src/models/diff.rs
+++ /dev/null
@@ -1,159 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::Path;
-
-#[derive(Clone, Copy)]
-pub struct Options {
- pub context: u32,
- pub ignore_whitespace: bool,
-}
-
-pub struct Diff {
- pub repository: super::repository::Info,
- pub old_revision: Option<String>,
- pub new_revision: String,
- pub old_oid: Option<String>,
- pub new_oid: String,
- pub files: Vec<File>,
- pub additions: usize,
- pub deletions: usize,
-}
-
-pub struct File {
- pub status: git2::Delta,
- pub old_path: String,
- pub new_path: String,
- pub old_oid: Option<String>,
- pub new_oid: Option<String>,
- pub old_mode: u32,
- pub new_mode: u32,
- pub old_size: u64,
- pub new_size: u64,
- pub binary: bool,
- pub additions: usize,
- pub deletions: usize,
- pub hunks: Vec<Hunk>,
-}
-
-pub struct Hunk {
- pub header: String,
- pub lines: Vec<Line>,
-}
-
-pub struct Line {
- pub origin: char,
- pub content: String,
- pub old_line: Option<u32>,
- pub new_line: Option<u32>,
-}
-
-impl Diff {
- pub fn load(
- root: &Path,
- name: &str,
- old_revision: Option<crate::router::Revision>,
- new_revision: crate::router::Revision,
- path: Option<String>,
- options: Options,
- ) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, name)?;
- let info = super::repository::info(&repository, name);
- let new_commit = super::revision::commit(&repository, &new_revision)?;
- let old_commit = old_revision
- .as_ref()
- .map(|revision| super::revision::commit(&repository, revision))
- .transpose()?;
- let old_tree = old_commit
- .as_ref()
- .map(git2::Commit::tree)
- .transpose()
- .map_err(super::Error::from_git)?;
- let new_tree = new_commit.tree().map_err(super::Error::from_git)?;
- let mut diff_options = git2::DiffOptions::new();
- diff_options.context_lines(options.context);
- if options.ignore_whitespace {
- diff_options.ignore_whitespace(true);
- }
- if let Some(path) = &path {
- diff_options.pathspec(path).disable_pathspec_match(true);
- }
- let mut diff = repository
- .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut diff_options))
- .map_err(super::Error::from_git)?;
- diff.find_similar(None).map_err(super::Error::from_git)?;
- let stats = diff.stats().map_err(super::Error::from_git)?;
- let mut files = Vec::with_capacity(diff.deltas().len());
- for (index, delta) in diff.deltas().enumerate() {
- let old_file = delta.old_file();
- let new_file = delta.new_file();
- let patch = git2::Patch::from_diff(&diff, index).map_err(super::Error::from_git)?;
- let (additions, deletions, hunks) = if let Some(patch) = patch {
- let (_, additions, deletions) =
- patch.line_stats().map_err(super::Error::from_git)?;
- let mut hunks = Vec::with_capacity(patch.num_hunks());
- for hunk_index in 0..patch.num_hunks() {
- let (hunk, lines) = patch.hunk(hunk_index).map_err(super::Error::from_git)?;
- let mut result = Hunk {
- header: String::from_utf8_lossy(hunk.header())
- .trim_end_matches('\n')
- .to_owned(),
- lines: Vec::with_capacity(lines),
- };
- for line_index in 0..lines {
- let line = patch
- .line_in_hunk(hunk_index, line_index)
- .map_err(super::Error::from_git)?;
- result.lines.push(Line {
- origin: line.origin(),
- content: String::from_utf8_lossy(line.content())
- .trim_end_matches('\n')
- .to_owned(),
- old_line: line.old_lineno(),
- new_line: line.new_lineno(),
- });
- }
- hunks.push(result);
- }
- (additions, deletions, hunks)
- } else {
- (0, 0, Vec::new())
- };
- files.push(File {
- status: delta.status(),
- old_path: path_string(old_file.path_bytes()),
- new_path: path_string(new_file.path_bytes()),
- old_oid: oid(old_file.id()),
- new_oid: oid(new_file.id()),
- old_mode: old_file.mode().into(),
- new_mode: new_file.mode().into(),
- old_size: old_file.size(),
- new_size: new_file.size(),
- binary: old_file.is_binary() || new_file.is_binary(),
- additions,
- deletions,
- hunks,
- });
- }
- Ok(Self {
- repository: info,
- old_revision: old_revision.as_ref().map(super::revision::selector),
- new_revision: super::revision::selector(&new_revision),
- old_oid: old_commit.map(|commit| commit.id().to_string()),
- new_oid: new_commit.id().to_string(),
- files,
- additions: stats.insertions(),
- deletions: stats.deletions(),
- })
- }
-}
-
-fn path_string(path: Option<&[u8]>) -> String {
- path.map_or_else(String::new, |path| {
- String::from_utf8_lossy(path).into_owned()
- })
-}
-
-fn oid(oid: git2::Oid) -> Option<String> {
- (!oid.is_zero()).then(|| oid.to_string())
-}
diff --git a/crates/gilti/src/models/history.rs b/crates/gilti/src/models/history.rs
deleted file mode 100644
--- a/crates/gilti/src/models/history.rs
+++ /dev/null
@@ -1,556 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::{collections::BTreeMap, path::Path, process::Command};
-
-pub const LOG_PAGE_SIZE: usize = 50;
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum Search {
- None,
- Grep(String),
- Author(String),
- Committer(String),
- Range(String),
-}
-
-pub struct Options {
- pub path: Option<String>,
- pub follow: bool,
- pub search: Search,
- pub offset: usize,
- pub limit: usize,
- pub graph: bool,
- pub ignore_whitespace: bool,
- pub include_statistics: bool,
-}
-
-pub struct History {
- pub repository: super::repository::Info,
- pub revision: String,
- pub entries: Vec<Entry>,
- pub has_next: bool,
- pub graph: bool,
-}
-
-pub struct Entry {
- pub oid: String,
- pub subject: String,
- pub body: String,
- pub author: super::commit::Identity,
- pub committer: super::commit::Identity,
- pub decorations: Vec<super::commit::Decoration>,
- pub notes: Option<String>,
- pub files: usize,
- pub additions: usize,
- pub deletions: usize,
- pub graph: String,
- pub graph_continuations: Vec<String>,
- pub path: Option<String>,
-}
-
-impl History {
- pub fn load(
- root: &Path,
- name: &str,
- revision: crate::router::Revision,
- options: Options,
- ) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, name)?;
- let info = super::repository::info(&repository, name);
- let resolved = super::revision::commit(&repository, &revision)?;
- let revision_name = super::revision::selector(&revision);
- if let Search::Range(range) = &options.search {
- for selector in range
- .split_ascii_whitespace()
- .flat_map(|selector| selector.split(".."))
- {
- let selector = if selector == "HEAD" {
- crate::router::Revision::Head
- } else if selector.starts_with("refs/") {
- crate::router::Revision::Ref(selector.to_owned())
- } else {
- crate::router::Revision::Commit(selector.to_owned())
- };
- super::revision::commit(&repository, &selector)?;
- }
- }
- let repository_path = super::repository::path(root, name)?;
- let follow = options.follow && options.path.is_some();
- let oids = select(
- &repository_path,
- resolved.id().to_string(),
- &options,
- follow,
- )?;
- let has_next = oids.len() > options.limit;
- let selected = &oids[..oids.len().min(options.limit)];
- let git_statistics = if !options.include_statistics {
- Some(BTreeMap::new())
- } else if follow {
- None
- } else {
- Some(statistics_from_git(
- &repository_path,
- selected,
- options.path.as_deref(),
- options.ignore_whitespace,
- )?)
- };
- let mut decorations = decorations(&repository)?;
- let mut entries = Vec::with_capacity(selected.len());
- let mut path = options.path.clone();
- for selected in selected {
- let oid = git2::Oid::from_str_ext(&selected.oid, repository.object_format())
- .map_err(|_| super::Error::NotFound)?;
- let commit = repository
- .find_commit(oid)
- .map_err(super::Error::from_git)?;
- let (files, additions, deletions) = match &git_statistics {
- Some(statistics) => statistics.get(&selected.oid).copied().unwrap_or_default(),
- None => statistics_from_git2(
- &repository,
- &commit,
- path.as_deref(),
- options.ignore_whitespace,
- )?,
- };
- let message = String::from_utf8_lossy(commit.message_bytes());
- let subject =
- String::from_utf8_lossy(commit.summary_bytes().unwrap_or_default()).into_owned();
- let body = message
- .split_once('\n')
- .map_or("", |(_, body)| body.trim_start_matches('\n'))
- .to_owned();
- let notes = repository
- .find_note(None, oid)
- .ok()
- .map(|note| String::from_utf8_lossy(note.message_bytes()).into_owned());
- entries.push(Entry {
- oid: selected.oid.clone(),
- subject,
- body,
- author: identity(commit.author()),
- committer: identity(commit.committer()),
- decorations: decorations.remove(&oid).unwrap_or_default(),
- notes,
- files,
- additions,
- deletions,
- graph: selected.graph.clone(),
- graph_continuations: selected.graph_continuations.clone(),
- path: path.clone(),
- });
- if follow {
- path = previous_path(&repository, &commit, path)?;
- }
- }
- Ok(Self {
- repository: info,
- revision: revision_name,
- entries,
- has_next,
- graph: options.graph && !follow,
- })
- }
-}
-
-struct Selected {
- oid: String,
- graph: String,
- graph_continuations: Vec<String>,
-}
-
-fn git(path: &Path) -> Command {
- let mut command = Command::new(crate::GIT);
- command
- .env_clear()
- .env("HOME", "/var/empty")
- .env("PATH", "/usr/bin:/bin")
- .env("LC_ALL", "C")
- .env("GIT_CONFIG_GLOBAL", "/dev/null")
- .env("GIT_CONFIG_NOSYSTEM", "1")
- .env("GIT_ATTR_NOSYSTEM", "1")
- .arg("--no-replace-objects")
- .arg("--git-dir")
- .arg(path)
- .arg("-c")
- .arg("core.hooksPath=/dev/null")
- .arg("-c")
- .arg("color.ui=false")
- .arg("--no-pager");
- command
-}
-
-fn select(
- path: &Path,
- revision: String,
- options: &Options,
- follow: bool,
-) -> Result<Vec<Selected>, super::Error> {
- let mut command = git(path);
- command
- .arg("log")
- .arg("--no-ext-diff")
- .arg("--no-textconv")
- .arg("--no-color")
- .arg("--format=format:%x1e%H%x1f")
- .arg(format!("--max-count={}", options.limit.saturating_add(1)))
- .arg(format!("--skip={}", options.offset));
- if options.graph && !follow {
- command.arg("--graph");
- }
- if options.ignore_whitespace {
- command.arg("--ignore-all-space");
- }
- if follow {
- command.arg("--follow");
- }
- match &options.search {
- Search::None => {
- command.arg(revision);
- }
- Search::Grep(value) => {
- command
- .arg("--regexp-ignore-case")
- .arg(format!("--grep={value}"))
- .arg(revision);
- }
- Search::Author(value) => {
- command
- .arg("--regexp-ignore-case")
- .arg(format!("--author={value}"))
- .arg(revision);
- }
- Search::Committer(value) => {
- command
- .arg("--regexp-ignore-case")
- .arg(format!("--committer={value}"))
- .arg(revision);
- }
- Search::Range(value) => {
- for selector in value.split_ascii_whitespace() {
- command.arg(selector);
- }
- }
- }
- if let Some(path) = &options.path {
- command.arg("--").arg(path);
- }
- let output = command
- .output()
- .map_err(|error| super::Error::Internal(error.to_string()))?;
- if !output.status.success() {
- let error = String::from_utf8_lossy(&output.stderr);
- return Err(super::Error::Internal(format!("git log failed: {error}")));
- }
- parse(&output.stdout)
-}
-
-fn parse(bytes: &[u8]) -> Result<Vec<Selected>, super::Error> {
- let text = std::str::from_utf8(bytes)
- .map_err(|_| super::Error::Internal("git log emitted invalid UTF-8".to_owned()))?;
- let mut result = Vec::new();
- let mut prefix_start = 0;
- while let Some(relative) = text[prefix_start..].find('\x1e') {
- let marker = prefix_start + relative;
- let graph_lines = text[prefix_start..marker]
- .strip_prefix('\n')
- .unwrap_or(&text[prefix_start..marker])
- .split_terminator('\n')
- .collect::<Vec<_>>();
- let (graph, graph_continuations) =
- graph_lines
- .split_last()
- .map_or(("", Vec::new()), |(graph, continuations)| {
- (
- *graph,
- continuations
- .iter()
- .map(|line| (*line).to_owned())
- .collect(),
- )
- });
- let remainder = &text[marker + 1..];
- let (oid, _) = remainder.split_once('\x1f').ok_or_else(|| {
- super::Error::Internal("git log emitted an invalid record".to_owned())
- })?;
- let oid = oid.trim();
- if !(matches!(oid.len(), 40 | 64) && oid.bytes().all(|byte| byte.is_ascii_hexdigit())) {
- return Err(super::Error::Internal(
- "git log emitted an invalid object id".to_owned(),
- ));
- }
- result.push(Selected {
- oid: oid.to_owned(),
- graph: graph.to_owned(),
- graph_continuations,
- });
- prefix_start = marker + 1 + oid.len() + 1;
- }
- Ok(result)
-}
-
-fn decorations(
- repository: &git2::Repository,
-) -> Result<BTreeMap<git2::Oid, Vec<super::commit::Decoration>>, super::Error> {
- let mut result = BTreeMap::new();
- if let Ok(head) = repository.head()
- && let Ok(commit) = head.peel_to_commit()
- {
- result
- .entry(commit.id())
- .or_insert_with(Vec::new)
- .push(super::commit::Decoration {
- label: "HEAD".to_owned(),
- reference: None,
- });
- }
- let references = repository.references().map_err(super::Error::from_git)?;
- for reference in references.flatten() {
- let Ok(name) = reference.name() else {
- continue;
- };
- if !name.starts_with("refs/heads/") && !name.starts_with("refs/tags/") {
- continue;
- }
- let Ok(commit) = reference.peel_to_commit() else {
- continue;
- };
- let label = name
- .strip_prefix("refs/heads/")
- .or_else(|| name.strip_prefix("refs/tags/"))
- .unwrap_or(name);
- result
- .entry(commit.id())
- .or_insert_with(Vec::new)
- .push(super::commit::Decoration {
- label: label.to_owned(),
- reference: Some(name.to_owned()),
- });
- }
- Ok(result)
-}
-
-fn statistics_from_git(
- repository: &Path,
- selected: &[Selected],
- path: Option<&str>,
- ignore_whitespace: bool,
-) -> Result<BTreeMap<String, (usize, usize, usize)>, super::Error> {
- if selected.is_empty() {
- return Ok(BTreeMap::new());
- }
- let mut command = git(repository);
- command
- .arg("log")
- .arg("--no-walk=unsorted")
- .arg("--root")
- .arg("--diff-merges=first-parent")
- .arg("--no-ext-diff")
- .arg("--no-textconv")
- .arg("--find-renames")
- .arg("--numstat")
- .arg("-z")
- .arg("--format=format:GILTI-STATS%x00%H%x00");
- if ignore_whitespace {
- command.arg("--ignore-all-space");
- }
- for entry in selected {
- command.arg(&entry.oid);
- }
- if let Some(path) = path {
- command.arg("--").arg(path);
- }
- let output = command
- .output()
- .map_err(|error| super::Error::Internal(error.to_string()))?;
- if !output.status.success() {
- return Err(super::Error::Internal(format!(
- "git log statistics failed: {}",
- String::from_utf8_lossy(&output.stderr)
- )));
- }
- parse_statistics(&output.stdout)
-}
-
-fn parse_statistics(
- output: &[u8],
-) -> Result<BTreeMap<String, (usize, usize, usize)>, super::Error> {
- const MARKER: &[u8] = b"GILTI-STATS\0";
- let mut result = BTreeMap::new();
- let mut cursor = 0;
- while let Some(relative) = output[cursor..]
- .windows(MARKER.len())
- .position(|value| value == MARKER)
- {
- let start = cursor + relative + MARKER.len();
- let oid_end = output[start..]
- .iter()
- .position(|byte| *byte == 0)
- .map(|end| start + end)
- .ok_or_else(|| super::Error::Internal("git statistics omitted an OID".to_owned()))?;
- let oid = std::str::from_utf8(&output[start..oid_end]).map_err(|_| {
- super::Error::Internal("git statistics emitted invalid UTF-8".to_owned())
- })?;
- if !(matches!(oid.len(), 40 | 64) && oid.bytes().all(|byte| byte.is_ascii_hexdigit())) {
- return Err(super::Error::Internal(
- "git statistics emitted an invalid object ID".to_owned(),
- ));
- }
- let next = output[oid_end + 1..]
- .windows(MARKER.len())
- .position(|value| value == MARKER)
- .map_or(output.len(), |offset| oid_end + 1 + offset);
- let mut files = 0;
- let mut additions = 0;
- let mut deletions = 0;
- let mut fields = output[oid_end + 1..next].split(|byte| *byte == 0);
- while let Some(field) = fields.next() {
- let field = field.strip_prefix(b"\n").unwrap_or(field);
- let mut values = field.splitn(3, |byte| *byte == b'\t');
- let (Some(added), Some(deleted), Some(path)) =
- (values.next(), values.next(), values.next())
- else {
- continue;
- };
- let count = |value: &[u8]| {
- (value != b"-")
- .then(|| std::str::from_utf8(value).ok()?.parse::<usize>().ok())
- .flatten()
- .unwrap_or(0)
- };
- if (added == b"-" || added.iter().all(u8::is_ascii_digit))
- && (deleted == b"-" || deleted.iter().all(u8::is_ascii_digit))
- {
- files += 1;
- additions += count(added);
- deletions += count(deleted);
- if path.is_empty() {
- fields.next();
- fields.next();
- }
- }
- }
- result.insert(oid.to_owned(), (files, additions, deletions));
- cursor = next;
- }
- Ok(result)
-}
-
-fn statistics_from_git2(
- repository: &git2::Repository,
- commit: &git2::Commit<'_>,
- path: Option<&str>,
- ignore_whitespace: bool,
-) -> Result<(usize, usize, usize), super::Error> {
- let old_tree = if commit.parent_count() > 0 {
- Some(
- commit
- .parent(0)
- .map_err(super::Error::from_git)?
- .tree()
- .map_err(super::Error::from_git)?,
- )
- } else {
- None
- };
- let new_tree = commit.tree().map_err(super::Error::from_git)?;
- let mut options = git2::DiffOptions::new();
- if ignore_whitespace {
- options.ignore_whitespace(true);
- }
- let mut diff = repository
- .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut options))
- .map_err(super::Error::from_git)?;
- diff.find_similar(None).map_err(super::Error::from_git)?;
- let Some(path) = path else {
- let stats = diff.stats().map_err(super::Error::from_git)?;
- return Ok((stats.files_changed(), stats.insertions(), stats.deletions()));
- };
- let mut files = 0;
- let mut additions = 0;
- let mut deletions = 0;
- for (index, delta) in diff.deltas().enumerate() {
- if delta.old_file().path_bytes() != Some(path.as_bytes())
- && delta.new_file().path_bytes() != Some(path.as_bytes())
- {
- continue;
- }
- files += 1;
- if let Some(patch) = git2::Patch::from_diff(&diff, index).map_err(super::Error::from_git)? {
- let (_, added, deleted) = patch.line_stats().map_err(super::Error::from_git)?;
- additions += added;
- deletions += deleted;
- }
- }
- Ok((files, additions, deletions))
-}
-
-fn previous_path(
- repository: &git2::Repository,
- commit: &git2::Commit<'_>,
- path: Option<String>,
-) -> Result<Option<String>, super::Error> {
- let Some(path) = path else { return Ok(None) };
- if commit.parent_count() == 0 {
- return Ok(Some(path));
- }
- let parent = commit.parent(0).map_err(super::Error::from_git)?;
- let old_tree = parent.tree().map_err(super::Error::from_git)?;
- let new_tree = commit.tree().map_err(super::Error::from_git)?;
- let mut diff = repository
- .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)
- .map_err(super::Error::from_git)?;
- diff.find_similar(None).map_err(super::Error::from_git)?;
- for delta in diff.deltas() {
- if delta.status() == git2::Delta::Renamed
- && delta.new_file().path_bytes() == Some(path.as_bytes())
- {
- return Ok(delta
- .old_file()
- .path_bytes()
- .map(|path| String::from_utf8_lossy(path).into_owned()));
- }
- }
- Ok(Some(path))
-}
-
-fn identity(signature: git2::Signature<'_>) -> super::commit::Identity {
- super::commit::Identity {
- name: signature
- .name()
- .map(str::to_owned)
- .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()),
- email: signature
- .email()
- .map(str::to_owned)
- .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned()),
- timestamp: signature.when().seconds(),
- offset_minutes: signature.when().offset_minutes(),
- }
-}
-
-#[cfg(test)]
-mod tests {
- #[test]
- fn parses_control_delimited_graph_output() {
- let rows = super::parse(b"* \x1e0123456789abcdef0123456789abcdef01234567\x1f\n|\\ \n| * \x1e89abcdef0123456789abcdef0123456789abcdef\x1f\n").unwrap();
- assert_eq!(rows.len(), 2);
- assert_eq!(rows[0].graph, "* ");
- assert_eq!(rows[1].graph, "| * ");
- assert_eq!(rows[1].graph_continuations, ["|\\ "]);
- }
-
- #[test]
- fn parses_git_numstat_records_and_binary_files() {
- let oid = "0123456789abcdef0123456789abcdef01234567";
- let other = "89abcdef0123456789abcdef0123456789abcdef";
- let output = format!(
- "GILTI-STATS\0{oid}\0\0\n1\t2\tfile.txt\0-\t-\tbinary\0GILTI-STATS\0{other}\0\0\n0\t0\t\0old\0new\0"
- );
- let statistics = super::parse_statistics(output.as_bytes()).unwrap();
- assert_eq!(statistics[oid], (2, 1, 2));
- assert_eq!(statistics[other], (1, 0, 0));
- }
-}
diff --git a/crates/gilti/src/models/mod.rs b/crates/gilti/src/models/mod.rs
deleted file mode 100644
--- a/crates/gilti/src/models/mod.rs
+++ /dev/null
@@ -1,143 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-pub mod about;
-pub mod archive;
-pub mod archive_signature;
-pub mod blame;
-pub mod commit;
-pub mod diff;
-pub mod history;
-pub mod object;
-pub mod overview;
-pub mod patch;
-pub mod refs;
-pub mod repositories;
-pub mod repository;
-pub mod revision;
-pub mod stats;
-pub mod tag;
-pub mod time;
-pub mod tree;
-
-#[derive(Debug)]
-pub enum Error {
- NotFound,
- Internal(String),
-}
-
-impl Error {
- pub fn from_git(error: git2::Error) -> Self {
- if matches!(
- error.code(),
- git2::ErrorCode::NotFound | git2::ErrorCode::UnbornBranch
- ) {
- Self::NotFound
- } else {
- Self::Internal(error.message().to_owned())
- }
- }
-}
-
-#[cfg(test)]
-mod migration_tests {
- #[test]
- fn loads_diff_commit_stats_archive_and_patch_models() {
- let root =
- std::env::temp_dir().join(format!("gilti-five-model-test-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&root);
- std::fs::create_dir_all(&root).unwrap();
- let repository = git2::Repository::init_bare(root.join("example.git")).unwrap();
- let signature = git2::Signature::now("Model Tester", "model@example.test").unwrap();
- let first_blob = repository.blob(b"first\n").unwrap();
- let mut builder = repository.treebuilder(None).unwrap();
- builder.insert("file.txt", first_blob, 0o100644).unwrap();
- let first_tree_oid = builder.write().unwrap();
- drop(builder);
- let first_tree = repository.find_tree(first_tree_oid).unwrap();
- let first = repository
- .commit(
- Some("refs/heads/main"),
- &signature,
- &signature,
- "first",
- &first_tree,
- &[],
- )
- .unwrap();
- let first = repository.find_commit(first).unwrap();
- let first_oid = first.id();
- let second_blob = repository.blob(b"second\n").unwrap();
- let mut builder = repository.treebuilder(None).unwrap();
- builder.insert("file.txt", second_blob, 0o100644).unwrap();
- let second_tree_oid = builder.write().unwrap();
- drop(builder);
- let second_tree = repository.find_tree(second_tree_oid).unwrap();
- let second = repository
- .commit(
- Some("refs/heads/main"),
- &signature,
- &signature,
- "second\n\nbody",
- &second_tree,
- &[&first],
- )
- .unwrap();
- repository.set_head("refs/heads/main").unwrap();
- drop(first);
- drop(first_tree);
- drop(second_tree);
- drop(repository);
-
- let old = crate::router::Revision::Commit(first_oid.to_string());
- let new = crate::router::Revision::Commit(second.to_string());
- let options = super::diff::Options {
- context: 3,
- ignore_whitespace: false,
- };
- let diff = super::diff::Diff::load(
- &root,
- "example",
- Some(old.clone()),
- new.clone(),
- None,
- options,
- )
- .unwrap();
- assert_eq!(diff.files.len(), 1);
- assert_eq!((diff.additions, diff.deletions), (1, 1));
- let commit = super::commit::Commit::load(&root, "example", new.clone(), options).unwrap();
- assert_eq!(commit.subject, "second");
- assert_eq!(commit.parents, [first_oid.to_string()]);
- assert!(commit.diff.is_some());
- let stats =
- super::stats::Stats::load(&root, "example", super::stats::Period::Week).unwrap();
- assert_eq!(stats.totals.iter().sum::<usize>(), 2);
- let archive =
- super::archive::Archive::load(&root, "example", &new, Some("file.txt")).unwrap();
- assert_eq!(archive.oid, second.to_string());
- let patch = super::patch::Patch::load(&root, "example", &old, &new).unwrap();
- assert_eq!(patch.old_oid, first_oid.to_string());
- assert_eq!(patch.new_oid, second.to_string());
- let history = super::history::History::load(
- &root,
- "example",
- new,
- super::history::Options {
- path: Some("file.txt".to_owned()),
- follow: true,
- search: super::history::Search::Grep("second".to_owned()),
- offset: 0,
- limit: 1,
- graph: true,
- ignore_whitespace: false,
- include_statistics: true,
- },
- )
- .unwrap();
- assert_eq!(history.entries.len(), 1);
- assert_eq!(history.entries[0].oid, second.to_string());
- assert!(!history.graph);
- std::fs::remove_dir_all(root).unwrap();
- }
-}
diff --git a/crates/gilti/src/models/object.rs b/crates/gilti/src/models/object.rs
deleted file mode 100644
--- a/crates/gilti/src/models/object.rs
+++ /dev/null
@@ -1,87 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::Path;
-
-pub struct RawObject {
- pub bytes: Vec<u8>,
- pub binary: bool,
-}
-
-impl RawObject {
- pub fn load(root: &Path, repository: &str, oid: &str) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, repository)?;
- let oid = git2::Oid::from_str_ext(oid, repository.object_format())
- .map_err(|_| super::Error::NotFound)?;
- let odb = repository.odb().map_err(super::Error::from_git)?;
- let object = odb.read(oid).map_err(super::Error::from_git)?;
- let bytes = object.data().to_vec();
- let binary = bytes.iter().take(8000).any(|byte| *byte == 0);
- Ok(Self { bytes, binary })
- }
-}
-
-#[cfg(test)]
-mod tests {
- fn fixture() -> (std::path::PathBuf, git2::Repository) {
- let root = std::env::temp_dir().join(format!(
- "gilti-object-test-{}-{}",
- std::process::id(),
- std::thread::current().name().unwrap_or("thread")
- ));
- let _ = std::fs::remove_dir_all(&root);
- std::fs::create_dir_all(&root).unwrap();
- let repository = git2::Repository::init_bare(root.join("example.git")).unwrap();
- (root, repository)
- }
-
- #[test]
- fn reads_raw_text_and_binary_objects_like_browser_blob() {
- let (root, repository) = fixture();
- let odb = repository.odb().unwrap();
- let text_oid = odb.write(git2::ObjectType::Blob, b"hello\n").unwrap();
- let binary_oid = odb
- .write(git2::ObjectType::Blob, b"binary\0payload")
- .unwrap();
-
- let text = super::RawObject::load(&root, "example", &text_oid.to_string()).unwrap();
- assert_eq!(text.bytes, b"hello\n");
- assert!(!text.binary);
- let binary = super::RawObject::load(&root, "example", &binary_oid.to_string()).unwrap();
- assert_eq!(binary.bytes, b"binary\0payload");
- assert!(binary.binary);
-
- std::fs::remove_dir_all(root).unwrap();
- }
-
- #[test]
- fn reads_sha256_objects_with_the_repository_format() {
- let root =
- std::env::temp_dir().join(format!("gilti-object-sha256-test-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&root);
- std::fs::create_dir_all(&root).unwrap();
- let mut options = git2::RepositoryInitOptions::new();
- options.bare(true).object_format(git2::ObjectFormat::Sha256);
- let repository = git2::Repository::init_opts(root.join("example.git"), &options).unwrap();
- let oid = repository
- .odb()
- .unwrap()
- .write(git2::ObjectType::Blob, b"sha256\n")
- .unwrap();
- assert_eq!(oid.to_string().len(), 64);
-
- let object = super::RawObject::load(&root, "example", &oid.to_string()).unwrap();
- assert_eq!(object.bytes, b"sha256\n");
- std::fs::remove_dir_all(root).unwrap();
- }
-
- #[test]
- fn missing_object_is_not_found() {
- let (root, _repository) = fixture();
- assert!(matches!(
- super::RawObject::load(&root, "example", "0000000000000000000000000000000000000000"),
- Err(crate::models::Error::NotFound)
- ));
- std::fs::remove_dir_all(root).unwrap();
- }
-}
diff --git a/crates/gilti/src/models/overview.rs b/crates/gilti/src/models/overview.rs
deleted file mode 100644
--- a/crates/gilti/src/models/overview.rs
+++ /dev/null
@@ -1,122 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::collections::HashMap;
-use std::path::Path;
-
-pub struct Overview {
- pub repository: super::repository::Info,
- pub empty: bool,
- pub branches: Vec<super::refs::Branch>,
- pub tags: Vec<super::refs::Tag>,
- pub commits: Vec<Commit>,
-}
-
-pub struct Commit {
- pub oid: String,
- pub subject: String,
- pub author: String,
- pub timestamp: i64,
- pub files: usize,
- pub insertions: usize,
- pub deletions: usize,
- pub decorations: Vec<Decoration>,
-}
-
-pub struct Decoration {
- pub label: String,
- pub reference: Option<String>,
- pub tag: bool,
-}
-
-impl Overview {
- pub fn load(root: &Path, name: &str) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, name)?;
- let info = super::repository::info(&repository, name);
- let head = match repository.head().and_then(|head| head.peel_to_commit()) {
- Ok(head) => head,
- Err(error) if error.code() == git2::ErrorCode::UnbornBranch => {
- return Ok(Self {
- repository: info,
- empty: true,
- branches: Vec::new(),
- tags: Vec::new(),
- commits: Vec::new(),
- });
- }
- Err(error) => return Err(super::Error::from_git(error)),
- };
- let refs = super::refs::Refs::load(root, name)?;
- let mut decorations = HashMap::<git2::Oid, Vec<Decoration>>::new();
- decorations.entry(head.id()).or_default().push(Decoration {
- label: "HEAD".to_owned(),
- reference: None,
- tag: false,
- });
- for branch in &refs.branches {
- if let Ok(oid) = repository.refname_to_id(&branch.reference) {
- decorations.entry(oid).or_default().push(Decoration {
- label: branch.name.clone(),
- reference: Some(branch.reference.clone()),
- tag: false,
- });
- }
- }
- for tag in &refs.tags {
- if let Ok(reference) = repository.find_reference(&tag.reference)
- && let Ok(commit) = reference.peel_to_commit()
- {
- decorations
- .entry(commit.id())
- .or_default()
- .push(Decoration {
- label: tag.name.clone(),
- reference: Some(tag.reference.clone()),
- tag: true,
- });
- }
- }
- let mut walk = repository.revwalk().map_err(super::Error::from_git)?;
- walk.push(head.id()).map_err(super::Error::from_git)?;
- walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)
- .map_err(super::Error::from_git)?;
- let mut commits = Vec::new();
- for oid in walk.take(10) {
- let oid = oid.map_err(super::Error::from_git)?;
- let commit = repository
- .find_commit(oid)
- .map_err(super::Error::from_git)?;
- let tree = commit.tree().map_err(super::Error::from_git)?;
- let parent_tree = commit.parent(0).ok().and_then(|parent| parent.tree().ok());
- let diff = repository
- .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)
- .map_err(super::Error::from_git)?;
- let stats = diff.stats().map_err(super::Error::from_git)?;
- commits.push(Commit {
- oid: oid.to_string(),
- subject: String::from_utf8_lossy(commit.summary_bytes().unwrap_or_default())
- .into_owned(),
- author: signature_name(&commit.author()),
- timestamp: commit.time().seconds(),
- files: stats.files_changed(),
- insertions: stats.insertions(),
- deletions: stats.deletions(),
- decorations: decorations.remove(&oid).unwrap_or_default(),
- });
- }
- Ok(Self {
- repository: info,
- empty: false,
- branches: refs.branches.into_iter().take(10).collect(),
- tags: refs.tags.into_iter().take(10).collect(),
- commits,
- })
- }
-}
-
-fn signature_name(signature: &git2::Signature<'_>) -> String {
- signature
- .name()
- .map(str::to_owned)
- .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned())
-}
diff --git a/crates/gilti/src/models/patch.rs b/crates/gilti/src/models/patch.rs
deleted file mode 100644
--- a/crates/gilti/src/models/patch.rs
+++ /dev/null
@@ -1,28 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::{Path, PathBuf};
-
-pub struct Patch {
- pub repository_path: PathBuf,
- pub old_oid: String,
- pub new_oid: String,
-}
-
-impl Patch {
- pub fn load(
- root: &Path,
- name: &str,
- old: &crate::router::Revision,
- new: &crate::router::Revision,
- ) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, name)?;
- let old = super::revision::commit(&repository, old)?;
- let new = super::revision::commit(&repository, new)?;
- Ok(Self {
- repository_path: repository.path().to_owned(),
- old_oid: old.id().to_string(),
- new_oid: new.id().to_string(),
- })
- }
-}
diff --git a/crates/gilti/src/models/refs.rs b/crates/gilti/src/models/refs.rs
deleted file mode 100644
--- a/crates/gilti/src/models/refs.rs
+++ /dev/null
@@ -1,196 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::Path;
-
-pub struct Refs {
- pub repository: super::repository::Info,
- pub branches: Vec<Branch>,
- pub tags: Vec<Tag>,
-}
-
-pub struct Branch {
- pub name: String,
- pub reference: String,
- pub subject: String,
- pub author: String,
- pub timestamp: i64,
-}
-
-pub struct Tag {
- pub name: String,
- pub reference: String,
- pub target: String,
- pub downloadable: bool,
- pub author: String,
- pub timestamp: i64,
-}
-
-impl Refs {
- pub fn load(root: &Path, name: &str) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, name)?;
- repository
- .head()
- .and_then(|head| head.peel_to_commit())
- .map_err(super::Error::from_git)?;
- let info = super::repository::info(&repository, name);
- let mut branches = Vec::new();
- let references = repository.references().map_err(super::Error::from_git)?;
- for reference in references {
- let reference = reference.map_err(super::Error::from_git)?;
- let Ok(reference_name) = reference.name() else {
- continue;
- };
- if !reference_name.starts_with("refs/heads/") {
- continue;
- }
- let commit = match reference.peel_to_commit() {
- Ok(commit) => commit,
- Err(_) => continue,
- };
- branches.push(Branch {
- name: reference_name["refs/heads/".len()..].to_owned(),
- reference: reference_name.to_owned(),
- subject: first_line(commit.message_bytes()),
- author: signature_name(&commit.author()),
- timestamp: commit.time().seconds(),
- });
- }
- branches.sort_by(|left, right| left.reference.cmp(&right.reference));
-
- let mut tags = Vec::new();
- let references = repository.references().map_err(super::Error::from_git)?;
- for reference in references {
- let reference = reference.map_err(super::Error::from_git)?;
- let Ok(reference_name) = reference.name() else {
- continue;
- };
- if !reference_name.starts_with("refs/tags/") {
- continue;
- }
- let object = match reference
- .resolve()
- .and_then(|reference| {
- reference
- .target()
- .ok_or_else(|| git2::Error::from_str("reference has no target"))
- })
- .and_then(|oid| repository.find_object(oid, None))
- {
- Ok(object) => object,
- Err(_) => continue,
- };
- let (author, timestamp) = if let Some(tag) = object.as_tag() {
- let tagger = tag.tagger();
- (
- tagger.as_ref().map_or_else(String::new, signature_name),
- tagger.as_ref().map_or(0, |tagger| tagger.when().seconds()),
- )
- } else if let Some(commit) = object.as_commit() {
- (signature_name(&commit.author()), commit.time().seconds())
- } else {
- (String::new(), 0)
- };
- let target = object
- .as_tag()
- .map_or_else(|| object.id(), git2::Tag::target_id);
- let downloadable = object.peel_to_commit().is_ok();
- tags.push(Tag {
- name: reference_name["refs/tags/".len()..].to_owned(),
- reference: reference_name.to_owned(),
- target: target.to_string(),
- downloadable,
- author,
- timestamp,
- });
- }
- tags.sort_by(|left, right| {
- right
- .timestamp
- .cmp(&left.timestamp)
- .then_with(|| left.reference.cmp(&right.reference))
- });
- Ok(Self {
- repository: info,
- branches,
- tags,
- })
- }
-}
-
-fn signature_name(signature: &git2::Signature<'_>) -> String {
- signature
- .name()
- .map(str::to_owned)
- .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned())
-}
-
-fn first_line(message: &[u8]) -> String {
- String::from_utf8_lossy(
- message
- .split(|byte| *byte == b'\n')
- .next()
- .unwrap_or_default(),
- )
- .into_owned()
-}
-
-#[cfg(test)]
-mod tests {
- #[test]
- fn loads_local_branches_and_annotated_tags() {
- let root = std::env::temp_dir().join(format!("gilti-refs-test-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&root);
- std::fs::create_dir_all(&root).unwrap();
- let repository = git2::Repository::init_bare(root.join("example.git")).unwrap();
- let signature = git2::Signature::new(
- "Example Author",
- "author@example.test",
- &git2::Time::new(1_700_000_000, 0),
- )
- .unwrap();
- let tree_oid = repository.treebuilder(None).unwrap().write().unwrap();
- let tree = repository.find_tree(tree_oid).unwrap();
- let commit_oid = repository
- .commit(
- Some("refs/heads/main"),
- &signature,
- &signature,
- "Subject line\n\nBody",
- &tree,
- &[],
- )
- .unwrap();
- repository.set_head("refs/heads/main").unwrap();
- repository
- .reference("refs/heads/feature/x", commit_oid, false, "test")
- .unwrap();
- let commit = repository.find_object(commit_oid, None).unwrap();
- repository
- .tag("v1.0", &commit, &signature, "Release", false)
- .unwrap();
- drop(commit);
- drop(tree);
- drop(repository);
-
- let refs = super::Refs::load(&root, "example").unwrap();
- assert_eq!(
- refs.branches
- .iter()
- .map(|branch| branch.name.as_str())
- .collect::<Vec<_>>(),
- ["feature/x", "main"]
- );
- assert!(
- refs.branches
- .iter()
- .all(|branch| branch.subject == "Subject line")
- );
- assert_eq!(refs.tags.len(), 1);
- assert_eq!(refs.tags[0].name, "v1.0");
- assert_eq!(refs.tags[0].author, "Example Author");
- assert!(refs.tags[0].downloadable);
-
- std::fs::remove_dir_all(root).unwrap();
- }
-}
diff --git a/crates/gilti/src/models/repositories.rs b/crates/gilti/src/models/repositories.rs
deleted file mode 100644
--- a/crates/gilti/src/models/repositories.rs
+++ /dev/null
@@ -1,181 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::Path;
-
-pub struct Repositories {
- pub rows: Vec<Repository>,
- pub offset: usize,
- pub has_next: bool,
-}
-
-pub struct Repository {
- pub name: String,
- pub description: String,
- pub timestamp: Option<i64>,
- pub populated: bool,
-}
-
-#[derive(Clone, Copy)]
-pub enum Sort {
- Name,
- Description,
- Owner,
- Idle,
-}
-
-pub struct Filter {
- pub search: Option<String>,
- pub sort: Sort,
- pub offset: usize,
- pub limit: usize,
-}
-
-impl Repositories {
- pub fn load(root: &Path, filter: Filter) -> Result<Self, super::Error> {
- let mut rows = Vec::new();
- scan(root, root, &mut rows)?;
- if let Some(search) = filter.search.as_deref() {
- let search = search.to_lowercase();
- rows.retain(|repository| {
- repository.name.to_lowercase().contains(&search)
- || repository.description.to_lowercase().contains(&search)
- });
- }
- if rows.is_empty() {
- return Err(super::Error::NotFound);
- }
- match filter.sort {
- Sort::Name | Sort::Owner => rows.sort_by(|left, right| left.name.cmp(&right.name)),
- Sort::Description => rows.sort_by(|left, right| {
- left.description
- .cmp(&right.description)
- .then_with(|| left.name.cmp(&right.name))
- }),
- Sort::Idle => rows.sort_by(|left, right| {
- right
- .timestamp
- .cmp(&left.timestamp)
- .then_with(|| left.name.cmp(&right.name))
- }),
- }
- let has_next = rows.len() > filter.offset.saturating_add(filter.limit);
- let rows = rows
- .into_iter()
- .skip(filter.offset)
- .take(filter.limit)
- .collect();
- Ok(Self {
- rows,
- offset: filter.offset,
- has_next,
- })
- }
-}
-
-fn scan(root: &Path, directory: &Path, rows: &mut Vec<Repository>) -> Result<(), super::Error> {
- let entries =
- std::fs::read_dir(directory).map_err(|error| super::Error::Internal(error.to_string()))?;
- for entry in entries {
- let entry = entry.map_err(|error| super::Error::Internal(error.to_string()))?;
- let file_type = entry
- .file_type()
- .map_err(|error| super::Error::Internal(error.to_string()))?;
- if !file_type.is_dir() {
- continue;
- }
- let file_name = entry.file_name();
- let Some(file_name) = file_name.to_str() else {
- continue;
- };
- if file_name.starts_with('.') {
- continue;
- }
- let path = entry.path();
- if let Some(name) = repository_name(root, &path) {
- if let Some(repository) = load(&path, name) {
- rows.push(repository);
- }
- } else {
- scan(root, &path, rows)?;
- }
- }
- Ok(())
-}
-
-fn repository_name<'a>(root: &Path, path: &'a Path) -> Option<&'a str> {
- let relative = path.strip_prefix(root).ok()?.to_str()?;
- relative.strip_suffix(".git")
-}
-
-fn load(path: &Path, name: &str) -> Option<Repository> {
- let repository = git2::Repository::open_bare(path).ok()?;
- let description = std::fs::read_to_string(path.join("description"))
- .ok()
- .map(|description| description.trim().to_owned())
- .filter(|description| !description.is_empty())
- .unwrap_or_else(|| "[no description]".to_owned());
- let commit = repository
- .head()
- .and_then(|head| head.peel_to_commit())
- .ok();
- Some(Repository {
- name: name.to_owned(),
- description,
- timestamp: commit.as_ref().map(|commit| commit.time().seconds()),
- populated: commit.is_some(),
- })
-}
-
-#[cfg(test)]
-mod tests {
- fn filter(search: Option<&str>) -> super::Filter {
- super::Filter {
- search: search.map(str::to_owned),
- sort: super::Sort::Name,
- offset: 0,
- limit: 50,
- }
- }
-
- #[test]
- fn scans_nested_and_terminal_dot_git_names_and_filters() {
- let root =
- std::env::temp_dir().join(format!("gilti-repositories-test-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&root);
- std::fs::create_dir_all(root.join("group")).unwrap();
- git2::Repository::init_bare(root.join("group/project.git")).unwrap();
- git2::Repository::init_bare(root.join("group/dotted.git.git")).unwrap();
- git2::Repository::init_bare(root.join("empty.git")).unwrap();
- std::fs::write(
- root.join("group/project.git/description"),
- "Project description\n",
- )
- .unwrap();
-
- let repositories = super::Repositories::load(&root, filter(None)).unwrap();
- assert_eq!(
- repositories
- .rows
- .iter()
- .map(|repository| repository.name.as_str())
- .collect::<Vec<_>>(),
- ["empty", "group/dotted.git", "group/project"]
- );
- assert!(
- repositories
- .rows
- .iter()
- .all(|repository| !repository.populated)
- );
- let repositories =
- super::Repositories::load(&root, filter(Some("Project description"))).unwrap();
- assert_eq!(repositories.rows.len(), 1);
- assert_eq!(repositories.rows[0].name, "group/project");
- assert!(matches!(
- super::Repositories::load(&root, filter(Some("missing"))),
- Err(crate::models::Error::NotFound)
- ));
- std::fs::remove_dir_all(root).unwrap();
- }
-}
diff --git a/crates/gilti/src/models/repository.rs b/crates/gilti/src/models/repository.rs
deleted file mode 100644
--- a/crates/gilti/src/models/repository.rs
+++ /dev/null
@@ -1,93 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::{Path, PathBuf};
-
-pub struct Info {
- pub name: String,
- pub description: String,
- pub has_readme: bool,
-}
-
-pub fn info(repository: &git2::Repository, name: &str) -> Info {
- let description = std::fs::read_to_string(repository.path().join("description"))
- .ok()
- .map(|description| description.trim().to_owned())
- .filter(|description| !description.is_empty())
- .unwrap_or_else(|| "[no description]".to_owned());
- let has_readme = repository
- .head()
- .and_then(|head| head.peel_to_tree())
- .ok()
- .is_some_and(|tree| {
- ["README.md", "README"].iter().any(|path| {
- tree.get_path(Path::new(path))
- .is_ok_and(|entry| entry.kind() == Some(git2::ObjectType::Blob))
- })
- });
- Info {
- name: name.to_owned(),
- description,
- has_readme,
- }
-}
-
-pub fn open(root: &Path, name: &str) -> Result<git2::Repository, super::Error> {
- git2::Repository::open_bare(path(root, name)?).map_err(super::Error::from_git)
-}
-
-pub fn path(root: &Path, name: &str) -> Result<PathBuf, super::Error> {
- let root = root
- .canonicalize()
- .map_err(|error| super::Error::Internal(error.to_string()))?;
- let path = repository_path(&root, name)
- .canonicalize()
- .map_err(|error| {
- if error.kind() == std::io::ErrorKind::NotFound {
- super::Error::NotFound
- } else {
- super::Error::Internal(error.to_string())
- }
- })?;
- if !path.starts_with(&root) || !path.is_dir() {
- return Err(super::Error::NotFound);
- }
- Ok(path)
-}
-
-fn repository_path(root: &Path, name: &str) -> PathBuf {
- let (parent, leaf) = name.rsplit_once('/').unwrap_or(("", name));
- root.join(parent).join(format!("{leaf}.git"))
-}
-
-#[cfg(test)]
-mod tests {
- #[test]
- fn appends_exactly_one_storage_suffix() {
- let root = std::path::Path::new("/repositories");
- assert_eq!(
- super::repository_path(root, "group/project"),
- root.join("group/project.git")
- );
- assert_eq!(
- super::repository_path(root, "group/project.git"),
- root.join("group/project.git.git")
- );
- }
-
- #[test]
- fn resolved_path_cannot_escape_the_repository_root() {
- let parent =
- std::env::temp_dir().join(format!("gilti-repository-path-test-{}", std::process::id()));
- let root = parent.join("repositories");
- let outside = parent.join("outside.git");
- let _ = std::fs::remove_dir_all(&parent);
- std::fs::create_dir_all(&root).unwrap();
- git2::Repository::init_bare(&outside).unwrap();
- assert!(matches!(
- super::path(&root, "../outside"),
- Err(crate::models::Error::NotFound)
- ));
- std::fs::remove_dir_all(parent).unwrap();
- }
-}
diff --git a/crates/gilti/src/models/revision.rs b/crates/gilti/src/models/revision.rs
deleted file mode 100644
--- a/crates/gilti/src/models/revision.rs
+++ /dev/null
@@ -1,34 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-pub fn selector(revision: &crate::router::Revision) -> String {
- match revision {
- crate::router::Revision::Head => "HEAD".to_owned(),
- crate::router::Revision::Ref(reference) | crate::router::Revision::Commit(reference) => {
- reference.clone()
- }
- }
-}
-
-pub fn commit<'repo>(
- repository: &'repo git2::Repository,
- revision: &crate::router::Revision,
-) -> Result<git2::Commit<'repo>, super::Error> {
- match revision {
- crate::router::Revision::Head => repository
- .head()
- .and_then(|head| head.peel_to_commit())
- .map_err(|_| super::Error::NotFound),
- crate::router::Revision::Ref(reference) => repository
- .find_reference(reference)
- .and_then(|reference| reference.peel_to_commit())
- .map_err(|_| super::Error::NotFound),
- crate::router::Revision::Commit(oid) => {
- let oid = git2::Oid::from_str_ext(oid, repository.object_format())
- .map_err(|_| super::Error::NotFound)?;
- repository
- .find_commit(oid)
- .map_err(|_| super::Error::NotFound)
- }
- }
-}
diff --git a/crates/gilti/src/models/stats.rs b/crates/gilti/src/models/stats.rs
deleted file mode 100644
--- a/crates/gilti/src/models/stats.rs
+++ /dev/null
@@ -1,187 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::collections::BTreeMap;
-use std::path::Path;
-
-#[derive(Clone, Copy, Eq, PartialEq)]
-pub enum Period {
- Week,
- Month,
- Quarter,
- Year,
-}
-
-impl Period {
- pub fn name(self) -> &'static str {
- match self {
- Self::Week => "week",
- Self::Month => "month",
- Self::Quarter => "quarter",
- Self::Year => "year",
- }
- }
-}
-
-pub struct Stats {
- pub repository: super::repository::Info,
- pub labels: Vec<String>,
- pub authors: Vec<Author>,
- pub totals: Vec<usize>,
-}
-
-pub struct Author {
- pub name: String,
- pub counts: Vec<usize>,
- pub total: usize,
-}
-
-impl Stats {
- pub fn load(root: &Path, name: &str, period: Period) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, name)?;
- let info = super::repository::info(&repository, name);
- let head = super::revision::commit(&repository, &crate::router::Revision::Head)?;
- let now = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map_or(0, |duration| duration.as_secs() as i64);
- let labels = labels(period, now);
- let positions = labels
- .iter()
- .enumerate()
- .map(|(index, label)| (label.clone(), index))
- .collect::<BTreeMap<_, _>>();
- let mut authors = BTreeMap::<String, Vec<usize>>::new();
- let mut walk = repository.revwalk().map_err(super::Error::from_git)?;
- walk.push(head.id()).map_err(super::Error::from_git)?;
- walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)
- .map_err(super::Error::from_git)?;
- for oid in walk {
- let commit = repository
- .find_commit(oid.map_err(super::Error::from_git)?)
- .map_err(super::Error::from_git)?;
- if commit.parent_count() > 1 {
- continue;
- }
- let label = label(period, commit.time().seconds());
- let Some(index) = positions.get(&label).copied() else {
- continue;
- };
- let signature = commit.author();
- let author = signature
- .name()
- .map(str::to_owned)
- .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned());
- authors.entry(author).or_insert_with(|| vec![0; 4])[index] += 1;
- }
- let mut authors = authors
- .into_iter()
- .map(|(name, counts)| Author {
- total: counts.iter().sum(),
- name,
- counts,
- })
- .collect::<Vec<_>>();
- authors.sort_by(|left, right| {
- right
- .total
- .cmp(&left.total)
- .then_with(|| left.name.cmp(&right.name))
- });
- let totals = (0..4)
- .map(|index| authors.iter().map(|author| author.counts[index]).sum())
- .collect();
- Ok(Self {
- repository: info,
- labels,
- authors,
- totals,
- })
- }
-}
-
-fn labels(period: Period, now: i64) -> Vec<String> {
- let mut time = tm(now);
- truncate(period, &mut time);
- let mut labels = Vec::with_capacity(4);
- for _ in 0..4 {
- labels.push(pretty(period, &time));
- decrement(period, &mut time);
- }
- labels.reverse();
- labels
-}
-
-fn label(period: Period, timestamp: i64) -> String {
- let mut time = tm(timestamp);
- truncate(period, &mut time);
- pretty(period, &time)
-}
-
-fn tm(timestamp: i64) -> libc::tm {
- super::time::utc(timestamp)
- .unwrap_or_else(|| super::time::utc(0).expect("the Unix epoch is representable"))
-}
-
-fn normalize(time: &mut libc::tm) {
- // SAFETY: time points to an initialized libc::tm and timegm normalizes it in place.
- unsafe { libc::timegm(time) };
-}
-
-fn truncate(period: Period, time: &mut libc::tm) {
- match period {
- Period::Week => {
- time.tm_mday -= (time.tm_wday + 6) % 7;
- }
- Period::Month => time.tm_mday = 1,
- Period::Quarter => {
- time.tm_mday = 1;
- time.tm_mon -= time.tm_mon % 3;
- }
- Period::Year => {
- time.tm_mday = 1;
- time.tm_mon = 0;
- }
- }
- normalize(time);
-}
-
-fn decrement(period: Period, time: &mut libc::tm) {
- match period {
- Period::Week => time.tm_mday -= 7,
- Period::Month => time.tm_mon -= 1,
- Period::Quarter => time.tm_mon -= 3,
- Period::Year => time.tm_year -= 1,
- }
- normalize(time);
-}
-
-fn pretty(period: Period, time: &libc::tm) -> String {
- match period {
- Period::Week => strftime("W%V %G", time),
- Period::Month => {
- const MONTHS: [&str; 12] = [
- "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
- ];
- format!("{} {}", MONTHS[time.tm_mon as usize], time.tm_year + 1900)
- }
- Period::Quarter => format!("Q{} {}", time.tm_mon / 3 + 1, time.tm_year + 1900),
- Period::Year => (time.tm_year + 1900).to_string(),
- }
-}
-
-fn strftime(format: &str, time: &libc::tm) -> String {
- let format = std::ffi::CString::new(format).expect("static format has no NUL");
- let mut buffer = [0_u8; 32];
- // SAFETY: the output buffer, format string and tm pointers are valid.
- let length = unsafe {
- libc::strftime(
- buffer.as_mut_ptr().cast::<libc::c_char>(),
- buffer.len(),
- format.as_ptr(),
- time,
- )
- };
- std::str::from_utf8(&buffer[..length])
- .expect("strftime emits ASCII for this format")
- .to_owned()
-}
diff --git a/crates/gilti/src/models/tag.rs b/crates/gilti/src/models/tag.rs
deleted file mode 100644
--- a/crates/gilti/src/models/tag.rs
+++ /dev/null
@@ -1,112 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::Path;
-
-pub struct Tag {
- pub repository: super::repository::Info,
- pub reference: String,
- pub name: String,
- pub oid: String,
- pub targets: Vec<Target>,
- pub annotated: bool,
- pub tagger: String,
- pub tagger_email: String,
- pub timestamp: Option<i64>,
- pub message: String,
- pub downloadable: bool,
-}
-
-pub struct Target {
- pub oid: String,
- pub commit: bool,
-}
-
-impl Tag {
- pub fn load(root: &Path, name: &str, reference: String) -> Result<Self, super::Error> {
- if !reference.starts_with("refs/tags/") {
- return Err(super::Error::NotFound);
- }
- let repository = super::repository::open(root, name)?;
- let info = super::repository::info(&repository, name);
- let git_reference = repository
- .find_reference(&reference)
- .map_err(super::Error::from_git)?;
- let oid = git_reference
- .resolve()
- .map_err(super::Error::from_git)?
- .target()
- .ok_or(super::Error::NotFound)?;
- let object = repository
- .find_object(oid, None)
- .map_err(super::Error::from_git)?;
- let downloadable = object.peel_to_commit().is_ok();
- let (targets, annotated, tagger, tagger_email, timestamp, message) =
- if let Some(tag) = object.as_tag() {
- let tagger = tag.tagger();
- (
- targets(tag)?,
- true,
- tagger.as_ref().map_or_else(String::new, signature_name),
- tagger.as_ref().map_or_else(String::new, signature_email),
- tagger.as_ref().map(|tagger| tagger.when().seconds()),
- String::from_utf8_lossy(tag.message_bytes().unwrap_or_default()).into_owned(),
- )
- } else {
- (
- vec![Target {
- oid: object.id().to_string(),
- commit: object.kind() == Some(git2::ObjectType::Commit),
- }],
- false,
- String::new(),
- String::new(),
- None,
- String::new(),
- )
- };
- Ok(Self {
- repository: info,
- reference: reference.clone(),
- name: reference["refs/tags/".len()..].to_owned(),
- oid: oid.to_string(),
- targets,
- annotated,
- tagger,
- tagger_email,
- timestamp,
- message,
- downloadable,
- })
- }
-}
-
-fn targets(tag: &git2::Tag<'_>) -> Result<Vec<Target>, super::Error> {
- let mut targets = Vec::new();
- let mut object = tag.target().map_err(super::Error::from_git)?;
- loop {
- targets.push(Target {
- oid: object.id().to_string(),
- commit: object.kind() == Some(git2::ObjectType::Commit),
- });
- let Some(tag) = object.as_tag() else {
- break;
- };
- object = tag.target().map_err(super::Error::from_git)?;
- }
- Ok(targets)
-}
-
-fn signature_name(signature: &git2::Signature<'_>) -> String {
- signature
- .name()
- .map(str::to_owned)
- .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned())
-}
-
-fn signature_email(signature: &git2::Signature<'_>) -> String {
- signature
- .email()
- .map(str::to_owned)
- .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned())
-}
diff --git a/crates/gilti/src/models/time.rs b/crates/gilti/src/models/time.rs
deleted file mode 100644
--- a/crates/gilti/src/models/time.rs
+++ /dev/null
@@ -1,10 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-pub fn utc(timestamp: i64) -> Option<libc::tm> {
- let mut value = std::mem::MaybeUninit::<libc::tm>::uninit();
- // SAFETY: both pointers are valid for the call; the result is read only when
- // gmtime_r reports success and has initialized it.
- let result = unsafe { libc::gmtime_r(&timestamp, value.as_mut_ptr()) };
- (!result.is_null()).then(|| unsafe { value.assume_init() })
-}
diff --git a/crates/gilti/src/models/tree.rs b/crates/gilti/src/models/tree.rs
deleted file mode 100644
--- a/crates/gilti/src/models/tree.rs
+++ /dev/null
@@ -1,250 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use std::path::Path;
-
-pub struct Tree {
- pub repository: super::repository::Info,
- pub revision: String,
- pub path: Option<String>,
- pub content: Content,
-}
-
-pub enum Content {
- Directory {
- oid: String,
- entries: Vec<Entry>,
- },
- Blob {
- oid: String,
- bytes: Vec<u8>,
- binary: bool,
- },
-}
-
-pub struct Entry {
- pub mode: u32,
- pub name: String,
- pub path: String,
- pub oid: String,
- pub kind: Kind,
- pub size: usize,
- pub symlink_target: Option<String>,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum Kind {
- Tree,
- Blob,
- Submodule,
-}
-
-impl Tree {
- pub fn load(
- root: &Path,
- name: &str,
- revision: crate::router::Revision,
- path: Option<String>,
- ) -> Result<Self, super::Error> {
- let repository = super::repository::open(root, name)?;
- let info = super::repository::info(&repository, name);
- let selector = super::revision::selector(&revision);
- let commit = super::revision::commit(&repository, &revision)?;
- let root_tree = commit.tree().map_err(super::Error::from_git)?;
- let content = match path.as_deref() {
- None => directory(&repository, &root_tree, "")?,
- Some(path) => {
- let entry = root_tree
- .get_path(Path::new(path))
- .map_err(super::Error::from_git)?;
- match entry.kind() {
- Some(git2::ObjectType::Tree) => {
- let tree = repository
- .find_tree(entry.id())
- .map_err(super::Error::from_git)?;
- directory(&repository, &tree, path)?
- }
- Some(git2::ObjectType::Blob) => {
- let blob = repository
- .find_blob(entry.id())
- .map_err(super::Error::from_git)?;
- let bytes = blob.content().to_vec();
- let binary = bytes.iter().take(8000).any(|byte| *byte == 0);
- Content::Blob {
- oid: entry.id().to_string(),
- bytes,
- binary,
- }
- }
- _ => return Err(super::Error::NotFound),
- }
- }
- };
- Ok(Self {
- repository: info,
- revision: selector,
- path,
- content,
- })
- }
-}
-
-fn directory(
- repository: &git2::Repository,
- tree: &git2::Tree<'_>,
- base: &str,
-) -> Result<Content, super::Error> {
- let odb = repository.odb().map_err(super::Error::from_git)?;
- let mut entries = Vec::with_capacity(tree.len());
- for entry in tree {
- let name = String::from_utf8_lossy(entry.name_bytes()).into_owned();
- let path = if base.is_empty() {
- name.clone()
- } else {
- format!("{base}/{name}")
- };
- let kind = match entry.kind() {
- Some(git2::ObjectType::Tree) => Kind::Tree,
- Some(git2::ObjectType::Commit) => Kind::Submodule,
- Some(git2::ObjectType::Blob) => Kind::Blob,
- _ => continue,
- };
- let size = if kind == Kind::Submodule {
- 0
- } else {
- odb.read_header(entry.id())
- .map_err(super::Error::from_git)?
- .0
- };
- let symlink_target = if entry.filemode_raw() == 0o120000 {
- repository
- .find_blob(entry.id())
- .ok()
- .map(|blob| String::from_utf8_lossy(blob.content()).into_owned())
- } else {
- None
- };
- entries.push(Entry {
- mode: entry.filemode_raw() as u32,
- name,
- path,
- oid: entry.id().to_string(),
- kind,
- size,
- symlink_target,
- });
- }
- Ok(Content::Directory {
- oid: tree.id().to_string(),
- entries,
- })
-}
-
-#[cfg(test)]
-mod tests {
- #[test]
- fn loads_directories_blobs_modes_and_symlinks() {
- let root = std::env::temp_dir().join(format!("gilti-tree-test-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&root);
- std::fs::create_dir_all(&root).unwrap();
- let repository = git2::Repository::init_bare(root.join("example.git")).unwrap();
- let odb = repository.odb().unwrap();
- let readme = odb.write(git2::ObjectType::Blob, b"hello\n").unwrap();
- let executable = odb.write(git2::ObjectType::Blob, b"#!/bin/sh\n").unwrap();
- let symlink = odb.write(git2::ObjectType::Blob, b"README.md").unwrap();
- let nested = odb.write(git2::ObjectType::Blob, b"nested\n").unwrap();
- let subtree = {
- let mut tree = repository.treebuilder(None).unwrap();
- tree.insert("file.txt", nested, 0o100644).unwrap();
- tree.write().unwrap()
- };
- let tree_oid = {
- let mut tree = repository.treebuilder(None).unwrap();
- tree.insert("README.md", readme, 0o100644).unwrap();
- tree.insert("run", executable, 0o100755).unwrap();
- tree.insert("link", symlink, 0o120000).unwrap();
- tree.insert("dir", subtree, 0o040000).unwrap();
- tree.write().unwrap()
- };
- let tree = repository.find_tree(tree_oid).unwrap();
- let signature = git2::Signature::now("Author", "author@example.test").unwrap();
- repository
- .commit(
- Some("refs/heads/main"),
- &signature,
- &signature,
- "Tree fixture",
- &tree,
- &[],
- )
- .unwrap();
- repository.set_head("refs/heads/main").unwrap();
- drop(tree);
- drop(odb);
- drop(repository);
-
- let root_tree =
- super::Tree::load(&root, "example", crate::router::Revision::Head, None).unwrap();
- let super::Content::Directory { entries, .. } = root_tree.content else {
- panic!("expected directory")
- };
- assert_eq!(entries.len(), 4);
- assert_eq!(
- entries
- .iter()
- .find(|entry| entry.name == "run")
- .unwrap()
- .mode,
- 0o100755
- );
- assert_eq!(
- entries
- .iter()
- .find(|entry| entry.name == "link")
- .unwrap()
- .symlink_target
- .as_deref(),
- Some("README.md")
- );
- assert_eq!(
- entries
- .iter()
- .find(|entry| entry.name == "dir")
- .unwrap()
- .kind,
- super::Kind::Tree
- );
-
- let blob = super::Tree::load(
- &root,
- "example",
- crate::router::Revision::Head,
- Some("dir/file.txt".to_owned()),
- )
- .unwrap();
- let super::Content::Blob { bytes, binary, .. } = blob.content else {
- panic!("expected blob")
- };
- assert_eq!(bytes, b"nested\n");
- assert!(!binary);
- assert!(matches!(
- super::Tree::load(
- &root,
- "example",
- crate::router::Revision::Head,
- Some("missing".to_owned())
- ),
- Err(crate::models::Error::NotFound)
- ));
- assert!(matches!(
- super::Tree::load(
- &root,
- "example",
- crate::router::Revision::Commit(readme.to_string()),
- None
- ),
- Err(crate::models::Error::NotFound)
- ));
- std::fs::remove_dir_all(root).unwrap();
- }
-}
diff --git a/crates/gilti/src/router.rs b/crates/gilti/src/router.rs
deleted file mode 100644
--- a/crates/gilti/src/router.rs
+++ /dev/null
@@ -1,867 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-/*
-URL scheme
-
-The URL space is divided into disjoint Gilti-specific and repository-specific
-namespaces. The reserved top-level segment "-" belongs to Gilti and cannot be
-used as the first segment of a repository name.
-
-Git objects are content-addressed and immutable: a full object ID always
-identifies the same object contents. Refs and repository metadata are mutable,
-and unreachable objects may eventually be removed.
-
-Routes marked I use only immutable selectors. Routes marked M depend on the
-current repository state. A composite selector inherits mutability from its
-components: a tree path under a full commit OID is immutable, while the same
-path under a branch or tag ref is mutable. A comparison is immutable only when
-both of its revision selectors are immutable.
-
-Repository names, ref names, and tree paths may all contain "/". Their
-boundaries are expressed using literal "/+/" segments and are never inferred
-from repository state. Structural delimiters are recognized before percent
-decoding. A literal data segment equal to "+" must be percent-encoded as
-"%2B".
-
-Route parameters whose names end in "*" may contain "/". They are terminated
-by the next structural "/+/" segment, a route-specific terminal marker, or the
-end of the route. The terminal ".git" suffix separates a repository name from
-Git HTTP transport paths.
-
- {repo*} canonical repository name
- {ref*} ref name relative to "refs/", for example "heads/main"
- {path*} path within a Git tree
- {commit_id} full OID of a commit; abbreviated IDs are not accepted
- {object_id} full OID of any Git object; abbreviated IDs are not accepted
- {rev*} "{commit_id}", "refs/{ref*}", or "HEAD"
- {asset_path*} path below the Gilti static asset root
- {old_rev*} revision selector
- {new_rev*} revision selector
- {dumb_http_path*} path below the Git dumb HTTP "objects/" endpoint
- {lfs_path*} path below the Git LFS endpoint
-
-Archive, diff, blob, and object formats are representation options, not route
-parameters. They are selected by the "format" query parameter, then by the
-Accept header, and finally by the server default. The query parameter takes
-precedence when both are present. Archive signature routes use the same query
-parameter to identify the signed archive format. Raw blob contents, raw Git
-object contents, and raw unified diffs are representations of their respective
-views rather than separate views.
-
-Rendered documents are an HTML view mode selected with the "view" query
-parameter. Supported values are "source" and "rendered". Renderable documents
-may default to "rendered"; other blobs default to "source".
-
-Gilti views and operational endpoints:
-
-/-/about <- Gilti about page
-/-/terms <- Gilti terms of use
-/-/assets/{asset_path*} <- Static asset
-/-/health <- Health check returning {"status":"ok"}
-*/
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum Route {
- // Repository views:
- /// M: Repository list
- /// /
- Repositories,
-
- /// M: Repository overview
- /// /{repo*}
- Overview(RepoRoute<()>),
-
- /// M: Repository documentation
- /// /{repo*}/+/about
- About(RepoRoute<()>),
-
- /// M: Repository activity statistics
- /// /{repo*}/+/stats
- Stats(RepoRoute<()>),
-
- /// M: Recent tags, commits, and summary data
- /// /{repo*}/+/summary
- Summary(RepoRoute<()>),
-
- // Immutable object views:
- /// I: Git object
- /// /{repo*}/+/object/{object_id}
- Object(RepoRoute<String>),
-
- // Refs and revisions:
- /// M: Ref list
- /// /{repo*}/+/refs
- Refs(RepoRoute<()>),
-
- /// I/M: Revision overview and resolved target
- /// /{repo*}/+/{rev*}
- Revision(RepoRoute<Revision>),
-
- /// I/M: Commit log, optionally restricted to a path
- /// /{repo*}/+/{rev*}/+/log
- /// /{repo*}/+/{rev*}/+/log/{path*}
- Log(RepoRoute<RevisionPath>),
-
- /// I/M: Tree, blob, document, or submodule
- /// /{repo*}/+/{rev*}/+/tree
- /// /{repo*}/+/{rev*}/+/tree/{path*}
- Tree(RepoRoute<RevisionPath>),
-
- /// I/M: File blame
- /// /{repo*}/+/{rev*}/+/blame/{path*}
- Blame(RepoRoute<RevisionFile>),
-
- /// I/M: Archive of the root tree or a subtree
- /// /{repo*}/+/{rev*}/+/archive
- /// /{repo*}/+/{rev*}/+/archive/{path*}
- Archive(RepoRoute<RevisionPath>),
-
- /// I/M: Detached archive signature
- /// /{repo*}/+/{rev*}/+/archive-signature
- ArchiveSignature(RepoRoute<Revision>),
-
- // Feed & updates:
- /// M: Atom commit feed, optionally restricted to a path
- /// /{repo*}/+/refs/{ref*}/+/feed/atom
- /// /{repo*}/+/refs/{ref*}/+/feed/atom/{path*}
- AtomFeed(RepoRoute<RefPath>),
-
- // Comparisons and patches:
- /// I/M: Diff between two revisions, optionally restricted to a path
- /// /{repo*}/+/diff/{old_rev*}..{new_rev*}
- /// /{repo*}/+/diff/{old_rev*}..{new_rev*}/+/{path*}
- Diff(RepoRoute<Comparison>),
-
- /// I/M: Mail-formatted patch or patch series, optionally restricted to a path
- /// /{repo*}/+/patch/{old_rev*}..{new_rev*}
- /// /{repo*}/+/patch/{old_rev*}..{new_rev*}/+/{path*}
- Patch(RepoRoute<Comparison>),
-
- // Git HTTP transport endpoints:
- /// M: Public clone URL
- /// /{repo*}.git
- GitClone(RepoRoute<()>),
-
- /// M: Service discovery and ref advertisement
- /// /{repo*}.git/info/refs
- GitInfoRefs(RepoRoute<()>),
-
- /// M: Fetch protocol endpoint
- /// /{repo*}.git/git-upload-pack
- GitUploadPack(RepoRoute<()>),
-
- /// M: Push protocol endpoint, when enabled
- /// /{repo*}.git/git-receive-pack
- GitReceivePack(RepoRoute<()>),
-
- /// M: Dumb HTTP HEAD advertisement, when enabled
- /// /{repo*}.git/HEAD
- GitHead(RepoRoute<()>),
-
- /// Git dumb HTTP object endpoint, when enabled
- /// /{repo*}.git/objects/{dumb_http_path*}
- GitObjects(RepoRoute<String>),
-
- /// Git LFS endpoint, when enabled
- /// /{repo*}.git/info/lfs/{lfs_path*}
- GitLfs(RepoRoute<String>),
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct RepoRoute<T> {
- pub repo: String,
- pub params: T,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct RevisionPath {
- pub rev: Revision,
- pub path: Option<String>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct RevisionFile {
- pub rev: Revision,
- pub path: String,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct RefPath {
- pub reference: String,
- pub path: Option<String>,
-}
-
-/// A revision range separated by `..` and an optional tree path separated by `/+/`.
-/// It is immutable only when both revision selectors are full commit OIDs.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct Comparison {
- pub old_rev: Revision,
- pub new_rev: Revision,
- pub path: Option<String>,
-}
-
-/// A revision route is immutable for a full commit OID and mutable for a ref or
-/// `HEAD`. An annotated tag displays its object chain and peeled target.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum Revision {
- Head,
- Ref(String),
- Commit(String),
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct ParseError;
-
-impl std::fmt::Display for ParseError {
- fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- formatter.write_str("invalid repository route")
- }
-}
-
-impl std::error::Error for ParseError {}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-enum TokenKind {
- Text,
- Slash,
- Boundary,
- Range,
- GitSuffix,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-struct Token<'a> {
- kind: TokenKind,
- raw: &'a str,
-}
-
-/// Parses a raw, percent-encoded URI path. Query parameters are handled separately.
-pub fn parse(path: &str) -> Result<Route, ParseError> {
- let tokens = lex(path)?;
- Parser {
- tokens: &tokens,
- cursor: 0,
- }
- .parse()
-}
-
-fn lex(path: &str) -> Result<Vec<Token<'_>>, ParseError> {
- let bytes = path.as_bytes();
- let mut tokens = Vec::new();
- let (mut cursor, mut start) = (0, 0);
- while cursor < bytes.len() {
- if bytes[cursor] == b'%' {
- if cursor + 2 >= bytes.len()
- || !bytes[cursor + 1].is_ascii_hexdigit()
- || !bytes[cursor + 2].is_ascii_hexdigit()
- {
- return Err(ParseError);
- }
- cursor += 3;
- continue;
- }
- if matches!(bytes[cursor], b'?' | b'#') {
- return Err(ParseError);
- }
- let delimiter = if bytes[cursor..].starts_with(b"/+/") {
- Some((TokenKind::Boundary, 3))
- } else if bytes[cursor] == b'/' {
- Some((TokenKind::Slash, 1))
- } else if bytes[cursor..].starts_with(b"..") {
- Some((TokenKind::Range, 2))
- } else if bytes[cursor..].starts_with(b".git")
- && (cursor + 4 == bytes.len() || bytes[cursor + 4] == b'/')
- {
- Some((TokenKind::GitSuffix, 4))
- } else {
- None
- };
- let Some((kind, width)) = delimiter else {
- cursor += 1;
- continue;
- };
- if start < cursor {
- tokens.push(Token {
- kind: TokenKind::Text,
- raw: &path[start..cursor],
- });
- }
- tokens.push(Token {
- kind,
- raw: &path[cursor..cursor + width],
- });
- cursor += width;
- start = cursor;
- }
- if start < cursor {
- tokens.push(Token {
- kind: TokenKind::Text,
- raw: &path[start..cursor],
- });
- }
- Ok(tokens)
-}
-
-struct Parser<'tokens, 'input> {
- tokens: &'tokens [Token<'input>],
- cursor: usize,
-}
-
-impl<'tokens, 'input> Parser<'tokens, 'input> {
- fn parse(mut self) -> Result<Route, ParseError> {
- self.expect(TokenKind::Slash)?;
- if self.end() {
- return Ok(Route::Repositories);
- }
- let repo = decode_repo(self.take_until(&[TokenKind::Boundary, TokenKind::GitSuffix]))?;
- match self.peek() {
- None => Ok(Route::Overview(route(repo, ()))),
- Some(TokenKind::Boundary) => {
- self.cursor += 1;
- self.view(repo)
- }
- Some(TokenKind::GitSuffix) => {
- self.cursor += 1;
- self.git(repo)
- }
- _ => Err(ParseError),
- }
- }
-
- fn view(&mut self, repo: String) -> Result<Route, ParseError> {
- Ok(match self.peek_text() {
- Some("about") => {
- self.cursor += 1;
- self.finish()?;
- Route::About(route(repo, ()))
- }
- Some("stats") => {
- self.cursor += 1;
- self.finish()?;
- Route::Stats(route(repo, ()))
- }
- Some("summary") => {
- self.cursor += 1;
- self.finish()?;
- Route::Summary(route(repo, ()))
- }
- Some("refs") if self.cursor + 1 == self.tokens.len() => {
- self.cursor += 1;
- Route::Refs(route(repo, ()))
- }
- Some("object") => {
- self.cursor += 1;
- self.expect(TokenKind::Slash)?;
- let object_id = parse_oid(self.take_text()?)?;
- self.finish()?;
- Route::Object(route(repo, object_id))
- }
- Some("diff") | Some("patch") => {
- let patch = self.take_text()? == "patch";
- self.expect(TokenKind::Slash)?;
- return self.comparison(repo, patch);
- }
- _ => {
- let rev = self.revision()?;
- if self.end() {
- Route::Revision(route(repo, rev))
- } else {
- self.expect(TokenKind::Boundary)?;
- return self.revision_view(repo, rev);
- }
- }
- })
- }
-
- fn revision_view(&mut self, repo: String, rev: Revision) -> Result<Route, ParseError> {
- Ok(match self.take_text()? {
- "log" => Route::Log(route(
- repo,
- RevisionPath {
- rev,
- path: self.optional_path(TokenKind::Slash)?,
- },
- )),
- "tree" => Route::Tree(route(
- repo,
- RevisionPath {
- rev,
- path: self.optional_path(TokenKind::Slash)?,
- },
- )),
- "blame" => {
- self.expect(TokenKind::Slash)?;
- Route::Blame(route(
- repo,
- RevisionFile {
- rev,
- path: decode_path(self.rest())?,
- },
- ))
- }
- "archive" => Route::Archive(route(
- repo,
- RevisionPath {
- rev,
- path: self.optional_path(TokenKind::Slash)?,
- },
- )),
- "archive-signature" => {
- self.finish()?;
- Route::ArchiveSignature(route(repo, rev))
- }
- "feed" => {
- self.expect(TokenKind::Slash)?;
- self.expect_text("atom")?;
- Route::AtomFeed(route(
- repo,
- RefPath {
- reference: reference(rev)?,
- path: self.optional_path(TokenKind::Slash)?,
- },
- ))
- }
- _ => return Err(ParseError),
- })
- }
-
- fn comparison(&mut self, repo: String, patch: bool) -> Result<Route, ParseError> {
- let old_rev = self.revision()?;
- self.expect(TokenKind::Range)?;
- let new_rev = self.revision()?;
- let comparison = route(
- repo,
- Comparison {
- old_rev,
- new_rev,
- path: self.optional_path(TokenKind::Boundary)?,
- },
- );
- Ok(if patch {
- Route::Patch(comparison)
- } else {
- Route::Diff(comparison)
- })
- }
-
- fn git(&mut self, repo: String) -> Result<Route, ParseError> {
- if self.end() {
- return Ok(Route::GitClone(route(repo, ())));
- }
- self.expect(TokenKind::Slash)?;
- Ok(match self.take_text()? {
- "info" => {
- self.expect(TokenKind::Slash)?;
- match self.take_text()? {
- "refs" => {
- self.finish()?;
- Route::GitInfoRefs(route(repo, ()))
- }
- "lfs" => {
- self.expect(TokenKind::Slash)?;
- Route::GitLfs(route(repo, decode_path(self.rest())?))
- }
- _ => return Err(ParseError),
- }
- }
- "git-upload-pack" => {
- self.finish()?;
- Route::GitUploadPack(route(repo, ()))
- }
- "git-receive-pack" => {
- self.finish()?;
- Route::GitReceivePack(route(repo, ()))
- }
- "HEAD" => {
- self.finish()?;
- Route::GitHead(route(repo, ()))
- }
- "objects" => {
- self.expect(TokenKind::Slash)?;
- Route::GitObjects(route(repo, decode_path(self.rest())?))
- }
- _ => return Err(ParseError),
- })
- }
-
- fn revision(&mut self) -> Result<Revision, ParseError> {
- parse_revision(self.take_until(&[TokenKind::Boundary, TokenKind::Range]))
- }
-
- fn optional_path(&mut self, separator: TokenKind) -> Result<Option<String>, ParseError> {
- if self.end() {
- Ok(None)
- } else {
- self.expect(separator)?;
- decode_path(self.rest()).map(Some)
- }
- }
-
- fn take_until(&mut self, delimiters: &[TokenKind]) -> &'tokens [Token<'input>] {
- let start = self.cursor;
- while self.peek().is_some_and(|kind| !delimiters.contains(&kind)) {
- self.cursor += 1;
- }
- &self.tokens[start..self.cursor]
- }
-
- fn rest(&mut self) -> &'tokens [Token<'input>] {
- let rest = &self.tokens[self.cursor..];
- self.cursor = self.tokens.len();
- rest
- }
-
- fn peek(&self) -> Option<TokenKind> {
- self.tokens.get(self.cursor).map(|token| token.kind)
- }
-
- fn peek_text(&self) -> Option<&'input str> {
- self.tokens
- .get(self.cursor)
- .filter(|token| token.kind == TokenKind::Text)
- .map(|token| token.raw)
- }
-
- fn take_text(&mut self) -> Result<&'input str, ParseError> {
- let text = self.peek_text().ok_or(ParseError)?;
- self.cursor += 1;
- Ok(text)
- }
-
- fn expect_text(&mut self, expected: &str) -> Result<(), ParseError> {
- (self.take_text()? == expected)
- .then_some(())
- .ok_or(ParseError)
- }
-
- fn expect(&mut self, expected: TokenKind) -> Result<(), ParseError> {
- (self.peek() == Some(expected))
- .then(|| self.cursor += 1)
- .ok_or(ParseError)
- }
-
- fn finish(&self) -> Result<(), ParseError> {
- self.end().then_some(()).ok_or(ParseError)
- }
-
- fn end(&self) -> bool {
- self.cursor == self.tokens.len()
- }
-}
-
-fn parse_revision(value: &[Token<'_>]) -> Result<Revision, ParseError> {
- if value.len() == 1 && value[0].kind == TokenKind::Text {
- return match value[0].raw {
- "HEAD" => Ok(Revision::Head),
- oid => parse_oid(oid).map(Revision::Commit),
- };
- }
- if value.len() >= 2 && value[0].raw == "refs" && value[1].kind == TokenKind::Slash {
- let reference = format!("refs/{}", decode_path(&value[2..])?);
- return valid_ref(&reference)
- .then_some(Revision::Ref(reference))
- .ok_or(ParseError);
- }
- Err(ParseError)
-}
-
-fn parse_oid(value: &str) -> Result<String, ParseError> {
- (matches!(value.len(), 40 | 64)
- && value
- .as_bytes()
- .iter()
- .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)))
- .then(|| value.to_owned())
- .ok_or(ParseError)
-}
-
-fn valid_ref(value: &str) -> bool {
- !value.ends_with('.')
- && !value.contains("..")
- && !value.contains("@{")
- && value.split('/').all(|part| {
- !part.is_empty()
- && !part.starts_with('.')
- && !part.ends_with(".lock")
- && !part
- .bytes()
- .any(|byte| byte < b' ' || byte == 0x7f || b" ~^:?*[\\".contains(&byte))
- })
-}
-
-fn decode_repo(value: &[Token<'_>]) -> Result<String, ParseError> {
- decode_name(value, true)
-}
-
-fn decode_path(value: &[Token<'_>]) -> Result<String, ParseError> {
- decode_name(value, false)
-}
-
-fn decode_name(value: &[Token<'_>], repo: bool) -> Result<String, ParseError> {
- if value.is_empty() {
- return Err(ParseError);
- }
- for (index, component) in value
- .split(|token| token.kind == TokenKind::Slash)
- .enumerate()
- {
- let literal = |expected| component.len() == 1 && component[0].raw == expected;
- if component.is_empty()
- || component.iter().any(|token| {
- !matches!(
- token.kind,
- TokenKind::Text | TokenKind::Range | TokenKind::GitSuffix
- )
- })
- || literal(".")
- || literal("..")
- || literal("+")
- || repo
- && (index == 0 && literal("-")
- || component
- .last()
- .is_some_and(|token| token.kind == TokenKind::GitSuffix))
- {
- return Err(ParseError);
- }
- }
- let mut decoded = String::new();
- for token in value {
- decoded.push_str(
- &percent_encoding::percent_decode_str(token.raw)
- .decode_utf8()
- .map_err(|_| ParseError)?,
- );
- }
- (!decoded.contains('\0'))
- .then_some(decoded)
- .ok_or(ParseError)
-}
-
-fn reference(rev: Revision) -> Result<String, ParseError> {
- match rev {
- Revision::Ref(reference) => Ok(reference),
- _ => Err(ParseError),
- }
-}
-
-fn route<T>(repo: String, params: T) -> RepoRoute<T> {
- RepoRoute { repo, params }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{
- Comparison, RefPath, RepoRoute, Revision, RevisionFile, RevisionPath, Route, TokenKind,
- lex, parse,
- };
-
- const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
- const OTHER: &str = "89abcdef0123456789abcdef0123456789abcdef";
-
- fn repository<T>(params: T) -> RepoRoute<T> {
- repo("group/проект", params)
- }
-
- fn repo<T>(name: &str, params: T) -> RepoRoute<T> {
- RepoRoute {
- repo: name.to_owned(),
- params,
- }
- }
-
- #[test]
- fn lexer_preserves_raw_tokens() {
- use TokenKind::{Boundary, GitSuffix, Range, Slash, Text};
-
- let tokens = lex("/repo%2Egit.git/diff/HEAD..refs/heads/main/+/src").unwrap();
- assert_eq!(
- tokens
- .iter()
- .map(|token| (token.kind, token.raw))
- .collect::<Vec<_>>(),
- [
- (Slash, "/"),
- (Text, "repo%2Egit"),
- (GitSuffix, ".git"),
- (Slash, "/"),
- (Text, "diff"),
- (Slash, "/"),
- (Text, "HEAD"),
- (Range, ".."),
- (Text, "refs"),
- (Slash, "/"),
- (Text, "heads"),
- (Slash, "/"),
- (Text, "main"),
- (Boundary, "/+/"),
- (Text, "src"),
- ]
- );
- }
-
- #[test]
- fn parses_repository_views() {
- assert_eq!(parse("/").unwrap(), Route::Repositories);
- assert_eq!(
- parse("/group/%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82").unwrap(),
- Route::Overview(repository(()))
- );
- assert_eq!(
- parse("/foo%2Egit/+/summary").unwrap(),
- Route::Summary(repo("foo.git", ()))
- );
- assert_eq!(
- parse("/odd..repo").unwrap(),
- Route::Overview(repo("odd..repo", ()))
- );
- for (path, expected) in [
- ("about", Route::About(repository(()))),
- ("stats", Route::Stats(repository(()))),
- ("summary", Route::Summary(repository(()))),
- ("refs", Route::Refs(repository(()))),
- ] {
- assert_eq!(parse(&format!("/group/проект/+/{path}")).unwrap(), expected);
- }
- assert_eq!(
- parse(&format!("/group/проект/+/object/{COMMIT}")).unwrap(),
- Route::Object(repository(COMMIT.to_owned()))
- );
- }
-
- #[test]
- fn parses_revision_views_and_escaped_paths() {
- let reference = Revision::Ref("refs/heads/feature/+".to_owned());
- assert_eq!(
- parse("/group/проект/+/refs/heads/feature/%2B").unwrap(),
- Route::Revision(repository(reference.clone()))
- );
- assert_eq!(
- parse("/group/проект/+/refs/heads/feature/%2B/+/tree").unwrap(),
- Route::Tree(repository(RevisionPath {
- rev: reference.clone(),
- path: None,
- }))
- );
- assert_eq!(
- parse("/group/проект/+/refs/heads/feature/%2B/+/tree/src/%2B/lib.rs").unwrap(),
- Route::Tree(repository(RevisionPath {
- rev: reference.clone(),
- path: Some("src/+/lib.rs".to_owned()),
- }))
- );
- assert_eq!(
- parse("/group/проект/+/HEAD/+/tree/src/a..b/foo.git").unwrap(),
- Route::Tree(repository(RevisionPath {
- rev: Revision::Head,
- path: Some("src/a..b/foo.git".to_owned()),
- }))
- );
- assert_eq!(
- parse(&format!("/group/проект/+/{COMMIT}/+/log/src/lib.rs")).unwrap(),
- Route::Log(repository(RevisionPath {
- rev: Revision::Commit(COMMIT.to_owned()),
- path: Some("src/lib.rs".to_owned()),
- }))
- );
- assert_eq!(
- parse("/group/проект/+/HEAD/+/archive").unwrap(),
- Route::Archive(repository(RevisionPath {
- rev: Revision::Head,
- path: None,
- }))
- );
- assert_eq!(
- parse("/group/проект/+/refs/heads/main/+/blame/src/lib.rs").unwrap(),
- Route::Blame(repository(RevisionFile {
- rev: Revision::Ref("refs/heads/main".to_owned()),
- path: "src/lib.rs".to_owned(),
- }))
- );
- }
-
- #[test]
- fn parses_feeds_comparisons_and_patches() {
- assert_eq!(
- parse("/group/проект/+/refs/heads/main/+/feed/atom/src/lib.rs").unwrap(),
- Route::AtomFeed(repository(RefPath {
- reference: "refs/heads/main".to_owned(),
- path: Some("src/lib.rs".to_owned()),
- }))
- );
- assert_eq!(
- parse(&format!(
- "/group/проект/+/diff/refs/heads/main..{COMMIT}/+/src/lib.rs"
- ))
- .unwrap(),
- Route::Diff(repository(Comparison {
- old_rev: Revision::Ref("refs/heads/main".to_owned()),
- new_rev: Revision::Commit(COMMIT.to_owned()),
- path: Some("src/lib.rs".to_owned()),
- }))
- );
- assert_eq!(
- parse(&format!("/group/проект/+/patch/{COMMIT}..{OTHER}")).unwrap(),
- Route::Patch(repository(Comparison {
- old_rev: Revision::Commit(COMMIT.to_owned()),
- new_rev: Revision::Commit(OTHER.to_owned()),
- path: None,
- }))
- );
- }
-
- #[test]
- fn parses_git_transport_routes() {
- for (path, expected) in [
- ("/group/проект.git", Route::GitClone(repository(()))),
- ("/foo%2Egit.git", Route::GitClone(repo("foo.git", ()))),
- (
- "/group/проект.git/info/refs",
- Route::GitInfoRefs(repository(())),
- ),
- (
- "/group/проект.git/git-upload-pack",
- Route::GitUploadPack(repository(())),
- ),
- (
- "/group/проект.git/git-receive-pack",
- Route::GitReceivePack(repository(())),
- ),
- ("/group/проект.git/HEAD", Route::GitHead(repository(()))),
- (
- "/group/проект.git/objects/ab/cdef",
- Route::GitObjects(repository("ab/cdef".to_owned())),
- ),
- (
- "/group/проект.git/info/lfs/objects/batch",
- Route::GitLfs(repository("objects/batch".to_owned())),
- ),
- ] {
- assert_eq!(parse(path).unwrap(), expected);
- }
- }
-
- #[test]
- fn rejects_ambiguous_or_noncanonical_routes() {
- for path in [
- "",
- "/-/about",
- "/repo/",
- "/repo/+/",
- "/repo/+/main",
- "/repo/+/deadbeef",
- "/repo/+/refs/heads/bad..ref",
- "/repo/+/HEAD/+/blame",
- "/repo/+/HEAD/+/feed/atom",
- "/repo/+/diff/HEAD...HEAD",
- "/repo/+/diff/HEAD..",
- "/repo/+/tree",
- "/repo.git/unknown",
- "/repo/%zz",
- "/repo?format=raw",
- ] {
- assert!(parse(path).is_err(), "accepted {path}");
- }
- }
-}
diff --git a/crates/gilti/src/ui.rs b/crates/gilti/src/ui.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/ui.rs
@@ -0,0 +1,296 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use maud::{Markup, PreEscaped, html};
+use serde::Deserialize;
+
+pub const PRIVATE_CONTENT_TYPE: &str = "application/vnd.gilti.repolist+json";
+
+#[derive(Deserialize)]
+struct RepoList {
+ page: String,
+ title: String,
+ root_desc: String,
+ root_url: String,
+ about_url: String,
+ noheader: bool,
+ search: String,
+ current_url: String,
+ root_readme: bool,
+ owner_enabled: bool,
+ links_enabled: bool,
+ section_grouping: bool,
+ shell: Shell,
+ sort_urls: SortUrls,
+ rows: Vec<Row>,
+ pager: Vec<Pager>,
+}
+
+#[derive(Deserialize)]
+struct Shell {
+ embedded: bool,
+ robots: String,
+ css: Vec<String>,
+ js: Vec<String>,
+ favicon: String,
+ head_include: Option<String>,
+ header: Option<String>,
+ footer_configured: bool,
+ footer: Option<String>,
+ logo: String,
+ logo_link: String,
+ cgit_version: String,
+ git_version: String,
+ generated_at: String,
+}
+
+#[derive(Deserialize)]
+struct SortUrls {
+ name: String,
+ desc: String,
+ owner: String,
+ idle: String,
+}
+
+#[derive(Deserialize)]
+#[serde(untagged)]
+enum Row {
+ Section(Section),
+ Repo(Box<Repo>),
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Section {
+ section: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct Repo {
+ name: String,
+ section: Option<String>,
+ url: String,
+ description: Description,
+ owner: String,
+ owner_url: String,
+ idle: Option<Age>,
+ log_url: String,
+ tree_url: String,
+}
+
+#[derive(Deserialize)]
+struct Description {
+ text: String,
+ truncated: bool,
+}
+
+#[derive(Deserialize)]
+struct Age {
+ timestamp: i64,
+ title: String,
+ unit: String,
+ amount: f64,
+}
+
+#[derive(Deserialize)]
+struct Pager {
+ url: String,
+ current: bool,
+}
+
+pub fn render(body: &[u8]) -> Result<Markup, serde_json::Error> {
+ let page: RepoList = serde_json::from_slice(body)?;
+ if page.page != "repolist" {
+ return Err(serde_json::Error::io(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "unknown private page",
+ )));
+ }
+ Ok(render_repolist(page))
+}
+
+fn include(content: &Option<String>) -> Markup {
+ PreEscaped(content.clone().unwrap_or_default())
+}
+
+fn render_repolist(page: RepoList) -> Markup {
+ let shell = &page.shell;
+ html! {
+ @if shell.embedded {
+ (include(&shell.header))
+ } @else {
+ (maud::DOCTYPE)
+ html lang="en" {
+ head {
+ title { (&page.title) }
+ meta name="generator" content=(format!("cgit {}", shell.cgit_version));
+
+ @if !shell.robots.is_empty() {
+ meta name="robots" content=(&shell.robots);
+ }
+
+ @if shell.css.is_empty() {
+ link rel="stylesheet" type="text/css" href="/cgit.css";
+ }
+
+ @for css in &shell.css {
+ @if !css.is_empty() {
+ link rel="stylesheet" type="text/css" href=(css);
+ }
+ }
+
+ @if shell.js.is_empty() {
+ script type="text/javascript" src="/cgit.js" {}
+ }
+
+ @for js in &shell.js {
+ @if !js.is_empty() {
+ script type="text/javascript" src=(js) {}
+ }
+ }
+
+ @if !shell.favicon.is_empty() {
+ link rel="shortcut icon" href=(&shell.favicon);
+ }
+
+ (include(&shell.head_include))
+ }
+ body {
+ (include(&shell.header))
+ (page_content(&page, true))
+ }
+ }
+ }
+ @if shell.embedded {
+ (page_content(&page, false))
+ (footer(shell, true))
+ }
+ }
+}
+
+fn page_content(page: &RepoList, footer_inside: bool) -> Markup {
+ let shell = &page.shell;
+ let has_repos = page.rows.iter().any(|row| matches!(row, Row::Repo(_)));
+ html! {
+ div id="cgit" {
+ @if !page.noheader {
+ table id="header" {
+ tr {
+ @if !shell.logo.is_empty() {
+ td class="logo" rowspan="2" {
+ a href=(if shell.logo_link.is_empty() { &page.root_url } else { &shell.logo_link }) {
+ img src=(&shell.logo) alt="cgit logo";
+ }
+ }
+ }
+ td class="main" { (&page.title) }
+ }
+ tr { td class="sub" { (&page.root_desc) } }
+ }
+ }
+ table class="tabs" { tr {
+ td {
+ a href=(&page.root_url) class="active" { "index" }
+ @if page.root_readme { a href=(&page.about_url) { "about" } }
+ }
+ td class="form" {
+ form method="get" action=(&page.current_url) {
+ input type="search" name="q" size="10" value=(&page.search);
+ input type="submit" value="search";
+ }
+ }
+ }}
+ div class="content" {
+ table summary="repository list" class="list nowrap" {
+ @if has_repos { tr class="nohover" {
+ th class="left" { a href=(&page.sort_urls.name) { "Name" } }
+ th class="left" { a href=(&page.sort_urls.desc) { "Description" } }
+ @if page.owner_enabled { th class="left" { a href=(&page.sort_urls.owner) { "Owner" } } }
+ th class="left" { a href=(&page.sort_urls.idle) { "Idle" } }
+ @if page.links_enabled { th class="left" { "Links" } }
+ }}
+ @for row in &page.rows {
+ @match row {
+ Row::Section(section) => tr class="nohover-highlight" {
+ td colspan=(3 + usize::from(page.owner_enabled) + usize::from(page.links_enabled)) class="reposection" { (section.section.as_deref().unwrap_or("")) }
+ },
+ Row::Repo(repo) => tr {
+ td class=(if page.section_grouping && repo.section.is_some() { "sublevel-repo" } else { "toplevel-repo" }) { a href=(&repo.url) { (&repo.name) } }
+ td { a href=(&repo.url) { (&repo.description.text) @if repo.description.truncated { "..." } } }
+ @if page.owner_enabled { td { a href=(&repo.owner_url) { (&repo.owner) } } }
+ td { @if let Some(age) = &repo.idle { span class=(if age.unit == "minutes" { "age-mins".to_owned() } else { format!("age-{}", age.unit) }) data-ut=(age.timestamp) title=(&age.title) { (format!("{:.0}", age.amount)) " " (if age.unit == "minutes" { "min." } else { &age.unit }) } } }
+ @if page.links_enabled { td { a class="button" href=(&repo.url) { "summary" } a class="button" href=(&repo.log_url) { "log" } a class="button" href=(&repo.tree_url) { "tree" } } }
+ },
+ }
+ }
+ }
+ @if !page.pager.is_empty() { ul class="pager" { @for (index, item) in page.pager.iter().enumerate() { li { a href=(&item.url) class=[item.current.then_some("current")] title=(format!("Page {}", index + 1)) { "[" (index + 1) "]" } } } } }
+ }
+ @if footer_inside { (footer(&page.shell, false)) }
+ }
+ }
+}
+
+fn footer(shell: &Shell, embedded: bool) -> Markup {
+ if let Some(footer) = &shell.footer {
+ return PreEscaped(footer.clone());
+ }
+ if embedded || shell.footer_configured {
+ return Markup::default();
+ }
+ html! { div class="footer" { "generated by " a href="https://git.zx2c4.com/cgit/about/" { "cgit " (&shell.cgit_version) } " (" a href="https://git-scm.com/" { "git " (&shell.git_version) } ") at " (&shell.generated_at) } }
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn renders_shell_and_escapes_data() {
+ let markup = super::render(br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/?p=about","noheader":false,"search":"<x>","current_url":"/","root_readme":true,"owner_enabled":true,"links_enabled":true,"section_grouping":true,"shell":{"embedded":false,"robots":"noindex","css":["/custom.css"],"js":["/custom.js"],"favicon":"/favicon.ico","head_include":"<meta name='x'>","header":"ignored","footer_configured":false,"footer":null,"logo":"/cgit.png","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[{"name":"<repo>","section":"group","url":"/?url=repo","description":{"text":"x&y","truncated":true},"owner":"o","owner_url":"/?q=o","idle":{"timestamp":1,"title":"date","unit":"years","amount":1},"log_url":"/?url=repo/log/&showmsg=1","tree_url":"/?url=repo/tree/"}],"pager":[]}"#).unwrap().into_string();
+ assert!(markup.contains("&lt;repo&gt;"));
+ assert!(markup.contains("x&amp;y..."));
+ assert!(markup.contains("href=\"/?url=repo/log/&amp;showmsg=1\""));
+ assert!(markup.contains("/custom.css"));
+ assert!(markup.contains("cgit.png"));
+ assert!(markup.contains("name='x'"));
+ }
+
+ #[test]
+ fn empty_rows_have_no_column_header() {
+ let markup = super::render(br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/?p=about","noheader":true,"search":"","current_url":"/","root_readme":false,"owner_enabled":false,"links_enabled":false,"section_grouping":false,"shell":{"embedded":false,"robots":"","css":[],"js":[],"favicon":"","head_include":null,"header":null,"footer_configured":false,"footer":null,"logo":"","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[],"pager":[{"url":"/?ofs=0","current":false}]}"#).unwrap().into_string();
+ assert!(!markup.contains("<tr class=\"nohover\">"));
+ }
+
+ #[test]
+ fn footer_respects_embedded_and_unreadable_include_modes() {
+ let mut shell = super::Shell {
+ embedded: false,
+ robots: String::new(),
+ css: Vec::new(),
+ js: Vec::new(),
+ favicon: String::new(),
+ head_include: None,
+ header: None,
+ footer_configured: false,
+ footer: None,
+ logo: String::new(),
+ logo_link: String::new(),
+ cgit_version: "v1".into(),
+ git_version: "2".into(),
+ generated_at: "now".into(),
+ };
+ assert!(super::footer(&shell, true).into_string().is_empty());
+ shell.footer_configured = true;
+ assert!(super::footer(&shell, false).into_string().is_empty());
+ shell.footer = Some("<footer>configured</footer>".into());
+ assert!(
+ super::footer(&shell, true)
+ .into_string()
+ .contains("<footer>configured</footer>")
+ );
+ }
+
+ #[test]
+ fn rejects_invalid_model() {
+ assert!(super::render(br#"{"page":"repolist"}"#).is_err());
+ }
+}
diff --git a/crates/gilti/src/views/about.rs b/crates/gilti/src/views/about.rs
deleted file mode 100644
--- a/crates/gilti/src/views/about.rs
+++ /dev/null
@@ -1,36 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::html;
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<()>,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let model = tokio::task::spawn_blocking(move || {
- crate::models::about::About::load(std::path::Path::new(repositories), &route.repo)
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => {
- return super::error(crate::models::Error::Internal(error.to_string()));
- }
- };
- let readme = String::from_utf8_lossy(&model.bytes);
- let content = html! { div id="summary" { pre { (readme) } } };
- super::shared::render(
- context,
- &model.repository,
- "HEAD",
- super::shared::Page::About,
- content,
- &method,
- )
-}
diff --git a/crates/gilti/src/views/archive.rs b/crates/gilti/src/views/archive.rs
deleted file mode 100644
--- a/crates/gilti/src/views/archive.rs
+++ /dev/null
@@ -1,188 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use tokio::io::AsyncWriteExt;
-
-#[derive(Clone, Copy)]
-pub enum Format {
- Tar,
- TarGzip,
- TarBzip2,
- TarLzip,
- TarXz,
- TarZstd,
- Zip,
-}
-
-impl Format {
- pub fn parse(value: Option<&str>) -> Option<Self> {
- match value.unwrap_or("tar.gz") {
- "tar" => Some(Self::Tar),
- "tar.gz" => Some(Self::TarGzip),
- "tar.bz2" => Some(Self::TarBzip2),
- "tar.lz" => Some(Self::TarLzip),
- "tar.xz" => Some(Self::TarXz),
- "tar.zst" => Some(Self::TarZstd),
- "zip" => Some(Self::Zip),
- _ => None,
- }
- }
-
- fn extension(self) -> &'static str {
- match self {
- Self::Tar => "tar",
- Self::TarGzip => "tar.gz",
- Self::TarBzip2 => "tar.bz2",
- Self::TarLzip => "tar.lz",
- Self::TarXz => "tar.xz",
- Self::TarZstd => "tar.zst",
- Self::Zip => "zip",
- }
- }
-
- fn content_type(self) -> &'static str {
- match self {
- Self::Tar => "application/x-tar; charset=UTF-8",
- Self::TarGzip => "application/x-gzip; charset=UTF-8",
- Self::TarBzip2 => "application/x-bzip2; charset=UTF-8",
- Self::TarLzip => "application/x-lzip; charset=UTF-8",
- Self::TarXz => "application/x-xz; charset=UTF-8",
- Self::TarZstd => "application/x-zstd; charset=UTF-8",
- Self::Zip => "application/x-zip; charset=UTF-8",
- }
- }
-
- fn git_format(self) -> &'static str {
- match self {
- Self::TarGzip => "tar.gz",
- Self::Zip => "zip",
- _ => "tar",
- }
- }
-
- fn compressor(self) -> Option<(&'static str, &'static [&'static str])> {
- match self {
- Self::TarBzip2 => Some(("/usr/bin/bzip2", &[])),
- Self::TarLzip => Some(("/usr/bin/lzip", &[])),
- Self::TarXz => Some(("/usr/bin/xz", &[])),
- Self::TarZstd => Some(("/usr/bin/zstd", &["-T0"])),
- _ => None,
- }
- }
-}
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<crate::router::RevisionPath>,
- format: Format,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let name = route.repo.clone();
- let revision = route.params.rev.clone();
- let path = route.params.path.clone();
- let model = tokio::task::spawn_blocking(move || {
- crate::models::archive::Archive::load(
- std::path::Path::new(repositories),
- &name,
- &revision,
- path.as_deref(),
- )
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => return super::error(crate::models::Error::Internal(error.to_string())),
- };
- let bytes = match generate(&model, route.params.path.as_deref(), format).await {
- Ok(bytes) => bytes,
- Err(error) => return super::error(crate::models::Error::Internal(error)),
- };
- let filename = format!("{}.{}", model.prefix, format.extension());
- super::bytes_response(
- format.content_type(),
- Some(content_disposition(&filename)),
- Some(format!("\"{}\"", model.oid)),
- bytes,
- &method,
- )
-}
-
-async fn generate(
- model: &crate::models::archive::Archive,
- path: Option<&str>,
- format: Format,
-) -> Result<Vec<u8>, String> {
- let mut command = tokio::process::Command::new(crate::GIT);
- command
- .arg("--git-dir")
- .arg(&model.repository_path)
- .arg("archive")
- .arg(format!("--format={}", format.git_format()))
- .arg(format!("--prefix={}/", model.prefix))
- .arg(&model.oid)
- .env("GIT_CONFIG_GLOBAL", "/dev/null")
- .env("GIT_CONFIG_NOSYSTEM", "1");
- if let Some(path) = path {
- command.arg("--").arg(format!(":(literal){path}"));
- }
- let output = command.output().await.map_err(|error| error.to_string())?;
- if !output.status.success() {
- return Err(format!(
- "git archive failed: {}",
- String::from_utf8_lossy(&output.stderr)
- ));
- }
- let Some((program, arguments)) = format.compressor() else {
- return Ok(output.stdout);
- };
- let mut child = tokio::process::Command::new(program)
- .args(arguments)
- .stdin(std::process::Stdio::piped())
- .stdout(std::process::Stdio::piped())
- .stderr(std::process::Stdio::piped())
- .spawn()
- .map_err(|error| error.to_string())?;
- let mut input = child.stdin.take().expect("compressor stdin is piped");
- let bytes = output.stdout;
- let writer = tokio::spawn(async move {
- input.write_all(&bytes).await?;
- input.shutdown().await
- });
- let output = child
- .wait_with_output()
- .await
- .map_err(|error| error.to_string())?;
- writer
- .await
- .map_err(|error| error.to_string())?
- .map_err(|error| error.to_string())?;
- if output.status.success() {
- Ok(output.stdout)
- } else {
- Err(format!(
- "archive compressor failed: {}",
- String::from_utf8_lossy(&output.stderr)
- ))
- }
-}
-
-fn content_disposition(filename: &str) -> String {
- let fallback = filename
- .chars()
- .map(|character| {
- if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
- character
- } else {
- '_'
- }
- })
- .collect::<String>();
- let encoded =
- percent_encoding::utf8_percent_encode(filename, percent_encoding::NON_ALPHANUMERIC);
- format!("inline; filename=\"{fallback}\"; filename*=UTF-8''{encoded}")
-}
diff --git a/crates/gilti/src/views/archive_signature.rs b/crates/gilti/src/views/archive_signature.rs
deleted file mode 100644
--- a/crates/gilti/src/views/archive_signature.rs
+++ /dev/null
@@ -1,53 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<crate::router::Revision>,
- format: Option<&str>,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let format = format.unwrap_or("tar.gz").to_owned();
- let model = tokio::task::spawn_blocking(move || {
- crate::models::archive_signature::ArchiveSignature::load(
- std::path::Path::new(repositories),
- &route.repo,
- route.params,
- &format,
- )
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => {
- return super::error(crate::models::Error::Internal(error.to_string()));
- }
- };
- let length = model.bytes.len();
- let body = if method == axum::http::Method::HEAD {
- axum::body::Body::empty()
- } else {
- axum::body::Body::from(model.bytes)
- };
- axum::response::Response::builder()
- .status(axum::http::StatusCode::OK)
- .header(
- axum::http::header::CONTENT_TYPE,
- "application/pgp-signature; charset=UTF-8",
- )
- .header(axum::http::header::CONTENT_LENGTH, length)
- .header(
- axum::http::header::CONTENT_DISPOSITION,
- format!("inline; filename=\"{}\"", model.filename),
- )
- .header(axum::http::header::ETAG, format!("\"{}\"", model.oid))
- .header("x-content-type-options", "nosniff")
- .header("content-security-policy", "default-src 'none'")
- .body(body)
- .expect("archive signature response is valid")
-}
diff --git a/crates/gilti/src/views/atom.rs b/crates/gilti/src/views/atom.rs
deleted file mode 100644
--- a/crates/gilti/src/views/atom.rs
+++ /dev/null
@@ -1,247 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<crate::router::RefPath>,
- host: Option<&axum::http::HeaderValue>,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let name = route.repo;
- let reference = route.params.reference;
- let path = route.params.path;
- let selection_path = path.clone();
- let model = tokio::task::spawn_blocking(move || {
- crate::models::history::History::load(
- std::path::Path::new(repositories),
- &name,
- crate::router::Revision::Ref(reference),
- crate::models::history::Options {
- path: selection_path,
- follow: false,
- search: crate::models::history::Search::None,
- offset: 0,
- limit: 10,
- graph: false,
- ignore_whitespace: false,
- include_statistics: false,
- },
- )
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => return super::error(crate::models::Error::Internal(error.to_string())),
- };
- let host = host
- .and_then(|host| host.to_str().ok())
- .filter(|host| !host.is_empty());
- let body = document(&model, path.as_deref(), host);
- super::bytes_response(
- "text/xml; charset=UTF-8",
- None,
- None,
- body.into_bytes(),
- &method,
- )
-}
-
-fn document(
- model: &crate::models::history::History,
- path: Option<&str>,
- host: Option<&str>,
-) -> String {
- let repository = super::shared::repository_url(&model.repository.name);
- let reference = &model.revision;
- let mut feed = String::from("<feed xmlns='http://www.w3.org/2005/Atom'>\n<title>");
- text(&mut feed, &model.repository.name);
- if let Some(path) = path {
- feed.push('/');
- text(&mut feed, path);
- }
- feed.push_str(", branch ");
- text(&mut feed, reference);
- feed.push_str("</title>\n<subtitle>");
- text(&mut feed, &model.repository.description);
- feed.push_str("</subtitle>\n");
- let self_path = path.map_or_else(
- || {
- format!(
- "{repository}/+/{}/+/feed/atom",
- super::shared::encode_path(&model.revision)
- )
- },
- |path| {
- format!(
- "{repository}/+/{}/+/feed/atom/{}",
- super::shared::encode_path(&model.revision),
- super::shared::encode_path(path)
- )
- },
- );
- if let Some(host) = host {
- let absolute = |path: &str| format!("http://{host}{path}");
- element(&mut feed, "id", &absolute(&self_path));
- attribute(
- &mut feed,
- "link",
- &[('r', "self"), ('h', &absolute(&self_path))],
- );
- attribute(
- &mut feed,
- "link",
- &[
- ('r', "alternate"),
- ('t', "text/html"),
- ('h', &absolute(&repository)),
- ],
- );
- }
- if let Some(entry) = model.entries.first() {
- element(&mut feed, "updated", &timestamp(&entry.committer));
- }
- for entry in &model.entries {
- feed.push_str("<entry>\n<title>");
- text(&mut feed, &entry.subject);
- feed.push_str("</title>\n");
- element(&mut feed, "updated", &timestamp(&entry.committer));
- feed.push_str("<author>\n");
- if !entry.author.name.is_empty() {
- element(&mut feed, "name", &entry.author.name);
- }
- if !entry.author.email.is_empty() {
- element(&mut feed, "email", &entry.author.email);
- }
- feed.push_str("</author>\n");
- element(&mut feed, "published", &timestamp(&entry.author));
- if let Some(host) = host {
- attribute(
- &mut feed,
- "link",
- &[
- ('r', "alternate"),
- ('t', "text/html"),
- ('h', &format!("http://{host}{repository}/+/{}", entry.oid)),
- ],
- );
- }
- element(&mut feed, "id", &urn(&entry.oid));
- feed.push_str("<content type='text'>\n");
- text(&mut feed, &entry.body);
- feed.push_str("</content>\n</entry>\n");
- }
- feed.push_str("</feed>\n");
- feed
-}
-
-fn element(output: &mut String, name: &str, value: &str) {
- output.push('<');
- output.push_str(name);
- output.push('>');
- text(output, value);
- output.push_str("</");
- output.push_str(name);
- output.push_str(">\n");
-}
-
-fn attribute(output: &mut String, name: &str, attributes: &[(char, &str)]) {
- output.push('<');
- output.push_str(name);
- for (key, value) in attributes {
- let key = match key {
- 'r' => "rel",
- 't' => "type",
- 'h' => "href",
- _ => unreachable!(),
- };
- output.push(' ');
- output.push_str(key);
- output.push_str("='");
- attribute_value(output, value);
- output.push('\'');
- }
- output.push_str("/>\n");
-}
-
-fn text(output: &mut String, value: &str) {
- for character in value.chars() {
- match character {
- '&' => output.push_str("&amp;"),
- '<' => output.push_str("&lt;"),
- '>' => output.push_str("&gt;"),
- character if xml_character(character) => output.push(character),
- _ => output.push('\u{fffd}'),
- }
- }
-}
-fn attribute_value(output: &mut String, value: &str) {
- for character in value.chars() {
- match character {
- '&' => output.push_str("&amp;"),
- '<' => output.push_str("&lt;"),
- '\'' => output.push_str("&apos;"),
- '"' => output.push_str("&quot;"),
- character if xml_character(character) => output.push(character),
- _ => output.push('\u{fffd}'),
- }
- }
-}
-
-fn xml_character(character: char) -> bool {
- matches!(character, '\u{9}' | '\u{a}' | '\u{d}')
- || ('\u{20}'..='\u{d7ff}').contains(&character)
- || ('\u{e000}'..='\u{fffd}').contains(&character)
- || character >= '\u{10000}'
-}
-
-fn urn(oid: &str) -> String {
- let algorithm = if oid.len() == 64 { "sha256" } else { "sha1" };
- format!("urn:{algorithm}:{oid}")
-}
-
-fn timestamp(identity: &crate::models::commit::Identity) -> String {
- let Some(value) = crate::models::time::utc(identity.timestamp) else {
- return "1970-01-01T00:00:00Z".to_owned();
- };
- format!(
- "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
- value.tm_year + 1900,
- value.tm_mon + 1,
- value.tm_mday,
- value.tm_hour,
- value.tm_min,
- value.tm_sec
- )
-}
-
-#[cfg(test)]
-mod tests {
- #[test]
- fn escapes_xml_and_identifies_sha256() {
- let mut output = String::new();
- super::text(&mut output, "<&>\0\u{1}\t");
- assert_eq!(output, "&lt;&amp;&gt;��\t");
- assert_eq!(
- super::urn(&"a".repeat(40)),
- format!("urn:sha1:{}", "a".repeat(40))
- );
- assert_eq!(
- super::urn(&"b".repeat(64)),
- format!("urn:sha256:{}", "b".repeat(64))
- );
- assert_eq!(
- super::timestamp(&crate::models::commit::Identity {
- name: String::new(),
- email: String::new(),
- timestamp: 0,
- offset_minutes: 330
- }),
- "1970-01-01T00:00:00Z"
- );
- }
-}
diff --git a/crates/gilti/src/views/blame.rs b/crates/gilti/src/views/blame.rs
deleted file mode 100644
--- a/crates/gilti/src/views/blame.rs
+++ /dev/null
@@ -1,112 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::{Markup, html};
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<crate::router::RevisionFile>,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let model = tokio::task::spawn_blocking(move || {
- crate::models::blame::Blame::load(
- std::path::Path::new(repositories),
- &route.repo,
- route.params.rev,
- route.params.path,
- )
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => {
- return super::error(crate::models::Error::Internal(error.to_string()));
- }
- };
- let content = content(&model);
- super::shared::render(
- context,
- &model.repository,
- &model.revision,
- super::shared::Page::Tree,
- content,
- &method,
- )
-}
-
-fn content(model: &crate::models::blame::Blame) -> Markup {
- let repo = super::shared::repository_url(&model.repository.name);
- let revision = super::shared::encode_path(&model.revision);
- let path = super::shared::encode_path(&model.path);
- html! {
- div class="path" { "path: " (breadcrumbs(&repo, &revision, &model.path)) }
- "blob: " (&model.oid) " ("
- a href=(format!("{repo}/+/{revision}/+/tree/{path}?format=raw")) { "plain" }
- ") ("
- a href=(format!("{repo}/+/{revision}/+/tree/{path}")) { "tree" }
- ")"
- @if model.binary {
- div class="error" { "blob is binary." }
- } @else {
- (blame_table(model, &repo))
- }
- }
-}
-
-fn blame_table(model: &crate::models::blame::Blame, repo: &str) -> Markup {
- let lines = String::from_utf8_lossy(&model.bytes)
- .split_inclusive('\n')
- .map(str::to_owned)
- .collect::<Vec<_>>();
- html! { table class="blame blob" {
- @for hunk in &model.hunks {
- @for offset in 0..hunk.lines {
- @let line = hunk.start + offset;
- tr class=[(offset % 2 == 0).then_some("alt")] {
- @if offset == 0 {
- td class="hashes" rowspan=(hunk.lines) {
- span class="oid" title=(format!(
- "author {} <{}> {}\ncommitter {} <{}>\n\n{}",
- hunk.author, hunk.author_email,
- super::shared::age(hunk.timestamp),
- hunk.committer, hunk.committer_email,
- hunk.summary
- )) {
- a href=(format!("{repo}/+/{}", hunk.oid)) { (&hunk.short_oid) }
- }
- @if let Some(parent) = &hunk.parent {
- " " a href=(format!(
- "{repo}/+/{parent}/+/blame/{}",
- super::shared::encode_path(&hunk.original_path)
- )) title="Blame the previous revision" { "^" }
- }
- }
- }
- td class="linenumbers" { a id=(format!("n{line}")) href=(format!("#n{line}")) { (line) } }
- td class="lines" { pre { code { (lines.get(line - 1).map_or("", String::as_str)) } } }
- }
- }
- }
- } }
-}
-
-fn breadcrumbs(repository: &str, revision: &str, path: &str) -> Markup {
- let parts = path.split('/').collect::<Vec<_>>();
- html! {
- a href=(format!("{repository}/+/{revision}/+/tree")) { "root" }
- @for (index, part) in parts.iter().enumerate() {
- "/"
- @let current = parts[..=index].join("/");
- @if index + 1 == parts.len() {
- a href=(format!("{repository}/+/{revision}/+/blame/{}", super::shared::encode_path(&current))) { (part) }
- } @else {
- a href=(format!("{repository}/+/{revision}/+/tree/{}", super::shared::encode_path(&current))) { (part) }
- }
- }
- }
-}
diff --git a/crates/gilti/src/views/diff.rs b/crates/gilti/src/views/diff.rs
deleted file mode 100644
--- a/crates/gilti/src/views/diff.rs
+++ /dev/null
@@ -1,456 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::{Markup, html};
-
-#[derive(Clone, Copy, Eq, PartialEq)]
-pub enum Mode {
- Unified,
- SideBySide,
- StatOnly,
-}
-
-#[derive(Clone, Copy)]
-pub struct Query {
- pub context: u32,
- pub ignore_whitespace: bool,
- pub mode: Mode,
-}
-
-impl Query {
- pub fn from_request(query: &crate::RequestQuery) -> Result<Self, ()> {
- let context = query
- .value("GILTI_QUERY_CONTEXT")
- .map(str::parse)
- .transpose()
- .map_err(|_| ())?
- .unwrap_or(3);
- let context = if context == 0 { 3 } else { context };
- if context > 40 {
- return Err(());
- }
- let ignore_whitespace = match query.value("GILTI_QUERY_IGNOREWS") {
- None | Some("0") => false,
- Some("1") => true,
- _ => return Err(()),
- };
- let mode = match query.value("GILTI_QUERY_DIFFTYPE") {
- None | Some("0") => Mode::Unified,
- Some("1") => Mode::SideBySide,
- Some("2") => Mode::StatOnly,
- _ => return Err(()),
- };
- Ok(Self {
- context,
- ignore_whitespace,
- mode,
- })
- }
-
- pub fn options(self) -> crate::models::diff::Options {
- crate::models::diff::Options {
- context: self.context,
- ignore_whitespace: self.ignore_whitespace,
- }
- }
-}
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<crate::router::Comparison>,
- query: Query,
- raw: bool,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let name = route.repo.clone();
- let old = route.params.old_rev.clone();
- let new = route.params.new_rev.clone();
- let path = route.params.path.clone();
- let model = tokio::task::spawn_blocking(move || {
- crate::models::diff::Diff::load(
- std::path::Path::new(repositories),
- &name,
- Some(old),
- new,
- path,
- query.options(),
- )
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => return super::error(crate::models::Error::Internal(error.to_string())),
- };
- if raw {
- return raw_response(context, &route, &model, query, &method).await;
- }
- let revision = model.new_revision.clone();
- let content = content(&model, query, route.params.path.as_deref(), true);
- super::shared::render(
- context,
- &model.repository,
- &revision,
- super::shared::Page::Diff,
- content,
- &method,
- )
-}
-
-pub fn content(
- model: &crate::models::diff::Diff,
- query: Query,
- path: Option<&str>,
- controls: bool,
-) -> Markup {
- html! {
- @if controls { (content_controls(query)) }
- div class="diffstat-header" {
- "Diffstat"
- @if let Some(path) = path { " (limited to '" (path) "')" }
- }
- table summary="diffstat" class="diffstat" {
- @for file in &model.files { (file_stat(model, file)) }
- }
- div class="diffstat-summary" {
- (model.files.len()) " files changed, " (model.additions) " insertions, " (model.deletions) " deletions"
- }
- @if query.mode != Mode::StatOnly {
- @if query.mode == Mode::SideBySide {
- table summary="ssdiff" class="ssdiff" {
- @for file in &model.files { (side_file(model, file)) }
- }
- } @else {
- table summary="diff" class="diff" { tr { td {
- @for file in &model.files { (unified_file(model, file)) }
- } } }
- }
- }
- }
-}
-
-pub fn content_controls(query: Query) -> Markup {
- html! { div class="gilti-panel" {
- b { "diff options" }
- form method="get" { table {
- tr { td colspan="2" {} }
- tr { td class="label" { "context:" } td class="ctrl" {
- select name="context" onchange="this.form.submit();" {
- @for value in [1_u32,2,3,4,5,6,7,8,9,10,15,20,25,30,35,40] {
- option value=(value) selected[query.context == value] { (value) }
- }
- }
- } }
- tr { td class="label" { "space:" } td class="ctrl" {
- select name="ignorews" onchange="this.form.submit();" {
- option value="0" selected[!query.ignore_whitespace] { "include" }
- option value="1" selected[query.ignore_whitespace] { "ignore" }
- }
- } }
- tr { td class="label" { "mode:" } td class="ctrl" {
- select name="dt" onchange="this.form.submit();" {
- option value="0" selected[query.mode == Mode::Unified] { "unified" }
- option value="1" selected[query.mode == Mode::SideBySide] { "ssdiff" }
- option value="2" selected[query.mode == Mode::StatOnly] { "stat only" }
- }
- } }
- tr { td {} td class="ctrl" { noscript { input type="submit" value="reload"; } } }
- } }
- } }
-}
-
-fn file_stat(model: &crate::models::diff::Diff, file: &crate::models::diff::File) -> Markup {
- let path = if file.new_path.is_empty() {
- &file.old_path
- } else {
- &file.new_path
- };
- let repo = super::shared::repository_url(&model.repository.name);
- let old = super::shared::encode_path(model.old_revision.as_deref().unwrap_or("HEAD"));
- let new = super::shared::encode_path(&model.new_revision);
- let path_url = super::shared::encode_path(path);
- let class = status_class(file.status);
- html! { tr {
- td class="mode" {
- (super::tree::filemode(if file.new_mode == 0 { file.old_mode } else { file.new_mode }))
- @if file.old_mode != 0 && file.new_mode != 0 && file.old_mode != file.new_mode {
- span class="modechange" { "[" (super::tree::filemode(file.old_mode)) "]" }
- }
- }
- td class=(class) {
- a href=(format!("{repo}/+/diff/{old}..{new}/+/{path_url}")) { (path) }
- @if matches!(file.status, git2::Delta::Renamed | git2::Delta::Copied) {
- " (" @if file.status == git2::Delta::Copied { "copied" } @else { "renamed" }
- " from " (&file.old_path) ")"
- }
- }
- td class="right" {
- @if file.binary { "bin" } @else { (file.additions + file.deletions) }
- }
- td class="graph" {
- @if file.binary { (file.old_size) " -> " (file.new_size) " bytes" }
- @else { "+" (file.additions) " −" (file.deletions) }
- }
- } }
-}
-
-fn unified_file(model: &crate::models::diff::Diff, file: &crate::models::diff::File) -> Markup {
- html! {
- (file_header(model, file))
- @if file.binary { div class="ctx" { "Binary files differ" } }
- @for hunk in &file.hunks {
- div class="hunk" { (&hunk.header) }
- @for line in &hunk.lines {
- div class=(line_class(line.origin)) {
- @if matches!(line.origin, '+' | '-' | ' ') { (line.origin) }
- (&line.content)
- }
- }
- }
- }
-}
-
-fn side_file(model: &crate::models::diff::Diff, file: &crate::models::diff::File) -> Markup {
- html! {
- tr { td colspan="4" { (file_header(model, file)) } }
- @if file.binary { tr { td colspan="4" { "Binary files differ" } } }
- @for hunk in &file.hunks {
- tr { td colspan="4" class="hunk" { (&hunk.header) } }
- @for (old, new) in side_rows(hunk) { tr {
- td class="lineno" { @if let Some(line) = old { (line.old_line.unwrap_or_default()) } }
- td class=[old.map(|line| line_class(line.origin))] { (side_content(old, new, true)) }
- td class="lineno" { @if let Some(line) = new { (line.new_line.unwrap_or_default()) } }
- td class=[new.map(|line| line_class(line.origin))] { (side_content(new, old, false)) }
- } }
- }
- }
-}
-
-fn side_rows(
- hunk: &crate::models::diff::Hunk,
-) -> Vec<(
- Option<&crate::models::diff::Line>,
- Option<&crate::models::diff::Line>,
-)> {
- let mut rows = Vec::new();
- let mut removed = Vec::new();
- let mut added = Vec::new();
- let flush = |rows: &mut Vec<_>, removed: &mut Vec<_>, added: &mut Vec<_>| {
- let count = removed.len().max(added.len());
- for index in 0..count {
- rows.push((removed.get(index).copied(), added.get(index).copied()));
- }
- removed.clear();
- added.clear();
- };
- for line in &hunk.lines {
- match line.origin {
- '-' => removed.push(line),
- '+' => added.push(line),
- _ => {
- flush(&mut rows, &mut removed, &mut added);
- rows.push((Some(line), Some(line)));
- }
- }
- }
- flush(&mut rows, &mut removed, &mut added);
- rows
-}
-
-fn side_content(
- line: Option<&crate::models::diff::Line>,
- other: Option<&crate::models::diff::Line>,
- old: bool,
-) -> Markup {
- let Some(line) = line else {
- return html! {};
- };
- let paired_change =
- other.is_some_and(|other| matches!((line.origin, other.origin), ('-', '+') | ('+', '-')));
- if !paired_change {
- return html! { (&line.content) };
- }
- let other = &other.expect("paired line exists").content;
- let common = if old {
- common_subsequence(&line.content, other)
- } else {
- common_subsequence(other, &line.content)
- };
- let segments = highlighted_segments(&line.content, &common);
- let class = if old { "del" } else { "add" };
- html! {
- @for (changed, text) in segments {
- @if changed { span class=(class) { (text) } } @else { (text) }
- }
- }
-}
-
-fn common_subsequence(old: &str, new: &str) -> Vec<char> {
- let old = old.chars().collect::<Vec<_>>();
- let new = new.chars().collect::<Vec<_>>();
- if old.len() >= 200 || new.len() >= 200 {
- return Vec::new();
- }
- let mut lengths = vec![vec![0_usize; new.len() + 1]; old.len() + 1];
- for left in (0..old.len()).rev() {
- for right in (0..new.len()).rev() {
- lengths[left][right] = if old[left] == new[right] {
- lengths[left + 1][right + 1] + 1
- } else {
- lengths[left + 1][right].max(lengths[left][right + 1])
- };
- }
- }
- let (mut left, mut right) = (0, 0);
- let mut common = Vec::with_capacity(lengths[0][0]);
- while left < old.len() && right < new.len() {
- if old[left] == new[right] {
- common.push(old[left]);
- left += 1;
- right += 1;
- } else if lengths[left + 1][right] >= lengths[left][right + 1] {
- left += 1;
- } else {
- right += 1;
- }
- }
- common
-}
-
-fn highlighted_segments(value: &str, common: &[char]) -> Vec<(bool, String)> {
- let mut common = common.iter().copied().peekable();
- let mut segments = Vec::<(bool, String)>::new();
- for character in value.chars() {
- let changed = common.peek().copied() != Some(character);
- if !changed {
- common.next();
- }
- if let Some((last_changed, text)) = segments.last_mut()
- && *last_changed == changed
- {
- text.push(character);
- continue;
- }
- segments.push((changed, character.to_string()));
- }
- segments
-}
-
-fn file_header(model: &crate::models::diff::Diff, file: &crate::models::diff::File) -> Markup {
- let old = if file.old_path.is_empty() {
- "/dev/null"
- } else {
- &file.old_path
- };
- let new = if file.new_path.is_empty() {
- "/dev/null"
- } else {
- &file.new_path
- };
- html! { div class="head" {
- "diff --git a/" (old) " b/" (new)
- @if file.old_mode == 0 { br; "new file mode " (format!("{:06o}", file.new_mode)) }
- @if file.new_mode == 0 { br; "deleted file mode " (format!("{:06o}", file.old_mode)) }
- @if let (Some(old_oid), Some(new_oid)) = (&file.old_oid, &file.new_oid) {
- br; "index " (&old_oid[..old_oid.len().min(7)]) ".." (&new_oid[..new_oid.len().min(7)])
- @if file.old_mode != 0 && file.new_mode != 0 {
- " " (format!("{:06o}", file.old_mode))
- @if file.old_mode != file.new_mode { ".." (format!("{:06o}", file.new_mode)) }
- }
- }
- br; "--- " @if file.old_mode == 0 { "/dev/null" } @else { "a/" (old) }
- br; "+++ " @if file.new_mode == 0 { "/dev/null" } @else { "b/" (new) }
- @if model.old_oid.is_none() { "" }
- } }
-}
-
-fn line_class(origin: char) -> &'static str {
- match origin {
- '+' => "add",
- '-' => "del",
- '@' => "hunk",
- _ => "ctx",
- }
-}
-
-fn status_class(status: git2::Delta) -> &'static str {
- match status {
- git2::Delta::Added => "add",
- git2::Delta::Copied => "cpy",
- git2::Delta::Deleted => "del",
- git2::Delta::Modified => "upd",
- git2::Delta::Renamed => "mov",
- git2::Delta::Typechange => "typ",
- git2::Delta::Conflicted => "stg",
- _ => "unk",
- }
-}
-
-async fn raw_response(
- context: &super::shared::Context,
- route: &crate::router::RepoRoute<crate::router::Comparison>,
- model: &crate::models::diff::Diff,
- query: Query,
- method: &axum::http::Method,
-) -> axum::response::Response {
- let repository = match crate::models::repository::path(
- std::path::Path::new(context.repositories),
- &route.repo,
- ) {
- Ok(repository) => repository,
- Err(error) => return super::error(error),
- };
- let mut command = tokio::process::Command::new(crate::GIT);
- command
- .arg("--git-dir")
- .arg(repository)
- .arg("-c")
- .arg("color.ui=false")
- .arg("diff")
- .arg("--no-ext-diff")
- .arg("--no-textconv")
- .arg("--no-renames")
- .arg(format!("--unified={}", query.context));
- if query.ignore_whitespace {
- command.arg("--ignore-all-space");
- }
- command
- .arg(model.old_oid.as_deref().unwrap_or(""))
- .arg(&model.new_oid);
- if let Some(path) = &route.params.path {
- command.arg("--").arg(path);
- }
- let output = match command.output().await {
- Ok(output) if output.status.success() => output.stdout,
- Ok(output) => {
- eprintln!(
- "gilti: git diff failed: {}",
- String::from_utf8_lossy(&output.stderr)
- );
- return super::error(crate::models::Error::Internal("git diff failed".to_owned()));
- }
- Err(error) => return super::error(crate::models::Error::Internal(error.to_string())),
- };
- super::bytes_response("text/plain; charset=UTF-8", None, None, output, method)
-}
-
-#[cfg(test)]
-mod tests {
- #[test]
- fn highlights_side_by_side_character_changes() {
- let common = super::common_subsequence("alpha", "aloha");
- assert_eq!(common, ['a', 'l', 'h', 'a']);
- assert_eq!(
- super::highlighted_segments("alpha", &common),
- [
- (false, "al".to_owned()),
- (true, "p".to_owned()),
- (false, "ha".to_owned()),
- ]
- );
- }
-}
diff --git a/crates/gilti/src/views/log.rs b/crates/gilti/src/views/log.rs
deleted file mode 100644
--- a/crates/gilti/src/views/log.rs
+++ /dev/null
@@ -1,298 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::{Markup, html};
-
-pub struct Query {
- pub offset: usize,
- pub show_message: bool,
- pub follow: bool,
- pub ignore_whitespace: bool,
- pub search: Search,
-}
-
-pub enum Search {
- None,
- Grep(String),
- Author(String),
- Committer(String),
- Range(String),
-}
-
-impl Query {
- pub fn from_request(query: &crate::RequestQuery) -> Result<Self, ()> {
- let offset = match query.value("GILTI_QUERY_OFFSET") {
- None => 0,
- Some(value) => value.parse().map_err(|_| ())?,
- };
- let boolean = |name| match query.value(name) {
- None | Some("0") => Ok(false),
- Some("1") => Ok(true),
- Some(_) => Err(()),
- };
- let search = match (
- query.value("GILTI_QUERY_GREP"),
- query.value("GILTI_QUERY_SEARCH"),
- ) {
- (None, None) => Search::None,
- (Some(_), None) | (None, Some(_)) => return Err(()),
- (Some("grep"), Some(value)) => Search::Grep(value.to_owned()),
- (Some("author"), Some(value)) => Search::Author(value.to_owned()),
- (Some("committer"), Some(value)) => Search::Committer(value.to_owned()),
- (Some("range"), Some(value)) if valid_range(value) => Search::Range(value.to_owned()),
- _ => return Err(()),
- };
- Ok(Self {
- offset,
- show_message: boolean("GILTI_QUERY_SHOWMSG")?,
- follow: boolean("GILTI_QUERY_FOLLOW")?,
- ignore_whitespace: boolean("GILTI_QUERY_IGNOREWS")?,
- search,
- })
- }
-
- fn history(&self) -> crate::models::history::Search {
- match &self.search {
- Search::None => crate::models::history::Search::None,
- Search::Grep(value) => crate::models::history::Search::Grep(value.clone()),
- Search::Author(value) => crate::models::history::Search::Author(value.clone()),
- Search::Committer(value) => crate::models::history::Search::Committer(value.clone()),
- Search::Range(value) => crate::models::history::Search::Range(value.clone()),
- }
- }
-
- fn suffix(&self, offset: usize, show_message: bool, follow: bool) -> String {
- let mut values = Vec::new();
- if offset != 0 {
- values.push(format!("ofs={offset}"));
- }
- if show_message {
- values.push("showmsg=1".to_owned());
- }
- if follow {
- values.push("follow=1".to_owned());
- }
- if self.ignore_whitespace {
- values.push("ignorews=1".to_owned());
- }
- match &self.search {
- Search::None => {}
- Search::Grep(value) => {
- values.push("qt=grep".to_owned());
- values.push(format!("q={}", query(value)));
- }
- Search::Author(value) => {
- values.push("qt=author".to_owned());
- values.push(format!("q={}", query(value)));
- }
- Search::Committer(value) => {
- values.push("qt=committer".to_owned());
- values.push(format!("q={}", query(value)));
- }
- Search::Range(value) => {
- values.push("qt=range".to_owned());
- values.push(format!("q={}", query(value)));
- }
- }
- if values.is_empty() {
- String::new()
- } else {
- format!("?{}", values.join("&"))
- }
- }
-}
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<crate::router::RevisionPath>,
- query: Query,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let name = route.repo;
- let revision = route.params.rev;
- let path = route.params.path;
- let options = crate::models::history::Options {
- path: path.clone(),
- follow: query.follow,
- search: query.history(),
- offset: query.offset,
- limit: crate::models::history::LOG_PAGE_SIZE,
- graph: true,
- ignore_whitespace: query.ignore_whitespace,
- include_statistics: true,
- };
- let model = tokio::task::spawn_blocking(move || {
- crate::models::history::History::load(
- std::path::Path::new(repositories),
- &name,
- revision,
- options,
- )
- })
- .await;
- let model = match model {
- Ok(Ok(value)) => value,
- Ok(Err(error)) => return super::error(error),
- Err(error) => return super::error(crate::models::Error::Internal(error.to_string())),
- };
- super::shared::render(
- context,
- &model.repository,
- &model.revision,
- super::shared::Page::Log,
- content(&model, &query, path.as_deref()),
- &method,
- )
-}
-
-fn content(model: &crate::models::history::History, query: &Query, path: Option<&str>) -> Markup {
- let base = format!(
- "{}/+/{}/+/log",
- super::shared::repository_url(&model.repository.name),
- super::shared::encode_path(&model.revision)
- );
- let base = path.map_or(base.clone(), |path| {
- format!("{base}/{}", super::shared::encode_path(path))
- });
- let columns = if model.graph { 6 } else { 5 };
- html! {
- form class="right" method="get" action=(&base) {
- @if query.show_message { input type="hidden" name="showmsg" value="1"; }
- @if query.follow { input type="hidden" name="follow" value="1"; }
- @if query.ignore_whitespace { input type="hidden" name="ignorews" value="1"; }
- select name="qt" {
- option value="grep" selected[matches!(query.search, Search::Grep(_))] { "log msg" }
- option value="author" selected[matches!(query.search, Search::Author(_))] { "author" }
- option value="committer" selected[matches!(query.search, Search::Committer(_))] { "committer" }
- option value="range" selected[matches!(query.search, Search::Range(_))] { "range" }
- }
- input class="txt" type="search" size="10" name="q" value=(search_value(&query.search));
- input type="submit" value="search";
- }
- @if let Some(path) = path {
- div class="path" {
- "path: " (path) " ("
- a href=(format!("{}{}", base, query.suffix(query.offset, query.show_message, !query.follow))) { (if query.follow { "unfollow" } else { "follow" }) }
- ")"
- }
- }
- table class="list nowrap" {
- tr class="nohover" {
- @if model.graph { th {} } @else { th class="left" { "Age" } }
- th class="left" { "Commit message (" a href=(format!("{}{}", base, query.suffix(query.offset, !query.show_message, query.follow))) { (if query.show_message { "Collapse" } else { "Expand" }) } ")" }
- th class="left" { "Author" }
- @if model.graph { th class="left" { "Age" } }
- th class="left" { "Files" }
- th class="left" { "Lines" }
- }
- @for entry in &model.entries {
- @if model.graph {
- @for continuation in &entry.graph_continuations {
- tr class="nohover" { td class="commitgraph" { (graph(continuation)) } td colspan=(columns - 1) {} }
- }
- }
- tr class=[query.show_message.then_some("logheader")] {
- @if model.graph { td class="commitgraph" { (graph(&entry.graph)) } } @else { td { (age(&entry.committer)) } }
- td class=[query.show_message.then_some("logsubject")] {
- a href=(format!("{}/+/{}", super::shared::repository_url(&model.repository.name), entry.oid)) { (&entry.subject) }
- @for decoration in &entry.decorations {
- @if let Some(reference) = &decoration.reference {
- @if reference.starts_with("refs/tags/") {
- span class="decoration" { " " a class="tag-deco" href=(format!("{}/+/{}", super::shared::repository_url(&model.repository.name), super::shared::encode_path(reference))) { (&decoration.label) } }
- } @else {
- @let link = entry.path.as_ref().map_or_else(|| format!("{}/+/{}/+/log{}", super::shared::repository_url(&model.repository.name), super::shared::encode_path(reference), query.suffix(0, query.show_message, query.follow)), |path| format!("{}/+/{}/+/log/{}{}", super::shared::repository_url(&model.repository.name), super::shared::encode_path(reference), super::shared::encode_path(path), query.suffix(0, query.show_message, query.follow)));
- span class="decoration" { " " a class="branch-deco" href=(link) { (&decoration.label) } }
- }
- } @else { span class="decoration" { " " (&decoration.label) } }
- }
- }
- td { (&entry.author.name) }
- @if model.graph { td { (age(&entry.committer)) } }
- td { (entry.files) }
- td { span class="deletions" { "-" (entry.deletions) } "/" span class="insertions" { "+" (entry.additions) } }
- }
- @if query.show_message { tr class="nohover-highlight" { td colspan=(columns) class="logmsg" { (&entry.body) @if let Some(notes) = &entry.notes { "\n" (notes) } } } }
- }
- }
- ul class="pager" {
- @if query.offset > 0 { li { a href=(format!("{}{}", base, query.suffix(query.offset.saturating_sub(crate::models::history::LOG_PAGE_SIZE), query.show_message, query.follow))) { "[prev]" } } }
- @if model.has_next { li { a href=(format!("{}{}", base, query.suffix(query.offset + crate::models::history::LOG_PAGE_SIZE, query.show_message, query.follow))) { "[next]" } } }
- }
- }
-}
-
-fn graph(line: &str) -> Markup {
- html! {
- @for (index, character) in line.chars().enumerate() {
- span class=(format!("column{}", index / 2 % 6 + 1)) { (character) }
- }
- }
-}
-
-fn search_value(search: &Search) -> &str {
- match search {
- Search::None => "",
- Search::Grep(value)
- | Search::Author(value)
- | Search::Committer(value)
- | Search::Range(value) => value,
- }
-}
-
-fn age(identity: &crate::models::commit::Identity) -> String {
- let now = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map_or(0, |duration| duration.as_secs() as i64);
- if now.saturating_sub(identity.timestamp) < 14 * 24 * 60 * 60 {
- return super::shared::age(identity.timestamp);
- }
- let local = identity.timestamp + i64::from(identity.offset_minutes) * 60;
- let Some(value) = crate::models::time::utc(local) else {
- return super::shared::age(identity.timestamp);
- };
- format!(
- "{:04}-{:02}-{:02}",
- value.tm_year + 1900,
- value.tm_mon + 1,
- value.tm_mday
- )
-}
-
-fn valid_range(value: &str) -> bool {
- let mut any = false;
- for selector in value.split_ascii_whitespace() {
- any = true;
- let valid = |selector: &str| {
- selector == "HEAD"
- || selector.starts_with("refs/")
- || (matches!(selector.len(), 40 | 64)
- && selector.bytes().all(|byte| byte.is_ascii_hexdigit()))
- };
- if selector.starts_with('-') || !selector.split("..").all(valid) {
- return false;
- }
- }
- any
-}
-
-fn query(value: &str) -> String {
- percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string()
-}
-
-#[cfg(test)]
-mod tests {
- #[test]
- fn query_is_strict() {
- assert!(super::Query::from_request(&crate::RequestQuery::default()).is_ok());
- let query = crate::request_query(Some("follow=2")).unwrap();
- assert!(super::Query::from_request(&query).is_err());
- let query = crate::request_query(Some("qt=range&q=-bad")).unwrap();
- assert!(super::Query::from_request(&query).is_err());
- let query = crate::request_query(Some("qt=range&q=HEAD..refs%2Fheads%2Fmain")).unwrap();
- assert!(super::Query::from_request(&query).is_ok());
- }
-}
diff --git a/crates/gilti/src/views/mod.rs b/crates/gilti/src/views/mod.rs
deleted file mode 100644
--- a/crates/gilti/src/views/mod.rs
+++ /dev/null
@@ -1,80 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-pub mod about;
-pub mod archive;
-pub mod archive_signature;
-pub mod atom;
-pub mod blame;
-pub mod diff;
-pub mod log;
-pub mod object;
-pub mod overview;
-pub mod patch;
-pub mod refs;
-pub mod repositories;
-pub mod revision;
-pub mod shared;
-pub mod stats;
-pub mod tag;
-pub mod tree;
-
-fn method_not_allowed() -> axum::response::Response {
- axum::response::Response::builder()
- .status(axum::http::StatusCode::METHOD_NOT_ALLOWED)
- .header(axum::http::header::CONTENT_TYPE, "text/plain")
- .body(axum::body::Body::from("method not allowed\n"))
- .expect("static response is valid")
-}
-
-pub(crate) fn bad_request(message: &'static str) -> axum::response::Response {
- axum::response::Response::builder()
- .status(axum::http::StatusCode::BAD_REQUEST)
- .header(axum::http::header::CONTENT_TYPE, "text/plain")
- .body(axum::body::Body::from(message))
- .expect("static response is valid")
-}
-
-pub(crate) fn bytes_response(
- content_type: &'static str,
- content_disposition: Option<String>,
- etag: Option<String>,
- bytes: Vec<u8>,
- method: &axum::http::Method,
-) -> axum::response::Response {
- let length = bytes.len();
- let body = if method == axum::http::Method::HEAD {
- axum::body::Body::empty()
- } else {
- axum::body::Body::from(bytes)
- };
- let mut response = axum::response::Response::builder()
- .status(axum::http::StatusCode::OK)
- .header(axum::http::header::CONTENT_TYPE, content_type)
- .header(axum::http::header::CONTENT_LENGTH, length);
- if let Some(value) = content_disposition {
- response = response.header(axum::http::header::CONTENT_DISPOSITION, value);
- }
- if let Some(value) = etag {
- response = response.header(axum::http::header::ETAG, value);
- }
- response.body(body).expect("byte response is valid")
-}
-
-fn error(error: crate::models::Error) -> axum::response::Response {
- let (status, message) = match error {
- crate::models::Error::NotFound => (axum::http::StatusCode::NOT_FOUND, "not found\n"),
- crate::models::Error::Internal(message) => {
- eprintln!("gilti: repository view failed: {message}");
- (
- axum::http::StatusCode::INTERNAL_SERVER_ERROR,
- "internal server error\n",
- )
- }
- };
- axum::response::Response::builder()
- .status(status)
- .header(axum::http::header::CONTENT_TYPE, "text/plain")
- .body(axum::body::Body::from(message))
- .expect("static response is valid")
-}
diff --git a/crates/gilti/src/views/object.rs b/crates/gilti/src/views/object.rs
deleted file mode 100644
--- a/crates/gilti/src/views/object.rs
+++ /dev/null
@@ -1,52 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-pub async fn serve(
- repositories: &'static str,
- route: crate::router::RepoRoute<String>,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return axum::response::Response::builder()
- .status(axum::http::StatusCode::METHOD_NOT_ALLOWED)
- .header(axum::http::header::CONTENT_TYPE, "text/plain")
- .body(axum::body::Body::from("method not allowed\n"))
- .expect("static response is valid");
- }
- let model = tokio::task::spawn_blocking(move || {
- crate::models::object::RawObject::load(
- std::path::Path::new(repositories),
- &route.repo,
- &route.params,
- )
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => {
- return super::error(crate::models::Error::Internal(error.to_string()));
- }
- };
- let length = model.bytes.len();
- let body = if method == axum::http::Method::HEAD {
- axum::body::Body::empty()
- } else {
- axum::body::Body::from(model.bytes)
- };
- axum::response::Response::builder()
- .status(axum::http::StatusCode::OK)
- .header(
- axum::http::header::CONTENT_TYPE,
- if model.binary {
- "application/octet-stream; charset=UTF-8"
- } else {
- "text/plain; charset=UTF-8"
- },
- )
- .header(axum::http::header::CONTENT_LENGTH, length)
- .header("x-content-type-options", "nosniff")
- .header("content-security-policy", "default-src 'none'")
- .body(body)
- .expect("object response is valid")
-}
diff --git a/crates/gilti/src/views/overview.rs b/crates/gilti/src/views/overview.rs
deleted file mode 100644
--- a/crates/gilti/src/views/overview.rs
+++ /dev/null
@@ -1,155 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::{Markup, html};
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<()>,
- headers: &axum::http::HeaderMap,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let clone_url = clone_url(context, headers, &route.repo);
- let repositories = context.repositories;
- let model = tokio::task::spawn_blocking(move || {
- crate::models::overview::Overview::load(std::path::Path::new(repositories), &route.repo)
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => {
- return super::error(crate::models::Error::Internal(error.to_string()));
- }
- };
- let content = content(&model, &clone_url);
- super::shared::render(
- context,
- &model.repository,
- "HEAD",
- super::shared::Page::Summary,
- content,
- &method,
- )
-}
-
-fn content(model: &crate::models::overview::Overview, clone_url: &str) -> Markup {
- let repo = super::shared::repository_url(&model.repository.name);
- html! {
- @if model.empty {
- div class="error" { "Repository seems to be empty" }
- }
- table summary="repository info" class="list nowrap" {
- @if !model.empty {
- tr class="nohover" {
- th class="left" { "Branch" }
- th class="left" { "Commit message" }
- th class="left" { "Author" }
- th class="left" colspan="2" { "Age" }
- }
- @for branch in &model.branches {
- @let revision = super::shared::encode_path(&branch.reference);
- tr {
- td { a href=(format!("{repo}/+/{revision}/+/log")) { (&branch.name) } }
- td { a href=(format!("{repo}/+/{revision}")) { (&branch.subject) } }
- td { (&branch.author) }
- td colspan="2" { (super::shared::age(branch.timestamp)) }
- }
- }
- @if !model.tags.is_empty() {
- tr class="nohover" { td colspan="5" { " " } }
- tr class="nohover" {
- th class="left" { "Tag" }
- th class="left" { "Download" }
- th class="left" { "Author" }
- th class="left" colspan="2" { "Age" }
- }
- @for tag in &model.tags {
- @let revision = super::shared::encode_path(&tag.reference);
- tr {
- td { a href=(format!("{repo}/+/{revision}")) { (&tag.name) } }
- td {
- @if tag.downloadable {
- @for (index, format) in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"].iter().enumerate() {
- @if index > 0 { "  " }
- (archive_link(&repo, &revision, format))
- }
- } @else {
- a href=(format!("{repo}/+/object/{}", tag.target)) { (&tag.target) }
- }
- }
- td { (&tag.author) }
- td colspan="2" { (super::shared::age(tag.timestamp)) }
- }
- }
- }
- @if !model.commits.is_empty() {
- tr class="nohover" { td colspan="5" { " " } }
- tr class="nohover" {
- th class="left" { "Age" }
- th class="left" { "Commit message" }
- th class="left" { "Author" }
- th class="left" { "Files" }
- th class="left" { "Lines" }
- }
- @for commit in &model.commits {
- tr {
- td { (super::shared::age(commit.timestamp)) }
- td {
- a href=(format!("{repo}/+/{}", commit.oid)) { (&commit.subject) }
- @if !commit.decorations.is_empty() { span class="decoration" {
- @for decoration in &commit.decorations {
- @if let Some(reference) = &decoration.reference {
- @let revision = super::shared::encode_path(reference);
- a class=(if decoration.tag { "tag-annotated-deco" } else { "branch-deco" }) href=(if decoration.tag { format!("{repo}/+/{revision}") } else { format!("{repo}/+/{revision}/+/log") }) { (&decoration.label) }
- } @else {
- a class="deco" href=(format!("{repo}/+/{}", commit.oid)) { (&decoration.label) }
- }
- }
- } }
- }
- td { (&commit.author) }
- td { (commit.files) }
- td { span class="deletions" { "-" (commit.deletions) } "/" span class="insertions" { "+" (commit.insertions) } }
- }
- }
- }
- }
- tr class="nohover" { td colspan="5" { " " } }
- tr class="nohover" { th class="left" colspan="5" { "Clone" } }
- tr { td colspan="5" { a rel="vcs-git" href=(clone_url) { (clone_url) } } }
- }
- }
-}
-
-fn archive_link(repository: &str, revision: &str, format: &str) -> Markup {
- html! { a href=(format!("{repository}/+/{revision}/+/archive?format={format}")) { (format) } }
-}
-
-fn clone_url(
- context: &super::shared::Context,
- headers: &axum::http::HeaderMap,
- repository: &str,
-) -> String {
- let repository = super::shared::encode_path(repository);
- if !context.clone_prefix.is_empty() {
- return format!(
- "{}/{}.git",
- context.clone_prefix.trim_end_matches('/'),
- repository
- );
- }
- let scheme = headers
- .get("x-forwarded-proto")
- .and_then(|value| value.to_str().ok())
- .filter(|value| matches!(*value, "http" | "https"))
- .unwrap_or("http");
- let host = headers
- .get(axum::http::header::HOST)
- .and_then(|value| value.to_str().ok())
- .unwrap_or("localhost");
- format!("{scheme}://{host}/{repository}.git")
-}
diff --git a/crates/gilti/src/views/patch.rs b/crates/gilti/src/views/patch.rs
deleted file mode 100644
--- a/crates/gilti/src/views/patch.rs
+++ /dev/null
@@ -1,63 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<crate::router::Comparison>,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let name = route.repo.clone();
- let old = route.params.old_rev.clone();
- let new = route.params.new_rev.clone();
- let model = tokio::task::spawn_blocking(move || {
- crate::models::patch::Patch::load(std::path::Path::new(repositories), &name, &old, &new)
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => return super::error(crate::models::Error::Internal(error.to_string())),
- };
- let mut command = tokio::process::Command::new(crate::GIT);
- command
- .arg("--git-dir")
- .arg(&model.repository_path)
- .arg("-c")
- .arg("color.ui=false")
- .arg("format-patch")
- .arg("--stdout")
- .arg("--keep-subject")
- .arg("--no-renames")
- .arg("--signature=Gilti")
- .arg(format!("{}..{}", model.old_oid, model.new_oid))
- .env("GIT_CONFIG_GLOBAL", "/dev/null")
- .env("GIT_CONFIG_NOSYSTEM", "1");
- if let Some(path) = &route.params.path {
- command.arg("--").arg(format!(":(literal){path}"));
- }
- let output = match command.output().await {
- Ok(output) if output.status.success() => output.stdout,
- Ok(output) => {
- eprintln!(
- "gilti: git format-patch failed: {}",
- String::from_utf8_lossy(&output.stderr)
- );
- return super::error(crate::models::Error::Internal(
- "git format-patch failed".to_owned(),
- ));
- }
- Err(error) => return super::error(crate::models::Error::Internal(error.to_string())),
- };
- let filename = format!("{}..{}.patch", model.old_oid, model.new_oid);
- super::bytes_response(
- "text/plain; charset=UTF-8",
- Some(format!("inline; filename=\"{filename}\"")),
- None,
- output,
- &method,
- )
-}
diff --git a/crates/gilti/src/views/refs.rs b/crates/gilti/src/views/refs.rs
deleted file mode 100644
--- a/crates/gilti/src/views/refs.rs
+++ /dev/null
@@ -1,87 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::{Markup, html};
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<()>,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let model = tokio::task::spawn_blocking(move || {
- crate::models::refs::Refs::load(std::path::Path::new(repositories), &route.repo)
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => {
- return super::error(crate::models::Error::Internal(error.to_string()));
- }
- };
- let content = content(&model);
- super::shared::render(
- context,
- &model.repository,
- "HEAD",
- super::shared::Page::Refs,
- content,
- &method,
- )
-}
-
-fn content(model: &crate::models::refs::Refs) -> Markup {
- let repo = super::shared::repository_url(&model.repository.name);
- html! { table class="list nowrap" {
- tr class="nohover" {
- th class="left" { "Branch" }
- th class="left" { "Commit message" }
- th class="left" { "Author" }
- th class="left" colspan="2" { "Age" }
- }
- @for branch in &model.branches {
- @let revision = super::shared::encode_path(&branch.reference);
- tr {
- td { a href=(format!("{repo}/+/{revision}/+/log")) { (&branch.name) } }
- td { a href=(format!("{repo}/+/{revision}")) { (&branch.subject) } }
- td { (&branch.author) }
- td colspan="2" { (super::shared::age(branch.timestamp)) }
- }
- }
- @if !model.tags.is_empty() {
- tr class="nohover" { td colspan="5" { " " } }
- tr class="nohover" {
- th class="left" { "Tag" }
- th class="left" { "Download" }
- th class="left" { "Author" }
- th class="left" colspan="2" { "Age" }
- }
- }
- @for tag in &model.tags {
- @let revision = super::shared::encode_path(&tag.reference);
- tr {
- td { a href=(format!("{repo}/+/{revision}")) { (&tag.name) } }
- td {
- @if tag.downloadable {
- @for (index, format) in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"].iter().enumerate() {
- @if index > 0 { "  " }
- (archive_link(&repo, &revision, format))
- }
- } @else {
- a href=(format!("{repo}/+/object/{}", tag.target)) { (&tag.target) }
- }
- }
- td { (&tag.author) }
- td colspan="2" { (super::shared::age(tag.timestamp)) }
- }
- }
- } }
-}
-
-fn archive_link(repository: &str, revision: &str, format: &str) -> Markup {
- html! { a href=(format!("{repository}/+/{revision}/+/archive?format={format}")) { (format) } }
-}
diff --git a/crates/gilti/src/views/repositories.rs b/crates/gilti/src/views/repositories.rs
deleted file mode 100644
--- a/crates/gilti/src/views/repositories.rs
+++ /dev/null
@@ -1,170 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::{DOCTYPE, html};
-
-pub struct Query {
- search: Option<String>,
- sort: crate::models::repositories::Sort,
- sort_name: &'static str,
- offset: usize,
-}
-
-impl Query {
- pub fn from_request(query: &crate::RequestQuery) -> Self {
- let (sort, sort_name) = match query.value("GILTI_QUERY_SORT") {
- Some("desc") => (crate::models::repositories::Sort::Description, "desc"),
- Some("owner") => (crate::models::repositories::Sort::Owner, "owner"),
- Some("idle") => (crate::models::repositories::Sort::Idle, "idle"),
- _ => (crate::models::repositories::Sort::Name, "name"),
- };
- Self {
- search: query.value("GILTI_QUERY_SEARCH").map(str::to_owned),
- sort,
- sort_name,
- offset: query
- .value("GILTI_QUERY_OFFSET")
- .and_then(|offset| offset.parse().ok())
- .unwrap_or(0),
- }
- }
-}
-
-pub async fn serve(
- context: &super::shared::Context,
- query: Query,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let filter = crate::models::repositories::Filter {
- search: query.search.clone(),
- sort: query.sort,
- offset: query.offset,
- limit: 50,
- };
- let model = tokio::task::spawn_blocking(move || {
- crate::models::repositories::Repositories::load(std::path::Path::new(repositories), filter)
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => {
- return super::error(crate::models::Error::Internal(error.to_string()));
- }
- };
- let document = html! {
- (DOCTYPE)
- html lang="en" {
- head {
- title { (&*context.root_title) }
- meta name="generator" content="Gilti";
- meta name="robots" content="index, nofollow";
- link rel="stylesheet" type="text/css" href="/-/assets/gilti.css";
- script type="text/javascript" src="/-/assets/gilti.js" {}
- link rel="shortcut icon" href="/-/assets/favicon.ico";
- }
- body { div id="gilti" {
- table id="header" {
- tr {
- td class="logo" rowspan="2" { a href="/" { img src="/-/assets/gilti.png" alt="gilti logo"; } }
- td class="main" { (&*context.root_title) }
- }
- tr { td class="sub" { (&*context.root_description) } }
- }
- table class="tabs" { tr {
- td { a href="/" class="active" { "index" } }
- td class="form" { form method="get" action="/" {
- input type="search" name="q" size="10" value=(query.search.as_deref().unwrap_or_default());
- input type="submit" value="search";
- } }
- } }
- div class="content" { table summary="repository list" class="list nowrap" {
- @if !model.rows.is_empty() {
- tr class="nohover" {
- th class="left" { a href=(sort_url("name", query.search.as_deref())) { "Name" } }
- th class="left" { a href=(sort_url("desc", query.search.as_deref())) { "Description" } }
- th class="left" { a href=(sort_url("owner", query.search.as_deref())) { "Owner" } }
- th class="left" { a href=(sort_url("idle", query.search.as_deref())) { "Idle" } }
- th class="left" { "Links" }
- }
- }
- @for repository in &model.rows {
- @let url = super::shared::repository_url(&repository.name);
- tr {
- td class="toplevel-repo" { a href=(&url) { (&repository.name) } }
- td { a href=(&url) { (description(&repository.description)) } }
- td { a href="/?q=" {} }
- td { @if let Some(timestamp) = repository.timestamp { (super::shared::age(timestamp)) } }
- td {
- a class="button" href=(&url) { "summary" }
- @if repository.populated {
- a class="button" href=(format!("{url}/+/HEAD/+/log")) { "log" }
- a class="button" href=(format!("{url}/+/HEAD/+/tree")) { "tree" }
- }
- }
- }
- }
- }
- @if model.offset > 0 || model.has_next {
- ul class="pager" {
- @if model.offset > 0 {
- li { a href=(page_url(model.offset.saturating_sub(50), query.sort_name, query.search.as_deref())) { "[previous]" } }
- }
- @if model.has_next {
- li { a href=(page_url(model.offset + 50, query.sort_name, query.search.as_deref())) { "[next]" } }
- }
- }
- }
- }
- div class="footer" { "generated by Gilti" }
- } }
- }
- }
- .into_string();
- let length = document.len();
- let body = if method == axum::http::Method::HEAD {
- axum::body::Body::empty()
- } else {
- axum::body::Body::from(document)
- };
- axum::response::Response::builder()
- .status(axum::http::StatusCode::OK)
- .header(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8")
- .header(axum::http::header::CONTENT_LENGTH, length)
- .body(body)
- .expect("repository list response is valid")
-}
-
-fn description(description: &str) -> String {
- let mut characters = description.chars();
- let shown = characters.by_ref().take(80).collect::<String>();
- if characters.next().is_some() {
- format!("{shown}...")
- } else {
- shown
- }
-}
-
-fn sort_url(sort: &str, search: Option<&str>) -> String {
- search.map_or_else(
- || format!("/?s={sort}"),
- |search| format!("/?s={sort}&q={}", query_value(search)),
- )
-}
-
-fn page_url(offset: usize, sort: &str, search: Option<&str>) -> String {
- let mut url = format!("/?ofs={offset}&s={sort}");
- if let Some(search) = search {
- url.push_str("&q=");
- url.push_str(&query_value(search));
- }
- url
-}
-
-fn query_value(value: &str) -> String {
- percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string()
-}
diff --git a/crates/gilti/src/views/revision.rs b/crates/gilti/src/views/revision.rs
deleted file mode 100644
--- a/crates/gilti/src/views/revision.rs
+++ /dev/null
@@ -1,118 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::{Markup, html};
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<crate::router::Revision>,
- query: super::diff::Query,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let name = route.repo.clone();
- let revision = route.params.clone();
- let model = tokio::task::spawn_blocking(move || {
- crate::models::commit::Commit::load(
- std::path::Path::new(repositories),
- &name,
- revision,
- query.options(),
- )
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => return super::error(crate::models::Error::Internal(error.to_string())),
- };
- let content = content(&model, query);
- super::shared::render_titled(
- context,
- &model.repository,
- &model.revision,
- super::shared::Page::Revision,
- Some(&model.subject),
- content,
- &method,
- )
-}
-
-fn content(model: &crate::models::commit::Commit, query: super::diff::Query) -> Markup {
- let repo = super::shared::repository_url(&model.repository.name);
- let revision = super::shared::encode_path(&model.revision);
- html! {
- (super::diff::content_controls(query))
- table summary="commit info" class="commit-info" {
- tr { th { "author" } td { (&model.author.name) " <" (&model.author.email) ">" }
- td class="right" { (timestamp(&model.author)) } }
- tr { th { "committer" } td { (&model.committer.name) " <" (&model.committer.email) ">" }
- td class="right" { (timestamp(&model.committer)) } }
- tr { th { "commit" } td colspan="2" class="oid" {
- a href=(format!("{repo}/+/{}", model.oid)) { (&model.oid) }
- @let patch_old = model.parents.first().map_or("HEAD", String::as_str);
- " (" a href=(format!("{repo}/+/patch/{patch_old}..{}", model.oid)) { "patch" } ")"
- } }
- tr { th { "tree" } td colspan="2" class="oid" {
- a href=(format!("{repo}/+/{revision}/+/tree")) { (&model.tree) }
- } }
- @for parent in &model.parents {
- tr { th { "parent" } td colspan="2" class="oid" {
- a href=(format!("{repo}/+/{parent}")) { (parent) }
- " (" a href=(format!("{repo}/+/diff/{parent}..{}", model.oid)) { "diff" } ")"
- } }
- }
- tr { th { "download" } td colspan="2" class="oid" {
- @for format in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"] {
- a href=(format!("{repo}/+/{revision}/+/archive?format={format}")) { (format) } br;
- }
- } }
- }
- div class="commit-subject" {
- (&model.subject)
- @for decoration in &model.decorations {
- " ("
- @if let Some(reference) = &decoration.reference {
- @let reference = super::shared::encode_path(reference);
- a href=(format!("{repo}/+/{reference}")) { (&decoration.label) }
- } @else { (&decoration.label) }
- ")"
- }
- }
- div class="commit-msg" { (&model.message) }
- @if let Some(notes) = &model.notes {
- div class="notes-header" { "Notes" }
- div class="notes" { (notes) }
- div class="notes-footer" {}
- }
- @if let Some(diff) = &model.diff {
- (super::diff::content(diff, query, None, false))
- }
- }
-}
-
-fn timestamp(identity: &crate::models::commit::Identity) -> String {
- let adjusted = identity.timestamp + i64::from(identity.offset_minutes) * 60;
- let value = crate::models::time::utc(adjusted)
- .unwrap_or_else(|| crate::models::time::utc(0).expect("the Unix epoch is representable"));
- let sign = if identity.offset_minutes < 0 {
- '-'
- } else {
- '+'
- };
- let offset = identity.offset_minutes.unsigned_abs();
- format!(
- "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {sign}{:02}{:02}",
- value.tm_year + 1900,
- value.tm_mon + 1,
- value.tm_mday,
- value.tm_hour,
- value.tm_min,
- value.tm_sec,
- offset / 60,
- offset % 60
- )
-}
diff --git a/crates/gilti/src/views/shared.rs b/crates/gilti/src/views/shared.rs
deleted file mode 100644
--- a/crates/gilti/src/views/shared.rs
+++ /dev/null
@@ -1,148 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::{DOCTYPE, Markup, html};
-
-#[derive(Clone)]
-pub struct Context {
- pub repositories: &'static str,
- pub root_title: std::sync::Arc<str>,
- pub root_description: std::sync::Arc<str>,
- pub clone_prefix: std::sync::Arc<str>,
-}
-
-#[derive(Clone, Copy, Eq, PartialEq)]
-pub enum Page {
- About,
- Summary,
- Refs,
- Log,
- Tree,
- Revision,
- Diff,
- Stats,
-}
-
-pub fn render(
- context: &Context,
- repository: &crate::models::repository::Info,
- revision: &str,
- active: Page,
- content: Markup,
- method: &axum::http::Method,
-) -> axum::response::Response {
- render_titled(context, repository, revision, active, None, content, method)
-}
-
-pub fn render_titled(
- context: &Context,
- repository: &crate::models::repository::Info,
- revision: &str,
- active: Page,
- page_title: Option<&str>,
- content: Markup,
- method: &axum::http::Method,
-) -> axum::response::Response {
- let repo = repository_url(&repository.name);
- let rev = encode_path(revision);
- let title = page_title.map_or_else(
- || format!("{} - {}", repository.name, repository.description),
- |page_title| {
- format!(
- "{page_title} - {} - {}",
- repository.name, repository.description
- )
- },
- );
- let document = html! {
- (DOCTYPE)
- html lang="en" {
- head {
- title { (title) }
- meta name="generator" content="Gilti";
- meta name="robots" content="index, nofollow";
- link rel="stylesheet" type="text/css" href="/-/assets/gilti.css";
- script type="text/javascript" src="/-/assets/gilti.js" {}
- link rel="shortcut icon" href="/-/assets/favicon.ico";
- }
- body { div id="gilti" {
- table id="header" {
- tr {
- td class="logo" rowspan="2" { a href="/" { img src="/-/assets/gilti.png" alt="gilti logo"; } }
- td class="main" { (&*context.root_title) " : " a href=(&repo) { (&repository.name) } }
- }
- tr { td class="sub" { (&repository.description) } }
- }
- table class="tabs" { tr { td {
- @if repository.has_readme {
- (tab(&format!("{repo}/+/about"), "about", active == Page::About))
- }
- (tab(&repo, "summary", active == Page::Summary))
- (tab(&format!("{repo}/+/refs"), "refs", active == Page::Refs))
- (tab(&format!("{repo}/+/{rev}/+/log"), "log", active == Page::Log))
- (tab(&format!("{repo}/+/{rev}/+/tree"), "tree", active == Page::Tree))
- (tab(&format!("{repo}/+/{rev}"), "commit", active == Page::Revision))
- (tab(&format!("{repo}/+/diff/HEAD..{rev}"), "diff", active == Page::Diff))
- (tab(&format!("{repo}/+/stats"), "stats", active == Page::Stats))
- } } }
- div class="content" { (content) }
- div class="footer" { "generated by Gilti" }
- } }
- }
- }
- .into_string();
- let length = document.len();
- let body = if method == axum::http::Method::HEAD {
- axum::body::Body::empty()
- } else {
- axum::body::Body::from(document)
- };
- axum::response::Response::builder()
- .status(axum::http::StatusCode::OK)
- .header(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8")
- .header(axum::http::header::CONTENT_LENGTH, length)
- .body(body)
- .expect("HTML response is valid")
-}
-
-fn tab(url: &str, label: &str, active: bool) -> Markup {
- html! { a href=(url) class=[active.then_some("active")] { (label) } }
-}
-
-pub fn repository_url(repository: &str) -> String {
- format!("/{}", encode_path(repository))
-}
-
-pub fn encode_path(value: &str) -> String {
- let mut encoded = String::new();
- for byte in value.bytes() {
- if byte.is_ascii_alphanumeric() || byte == b'/' || byte == b'_' {
- encoded.push(char::from(byte));
- } else {
- use std::fmt::Write;
- write!(encoded, "%{byte:02x}").expect("writing to String cannot fail");
- }
- }
- encoded
-}
-
-pub fn age(timestamp: i64) -> String {
- let now = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map_or(0, |duration| duration.as_secs() as i64);
- let seconds = now.saturating_sub(timestamp).max(0);
- let (amount, unit) = if seconds < 120 * 60 {
- (seconds / 60, "min.")
- } else if seconds < 48 * 60 * 60 {
- (seconds / (60 * 60), "hours")
- } else if seconds < 14 * 24 * 60 * 60 {
- (seconds / (24 * 60 * 60), "days")
- } else if seconds < 60 * 24 * 60 * 60 {
- (seconds / (7 * 24 * 60 * 60), "weeks")
- } else if seconds < 730 * 24 * 60 * 60 {
- (seconds / (30 * 24 * 60 * 60), "months")
- } else {
- (seconds / (365 * 24 * 60 * 60), "years")
- };
- format!("{amount} {unit} ago")
-}
diff --git a/crates/gilti/src/views/stats.rs b/crates/gilti/src/views/stats.rs
deleted file mode 100644
--- a/crates/gilti/src/views/stats.rs
+++ /dev/null
@@ -1,122 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::{Markup, html};
-
-pub struct Query {
- pub period: crate::models::stats::Period,
- pub code: &'static str,
- pub top: Option<usize>,
-}
-
-pub enum QueryError {
- BadRequest,
- NotFound,
-}
-
-impl Query {
- pub fn from_request(query: &crate::RequestQuery) -> Result<Self, QueryError> {
- let (period, code) = match query.value("GILTI_QUERY_PERIOD") {
- None | Some("w" | "week") => (crate::models::stats::Period::Week, "w"),
- Some("m" | "month") => (crate::models::stats::Period::Month, "m"),
- Some("q" | "quarter") => (crate::models::stats::Period::Quarter, "q"),
- Some("y" | "year") => (crate::models::stats::Period::Year, "y"),
- Some(_) => return Err(QueryError::NotFound),
- };
- let top = match query.value("GILTI_QUERY_OFFSET") {
- None | Some("0") => Some(10),
- Some("-1") => None,
- Some(value) => Some(
- value
- .parse::<usize>()
- .ok()
- .filter(|value| *value > 0)
- .ok_or(QueryError::BadRequest)?,
- ),
- };
- Ok(Self { period, code, top })
- }
-}
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<()>,
- query: Query,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let name = route.repo;
- let model = tokio::task::spawn_blocking(move || {
- crate::models::stats::Stats::load(std::path::Path::new(repositories), &name, query.period)
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => return super::error(crate::models::Error::Internal(error.to_string())),
- };
- let content = content(&model, &query);
- super::shared::render(
- context,
- &model.repository,
- "HEAD",
- super::shared::Page::Stats,
- content,
- &method,
- )
-}
-
-fn content(model: &crate::models::stats::Stats, query: &Query) -> Markup {
- let shown = query
- .top
- .unwrap_or(model.authors.len())
- .min(model.authors.len());
- html! {
- div class="gilti-panel" {
- b { "stat options" }
- form method="get" { table {
- tr { td colspan="2" {} }
- tr { td class="label" { "Period:" } td class="ctrl" {
- select name="period" onchange="this.form.submit();" {
- @for (code, name) in [("w","week"),("m","month"),("q","quarter"),("y","year")] {
- option value=(code) selected[query.code == code] { (name) }
- }
- }
- } }
- tr { td class="label" { "Authors:" } td class="ctrl" {
- select name="ofs" onchange="this.form.submit();" {
- @for value in [10_usize,25,50,100] {
- option value=(value) selected[query.top == Some(value)] { (value) }
- }
- option value="-1" selected[query.top.is_none()] { "all" }
- }
- } }
- tr { td {} td class="ctrl" { noscript { input type="submit" value="Reload"; } } }
- } }
- }
- h2 { "Commits per author per " (query.period.name()) }
- table class="stats" {
- tr { th { "Author" } @for label in &model.labels { th { (label) } } th { "Total" } }
- @for author in &model.authors[..shown] {
- tr { td class="left" { (&author.name) }
- @for count in &author.counts { td { (count) } }
- td class="sum" { (author.total) }
- }
- }
- @if shown < model.authors.len() {
- @let others = &model.authors[shown..];
- tr { td class="left" { "Others (" (others.len()) ")" }
- @for index in 0..4 { td { (others.iter().map(|author| author.counts[index]).sum::<usize>()) } }
- td class="sum" { (others.iter().map(|author| author.total).sum::<usize>()) }
- }
- }
- tr { td class="total" { "Total" }
- @for total in &model.totals { td class="sum" { (total) } }
- td class="sum" { (model.totals.iter().sum::<usize>()) }
- }
- }
- }
-}
diff --git a/crates/gilti/src/views/tag.rs b/crates/gilti/src/views/tag.rs
deleted file mode 100644
--- a/crates/gilti/src/views/tag.rs
+++ /dev/null
@@ -1,76 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::{Markup, html};
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<crate::router::Revision>,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let crate::router::Revision::Ref(reference) = route.params else {
- return super::error(crate::models::Error::NotFound);
- };
- let repositories = context.repositories;
- let model = tokio::task::spawn_blocking(move || {
- crate::models::tag::Tag::load(std::path::Path::new(repositories), &route.repo, reference)
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => {
- return super::error(crate::models::Error::Internal(error.to_string()));
- }
- };
- let content = content(&model);
- super::shared::render(
- context,
- &model.repository,
- &model.reference,
- super::shared::Page::Revision,
- content,
- &method,
- )
-}
-
-fn content(model: &crate::models::tag::Tag) -> Markup {
- let repo = super::shared::repository_url(&model.repository.name);
- let revision = super::shared::encode_path(&model.reference);
- html! {
- table class="commit-info" {
- tr { td { "tag name" } td { (&model.name) @if model.annotated { " (" (&model.oid) ")" } } }
- @if let Some(timestamp) = model.timestamp {
- tr { td { "tag date" } td { (super::shared::age(timestamp)) } }
- }
- @if model.annotated {
- tr { td { "tagged by" } td { (&model.tagger) " <" (&model.tagger_email) ">" } }
- }
- tr { td { "tagged object" } td class="oid" {
- @for (index, target) in model.targets.iter().enumerate() {
- @if index > 0 { " → " }
- @if target.commit {
- a href=(format!("{repo}/+/{}", target.oid)) { (&target.oid) }
- } @else {
- a href=(format!("{repo}/+/object/{}", target.oid)) { (&target.oid) }
- }
- }
- } }
- @if model.downloadable {
- tr { td { "download" } td class="oid" {
- @for format in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"] {
- a href=(format!("{repo}/+/{revision}/+/archive?format={format}")) { (format) } br;
- }
- } }
- }
- }
- @if model.annotated {
- @let mut lines = model.message.splitn(2, '\n');
- div class="commit-subject" { (lines.next().unwrap_or_default()) }
- div class="commit-msg" { (lines.next().unwrap_or_default()) }
- }
- }
-}
diff --git a/crates/gilti/src/views/tree.rs b/crates/gilti/src/views/tree.rs
deleted file mode 100644
--- a/crates/gilti/src/views/tree.rs
+++ /dev/null
@@ -1,293 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-use maud::{DOCTYPE, Markup, html};
-
-pub async fn serve(
- context: &super::shared::Context,
- route: crate::router::RepoRoute<crate::router::RevisionPath>,
- format: Option<&str>,
- method: axum::http::Method,
-) -> axum::response::Response {
- if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
- return super::method_not_allowed();
- }
- let repositories = context.repositories;
- let model = tokio::task::spawn_blocking(move || {
- crate::models::tree::Tree::load(
- std::path::Path::new(repositories),
- &route.repo,
- route.params.rev,
- route.params.path,
- )
- })
- .await;
- let model = match model {
- Ok(Ok(model)) => model,
- Ok(Err(error)) => return super::error(error),
- Err(error) => {
- return super::error(crate::models::Error::Internal(error.to_string()));
- }
- };
- if format == Some("raw") {
- return raw(model, &method);
- }
- let content = html_content(&model);
- super::shared::render(
- context,
- &model.repository,
- &model.revision,
- super::shared::Page::Tree,
- content,
- &method,
- )
-}
-
-fn html_content(model: &crate::models::tree::Tree) -> Markup {
- let repo = super::shared::repository_url(&model.repository.name);
- let revision = super::shared::encode_path(&model.revision);
- let prefix = format!("{repo}/+/{revision}/+/tree");
- html! {
- @if let Some(path) = &model.path {
- div class="path" { "path: " (breadcrumbs(&prefix, path)) }
- }
- @match &model.content {
- crate::models::tree::Content::Directory { entries, .. } => {
- table summary="tree listing" class="list" {
- tr class="nohover" {
- th class="left" { "Mode" }
- th class="left" { "Name" }
- th class="right" { "Size" }
- th {}
- }
- @for entry in entries {
- @let path = super::shared::encode_path(&entry.path);
- tr {
- td class="ls-mode" { (filemode(entry.mode)) }
- td {
- @match entry.kind {
- crate::models::tree::Kind::Submodule => {
- span class="ls-mod" { (&entry.name) " @ " (&entry.oid) }
- }
- _ => {
- a href=(format!("{prefix}/{path}")) class=(entry_class(entry)) { (&entry.name) }
- @if let Some(target) = &entry.symlink_target {
- " -> " a href=(format!("{prefix}/{}", super::shared::encode_path(&symlink_path(&entry.path, target)))) class="ls-blob" { (target) }
- }
- }
- }
- }
- td class="ls-size" { (entry.size) }
- td {
- a class="button" href=(format!("{repo}/+/{revision}/+/log/{path}")) { "log" }
- a class="button" href=(format!("{repo}/+/stats")) { "stats" }
- @if entry.kind != crate::models::tree::Kind::Submodule {
- a class="button" href=(format!("{prefix}/{path}?format=raw")) { "plain" }
- }
- @if entry.kind == crate::models::tree::Kind::Blob && entry.symlink_target.is_none() {
- a class="button" href=(format!("{repo}/+/{revision}/+/blame/{path}")) { "blame" }
- }
- }
- }
- }
- }
- }
- crate::models::tree::Content::Blob { oid, bytes, binary } => {
- "blob: " (oid) " ("
- a href=(format!("{prefix}/{}?format=raw", super::shared::encode_path(model.path.as_deref().unwrap_or_default()))) { "plain" }
- @if !binary { ") (" a href=(format!("{repo}/+/{revision}/+/blame/{}", super::shared::encode_path(model.path.as_deref().unwrap_or_default()))) { "blame" } }
- ")"
- @if *binary { (binary_blob(bytes)) } @else { (text_blob(bytes)) }
- }
- }
- }
-}
-
-pub(super) fn filemode(mode: u32) -> String {
- let mut value = String::with_capacity(10);
- value.push(match mode {
- 0o040000 => 'd',
- 0o120000 => 'l',
- 0o160000 => 'm',
- _ => '-',
- });
- for bit in [
- 0o400, 0o200, 0o100, 0o040, 0o020, 0o010, 0o004, 0o002, 0o001,
- ] {
- value.push(if mode & bit == 0 {
- '-'
- } else {
- match bit {
- 0o400 | 0o040 | 0o004 => 'r',
- 0o200 | 0o020 | 0o002 => 'w',
- _ => 'x',
- }
- });
- }
- value
-}
-
-fn entry_class(entry: &crate::models::tree::Entry) -> String {
- if entry.kind == crate::models::tree::Kind::Tree {
- return "ls-dir".to_owned();
- }
- let extension = entry.name.rsplit_once('.').map(|(_, extension)| extension);
- extension.map_or_else(
- || "ls-blob".to_owned(),
- |extension| format!("ls-blob {extension}"),
- )
-}
-
-fn symlink_path(path: &str, target: &str) -> String {
- let mut parts = path.rsplit_once('/').map_or_else(Vec::new, |(parent, _)| {
- parent.split('/').collect::<Vec<_>>()
- });
- for part in target.split('/') {
- match part {
- "" | "." => {}
- ".." => {
- parts.pop();
- }
- part => parts.push(part),
- }
- }
- parts.join("/")
-}
-
-fn breadcrumbs(prefix: &str, path: &str) -> Markup {
- let parts = path.split('/').collect::<Vec<_>>();
- html! {
- a href=(prefix) { "root" }
- @for (index, part) in parts.iter().enumerate() {
- "/"
- @let path = parts[..=index].join("/");
- a href=(format!("{prefix}/{}", super::shared::encode_path(&path))) { (part) }
- }
- }
-}
-
-fn text_blob(bytes: &[u8]) -> Markup {
- let text = String::from_utf8_lossy(bytes);
- let lines = if bytes.is_empty() {
- 0
- } else {
- bytes.iter().filter(|byte| **byte == b'\n').count()
- + usize::from(bytes.last() != Some(&b'\n'))
- };
- html! { table summary="blob content" class="blob" { tr {
- td class="linenumbers" { pre {
- @for line in 1..=lines { a id=(format!("n{line}")) href=(format!("#n{line}")) { (line) } "\n" }
- } }
- td class="lines" { pre { code { (text) } } }
- } } }
-}
-
-fn binary_blob(bytes: &[u8]) -> Markup {
- html! { table summary="blob content" class="bin-blob" {
- tr { th { "ofs" } th { "hex dump" } th { "ascii" } }
- @for (row, chunk) in bytes.chunks(32).enumerate() {
- tr {
- td class="right" { (format!("{:04x}", row * 32)) }
- td class="hex" { (chunk.iter().map(|byte| format!("{byte:02x}")).collect::<Vec<_>>().join(" ")) }
- td class="hex" { (chunk.iter().map(|byte| if byte.is_ascii_graphic() { char::from(*byte) } else { '.' }).collect::<String>()) }
- }
- }
- } }
-}
-
-fn raw(model: crate::models::tree::Tree, method: &axum::http::Method) -> axum::response::Response {
- let filename = model.path.as_deref().map(content_disposition);
- match model.content {
- crate::models::tree::Content::Blob { oid, bytes, binary } => {
- let length = bytes.len();
- let body = if method == axum::http::Method::HEAD {
- axum::body::Body::empty()
- } else {
- axum::body::Body::from(bytes)
- };
- axum::response::Response::builder()
- .status(axum::http::StatusCode::OK)
- .header(
- axum::http::header::CONTENT_TYPE,
- if binary {
- "application/octet-stream"
- } else {
- "text/plain; charset=UTF-8"
- },
- )
- .header(axum::http::header::CONTENT_LENGTH, length)
- .header(axum::http::header::ETAG, format!("\"{oid}\""))
- .header(
- axum::http::header::CONTENT_DISPOSITION,
- filename.as_deref().unwrap_or("inline"),
- )
- .body(body)
- .expect("raw blob response is valid")
- }
- crate::models::tree::Content::Directory { oid, entries } => {
- let repo = super::shared::repository_url(&model.repository.name);
- let revision = super::shared::encode_path(&model.revision);
- let current = model.path.as_deref().unwrap_or_default();
- let title = if current.is_empty() {
- "/".to_owned()
- } else {
- format!("/{current}/")
- };
- let document = html! {
- (DOCTYPE)
- html { head { title { (&title) } } body {
- h2 { (&title) }
- ul {
- @if let Some((parent, _)) = current.rsplit_once('/') {
- li { a href=(format!("{repo}/+/{revision}/+/tree/{}?format=raw", super::shared::encode_path(parent))) { "../" } }
- } @else if !current.is_empty() {
- li { a href=(format!("{repo}/+/{revision}/+/tree?format=raw")) { "../" } }
- }
- @for entry in entries {
- @let path = super::shared::encode_path(&entry.path);
- li {
- @if entry.kind == crate::models::tree::Kind::Submodule {
- (&entry.name) " @ " (&entry.oid)
- } @else {
- a href=(format!("{repo}/+/{revision}/+/tree/{path}?format=raw")) { (&entry.name) @if entry.kind == crate::models::tree::Kind::Tree { "/" } }
- }
- }
- }
- }
- } }
- }
- .into_string();
- let length = document.len();
- let body = if method == axum::http::Method::HEAD {
- axum::body::Body::empty()
- } else {
- axum::body::Body::from(document)
- };
- axum::response::Response::builder()
- .status(axum::http::StatusCode::OK)
- .header(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8")
- .header(axum::http::header::CONTENT_LENGTH, length)
- .header(axum::http::header::ETAG, format!("\"{oid}\""))
- .body(body)
- .expect("raw directory response is valid")
- }
- }
-}
-
-fn content_disposition(path: &str) -> String {
- let name = path.rsplit('/').next().unwrap_or(path);
- let fallback = name
- .chars()
- .map(|character| {
- if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
- character
- } else {
- '_'
- }
- })
- .collect::<String>();
- format!(
- "inline; filename=\"{fallback}\"; filename*=UTF-8''{}",
- super::shared::encode_path(name)
- )
-}
diff --git a/git b/git
new file mode 160000
--- /dev/null
+++ b/git
@@ -0,0 +1 @@
+Subproject commit 94f057755b7941b321fd11fec1b2e3ca5313a4e0
diff --git a/mise.toml b/mise.toml
index 37ffe1c..6101ba9 100644
--- a/mise.toml
+++ b/mise.toml
@@ -8,11 +8,15 @@ experimental = true
[tools]
helm = "4.1.1"
-rust = { version = "1.97.1", profile = "minimal", components = ["clippy", "llvm-tools-preview", "rustfmt"] }
+rust = { version = "1.97.1", profile = "minimal", components = [
+ "clippy",
+ "llvm-tools-preview",
+ "rustfmt",
+] }
shellcheck = "0.11.0"
[task_config]
dir = "{{cwd}}"
includes = [
- "git::https://git.dimidiumlabs.io/platform.git//tasks?ref=8bc35fe8be889c50db2d1fb4425cc1b4097dc6b8",
+ "git::https://github.com/dimidiumlabs/platform.git//tasks?ref=8bc35fe8be889c50db2d1fb4425cc1b4097dc6b8",
]
diff --git a/scripts/build-artifacts.sh b/scripts/build-artifacts.sh
index 32016ff..741b40e 100755
--- a/scripts/build-artifacts.sh
+++ b/scripts/build-artifacts.sh
@@ -19,21 +19,39 @@ ldd --version 2>&1 | grep -q musl || {
echo 'container artifacts must be built in a musl environment' >&2
exit 1
}
+[ -f git/Makefile ] || {
+ echo 'Git submodule is not initialized; run git submodule update --init' >&2
+ exit 1
+}
workspace=$PWD
build=$(mktemp -d)
trap 'rm -rf "$build"' EXIT INT TERM
+cp -R cgit "$build/cgit"
+cp -R git "$build/cgit/git"
+make -C "$build/cgit/git" -f ../Makefile -j"$(nproc)" ../cgit \
+ NO_CURL=1 \
+ NO_GETTEXT=1 \
+ NO_OPENSSL=1 \
+ NO_REGEX=NeedsStartEnd
+
CARGO_TARGET_DIR="$build/target" cargo build \
--manifest-path "$workspace/Cargo.toml" \
--locked \
--release \
--workspace
+
output="$workspace/.container/binary-$arch"
rm -rf "$output"
install -Dm0755 "$build/target/release/gilti" "$output/gilti"
install -Dm0755 "$build/target/release/gilti-ssh" "$output/gilti-ssh"
-install -Dm0644 crates/gilti/assets/gilti.css "$output/gilti.css"
-install -Dm0644 crates/gilti/assets/gilti.js "$output/gilti.js"
-install -Dm0644 crates/gilti/assets/gilti.png "$output/gilti.png"
-install -Dm0644 crates/gilti/assets/favicon.ico "$output/favicon.ico"
+install -Dm0755 "$build/cgit/cgit" "$output/gilti-cgit"
+strip "$output/gilti-cgit"
+
+install -Dm0644 cgit/cgit.css "$output/cgit.css"
+install -Dm0644 cgit/cgit.js "$output/cgit.js"
+install -Dm0644 cgit/cgit.png "$output/cgit.png"
+install -Dm0644 cgit/favicon.ico "$output/favicon.ico"
+install -Dm0644 cgit/COPYING "$output/COPYING.cgit.txt"
+install -Dm0644 git/COPYING "$output/COPYING.git.txt"
diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh
index 6da7570..3eaa8f9 100755
--- a/scripts/entrypoint.sh
+++ b/scripts/entrypoint.sh
@@ -8,6 +8,7 @@ umask 077
state=/var/lib/gilti
run_dir=/run/gilti
+http_run_dir=$run_dir/http
ssh_run_dir=$run_dir/ssh
git_home=$state/git
@@ -29,8 +30,10 @@ prepare_runtime() {
install -d -m 0750 -o git -g git "$git_home" "$repositories"
install -d -m 0700 -o root -g root "$host_key_dir"
install -d -m 0755 -o root -g root "$run_dir"
+ install -d -m 0750 -o git -g git "$http_run_dir"
install -d -m 0750 -o root -g git "$ssh_run_dir"
- rm -f "$ssh_run_dir/sshd.pid" "$authorized_keys" "$authorized_keys".*
+ rm -f "$ssh_run_dir/sshd.pid" "$http_run_dir"/cgitrc.* \
+ "$authorized_keys" "$authorized_keys".*
}
prepare_authorized_keys() {
diff --git a/tests/chart.sh b/tests/chart.sh
index 42f6910..f0471d4 100755
--- a/tests/chart.sh
+++ b/tests/chart.sh
@@ -28,15 +28,20 @@ grep -q '^apiVersion: gateway.networking.k8s.io/v1$' "$rendered"
grep -q 'helm.sh/resource-policy: keep' "$rendered"
grep -q 'ssh-ed25519 AAAAcharttest gilti' "$rendered"
grep -q 'mountPath: /etc/gilti/authorized_keys' "$rendered"
+grep -q '^ cache=5$' "$rendered"
+if grep -q '/var/cache/cgit' "$rendered"; then
+ echo 'chart still provisions the removed cgit disk cache' >&2
+ exit 1
+fi
cat >"$injected" <<'EOF'
-web:
+cgit:
rootTitle: |-
Gilti
scan-path=/var/lib/gilti/git/repositories
EOF
if helm template gilti "$chart" -f "$injected" >"$invalid" 2>&1; then
- echo 'chart accepted multiline web configuration' >&2
+ echo 'chart accepted multiline cgit configuration' >&2
exit 1
fi
@@ -44,3 +49,8 @@ if helm template gilti "$chart" --set replicaCount=2 >"$invalid" 2>&1; then
echo 'chart accepted unsupported replicaCount=2' >&2
exit 1
fi
+
+if helm template gilti "$chart" --set cgit.cache=3601 >"$invalid" 2>&1; then
+ echo 'chart accepted an excessive CGI cache lifetime' >&2
+ exit 1
+fi
diff --git a/tests/smoke.sh b/tests/smoke.sh
index 25f6304..f9ed0c7 100755
--- a/tests/smoke.sh
+++ b/tests/smoke.sh
@@ -29,10 +29,6 @@ ssh-keygen -q -t ed25519 -N '' -f "$work/admin-2"
ssh-keygen -q -t ed25519 -N '' -f "$work/stranger"
cat "$work/admin.pub" "$work/admin-2.pub" >"$authorized_keys"
"$engine" volume create "$volume" >/dev/null
-if "$engine" run --rm --entrypoint /bin/sh "$image" -c 'test -e /usr/local/bin/gilti-cgit' >/dev/null 2>&1; then
- echo 'removed legacy browser binary is installed' >&2
- exit 1
-fi
printf '%s\n' 'not an SSH key' >"$work/bad-authorized-keys"
if "$engine" run --rm \
@@ -60,7 +56,7 @@ start() {
"$image" >/dev/null
i=0
- until curl -fsS "http://127.0.0.1:$http_port/-/health" >/dev/null 2>&1; do
+ until curl -fsS "http://127.0.0.1:$http_port/healthz" >/dev/null 2>&1; do
i=$((i + 1))
if [ "$i" -ge 60 ]; then
"$engine" logs "$name" >&2
@@ -77,6 +73,23 @@ start() {
}
start
+cgit_path=$("$engine" exec "$name" sh -c 'command -v gilti-cgit')
+[ "$cgit_path" = /usr/local/bin/gilti-cgit ] || {
+ echo "unexpected cgit binary path: $cgit_path" >&2
+ exit 1
+}
+if "$engine" exec "$name" test -e /usr/share/webapps/cgit/cgit.cgi; then
+ echo 'legacy cgit.cgi is installed' >&2
+ exit 1
+fi
+if "$engine" exec "$name" test -e /var/cache/cgit; then
+ echo 'legacy cgit disk-cache directory exists' >&2
+ exit 1
+fi
+if "$engine" exec "$name" sh -c 'grep -q "^cache=" /run/gilti/http/cgitrc.*'; then
+ echo 'Gilti cache parameter leaked into the cgit configuration' >&2
+ exit 1
+fi
sshd_config=$("$engine" exec "$name" /usr/sbin/sshd -T -f /etc/ssh/sshd_config \
-C user=git,host=localhost,addr=127.0.0.1)
for expected in \
@@ -96,30 +109,30 @@ for expected in \
exit 1
}
done
-[ "$(curl -fsS "http://127.0.0.1:$http_port/-/health")" = '{"status":"ok"}' ] || {
+[ "$(curl -fsS "http://127.0.0.1:$http_port/healthz")" = ok ] || {
echo 'unexpected health response' >&2
exit 1
}
status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:$http_port/")
-[ "$status" = 405 ] || {
- echo "POST to repository browser returned HTTP $status instead of 405" >&2
+[ "$status" = 403 ] || {
+ echo "POST to cgit returned HTTP $status instead of 403" >&2
exit 1
}
-curl -fsS "http://127.0.0.1:$http_port/-/assets/gilti.css" | grep -q 'div#gilti'
-content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/gilti.css" |
+curl -fsS "http://127.0.0.1:$http_port/cgit.css" | grep -q 'cgit'
+content_type=$(curl -fsSI "http://127.0.0.1:$http_port/cgit.css" |
awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }')
[ "$content_type" = text/css ] || {
- echo "unexpected gilti.css content type: $content_type" >&2
+ echo "unexpected cgit.css content type: $content_type" >&2
exit 1
}
-curl -fsS "http://127.0.0.1:$http_port/-/assets/gilti.js" | grep -q 'function'
-content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/gilti.js" |
+curl -fsS "http://127.0.0.1:$http_port/cgit.js" | grep -q 'function'
+content_type=$(curl -fsSI "http://127.0.0.1:$http_port/cgit.js" |
awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }')
[ "$content_type" = text/javascript ] || {
- echo "unexpected gilti.js content type: $content_type" >&2
+ echo "unexpected cgit.js content type: $content_type" >&2
exit 1
}
-curl -fsSI "http://127.0.0.1:$http_port/-/health" >/dev/null
+curl -fsSI "http://127.0.0.1:$http_port/healthz" >/dev/null
# shellcheck disable=SC2016 # Expanded by the shell inside the container.
httpd_uid=$("$engine" exec "$name" sh -c '
@@ -147,16 +160,16 @@ keys_mode=$("$engine" exec "$name" stat -c '%u:%g:%a' /run/gilti/ssh/authorized_
}
if "$engine" exec --user 10000:10000 "$name" sh -c \
'printf x >>/run/gilti/ssh/authorized_keys' 2>/dev/null; then
- echo 'Git service user can modify authorized_keys' >&2
+ echo 'Git/cgit user can modify authorized_keys' >&2
exit 1
fi
if "$engine" exec --user 10000:10000 "$name" rm /run/gilti/ssh/authorized_keys \
2>/dev/null; then
- echo 'Git service user can remove authorized_keys' >&2
+ echo 'Git/cgit user can remove authorized_keys' >&2
exit 1
fi
if "$engine" exec --user 10000:10000 "$name" test -r /var/lib/gilti/ssh/ssh_host_ed25519_key; then
- echo 'Git service user can read the SSH host private key' >&2
+ echo 'Git/cgit user can read the SSH host private key' >&2
exit 1
fi
@@ -232,64 +245,17 @@ until curl -fsS "http://127.0.0.1:$http_port/" | grep -q 'testing'; do
[ "$i" -lt 30 ] || { "$engine" logs "$name" >&2; exit 1; }
sleep 1
done
-curl -fsS "http://127.0.0.1:$http_port/testing" | grep -q 'Initial commit'
-curl -fsS "http://127.0.0.1:$http_port/testing/+/HEAD/+/tree/README%2emd" | grep -q 'Testing'
-old_commit=$(git -C "$work/testing-clone" rev-parse HEAD^)
-new_commit=$(git -C "$work/testing-clone" rev-parse HEAD)
-curl -fsS "http://127.0.0.1:$http_port/testing/+/$new_commit" | grep -q 'Push with second key'
-log_url="http://127.0.0.1:$http_port/testing/+/refs/heads/main/+/log"
-curl -fsS "$log_url" -o "$work/log.html"
-grep -q 'id="gilti"' "$work/log.html"
-grep -q 'Push with second key' "$work/log.html"
-log_length=$(wc -c <"$work/log.html" | tr -d ' ')
-head_length=$(curl -fsSI "$log_url" |
- awk -F ': ' 'tolower($1) == "content-length" { gsub("\\r", "", $2); print $2 }')
-[ "$head_length" = "$log_length" ] || {
- echo "Log HEAD length $head_length differs from GET length $log_length" >&2
- exit 1
-}
-feed_url="http://127.0.0.1:$http_port/testing/+/refs/heads/main/+/feed/atom"
-curl -fsS "$feed_url" -o "$work/feed.xml"
-grep -q '<feed xmlns=' "$work/feed.xml"
-grep -q '<title>Push with second key</title>' "$work/feed.xml"
-grep -q "<id>urn:sha1:$new_commit</id>" "$work/feed.xml"
-feed_length=$(wc -c <"$work/feed.xml" | tr -d ' ')
-head_length=$(curl -fsSI "$feed_url" |
- awk -F ': ' 'tolower($1) == "content-length" { gsub("\\r", "", $2); print $2 }')
-[ "$head_length" = "$feed_length" ] || {
- echo "Atom HEAD length $head_length differs from GET length $feed_length" >&2
- exit 1
-}
-curl -fsS "http://127.0.0.1:$http_port/testing/+/stats" | grep -q 'Gilti smoke test'
-curl -fsS "http://127.0.0.1:$http_port/testing/+/diff/$old_commit..$new_commit?format=raw" |
- grep -q '^+Second key can push\.'
-curl -fsS "http://127.0.0.1:$http_port/testing/+/patch/$old_commit..$new_commit/+/README%2emd" |
- grep -q '^Subject: Push with second key'
-for format in tar tar.gz tar.bz2 tar.lz tar.xz tar.zst zip; do
- curl -fsS "http://127.0.0.1:$http_port/testing/+/HEAD/+/archive?format=$format" \
- -o "$work/testing.$format"
- [ -s "$work/testing.$format" ] || {
- echo "empty $format archive" >&2
- exit 1
- }
-done
-summary_status=$(curl -sS -o /dev/null -w '%{http_code}:%{redirect_url}' \
- "http://127.0.0.1:$http_port/testing/+/summary")
-[ "$summary_status" = "308:http://127.0.0.1:$http_port/testing" ] || {
- echo "unexpected summary redirect: $summary_status" >&2
- exit 1
-}
-GIT_CONFIG_GLOBAL=/dev/null git clone -q \
- "http://127.0.0.1:$http_port/testing.git" "$work/testing-http-clone"
-[ -f "$work/testing-http-clone/README.md" ]
-lfs_oid=2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881
-lfs_response=$(printf '{"operation":"download","objects":[{"oid":"%s","size":1}]}' "$lfs_oid" |
- curl -fsS -H 'Content-Type: application/vnd.git-lfs+json' --data-binary @- \
- "http://127.0.0.1:$http_port/testing.git/info/lfs/objects/batch")
-printf '%s' "$lfs_response" | grep -q '"code":404' || {
- echo 'unexpected LFS batch response' >&2
+cache_url="http://127.0.0.1:$http_port/testing/"
+curl -fsS -D "$work/cache-1.headers" -o /dev/null "$cache_url"
+sleep 2
+curl -fsS -D "$work/cache-2.headers" -o /dev/null "$cache_url"
+cache_modified_1=$(awk -F ': ' 'tolower($1) == "last-modified" { gsub("\\r", "", $2); print $2 }' "$work/cache-1.headers")
+cache_modified_2=$(awk -F ': ' 'tolower($1) == "last-modified" { gsub("\\r", "", $2); print $2 }' "$work/cache-2.headers")
+[ -n "$cache_modified_1" ] && [ "$cache_modified_1" = "$cache_modified_2" ] || {
+ echo 'CGI response was not served from the in-memory cache' >&2
exit 1
}
+
# The running sshd uses its startup snapshot, not the mounted source file.
cat "$work/admin.pub" >"$authorized_keys"
# shellcheck disable=SC2086