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.yml18+8 −10
-rw-r--r--.gitignore5+2 −3
-rw-r--r--.gitmodules6+6 −0
-rw-r--r--CODEOWNERS2+1 −1
-rw-r--r--Cargo.toml4+2 −2
-rw-r--r--Dockerfile17+11 −6
-rw-r--r--README.md14+8 −6
-rw-r--r--REUSE.toml4+2 −2
-rw-r--r--cgit/COPYING339+339 −0
-rw-r--r--cgit/Makefile25+25 −0
-rw-r--r--cgit/cgit.c400+400 −0
-rw-r--r--cgit/cgit.css (renamed from crates/gilti/assets/gilti.css)374+187 −187
-rw-r--r--cgit/cgit.h275+275 −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.c47+47 −0
-rw-r--r--cgit/cmd.h19+19 −0
-rw-r--r--cgit/favicon.ico (renamed from crates/gilti/assets/favicon.ico)bin1078 -> 1078 bytes
-rw-r--r--cgit/html.c350+350 −0
-rw-r--r--cgit/html.h41+41 −0
-rw-r--r--cgit/parsing.c181+181 −0
-rw-r--r--cgit/shared.c482+482 −0
-rw-r--r--cgit/ui-atom.c159+159 −0
-rw-r--r--cgit/ui-atom.h10+10 −0
-rw-r--r--cgit/ui-log.c555+555 −0
-rw-r--r--cgit/ui-log.h13+13 −0
-rw-r--r--cgit/ui-shared.c986+986 −0
-rw-r--r--cgit/ui-shared.h69+69 −0
-rw-r--r--charts/gilti/Chart.yaml4+2 −2
-rw-r--r--charts/gilti/README.md12+7 −5
-rw-r--r--charts/gilti/templates/configmap.yaml2+1 −1
-rw-r--r--charts/gilti/templates/deployment.yaml14+8 −6
-rw-r--r--charts/gilti/values.schema.json5+3 −2
-rw-r--r--charts/gilti/values.yaml4+3 −1
-rw-r--r--crates/gilti/Cargo.toml2+1 −1
-rw-r--r--crates/gilti/src/cgi.rs284+278 −6
-rw-r--r--crates/gilti/src/main.rs257+220 −37
-rw-r--r--crates/gilti/src/models/history.rs556+0 −556
-rw-r--r--crates/gilti/src/models/mod.rs21+0 −21
-rw-r--r--crates/gilti/src/models/object.rs2+1 −1
-rw-r--r--crates/gilti/src/models/stats.rs8+6 −2
-rw-r--r--crates/gilti/src/models/time.rs10+0 −10
-rw-r--r--crates/gilti/src/views/atom.rs247+0 −247
-rw-r--r--crates/gilti/src/views/diff.rs2+1 −1
-rw-r--r--crates/gilti/src/views/log.rs298+0 −298
-rw-r--r--crates/gilti/src/views/mod.rs4+1 −3
-rw-r--r--crates/gilti/src/views/repositories.rs8+4 −4
-rw-r--r--crates/gilti/src/views/revision.rs8+6 −2
-rw-r--r--crates/gilti/src/views/shared.rs8+4 −4
-rw-r--r--crates/gilti/src/views/stats.rs2+1 −1
m---------git1+1 −0
-rw-r--r--mise.toml8+6 −2
-rwxr-xr-xscripts/build-artifacts.sh26+22 −4
-rwxr-xr-xtests/chart.sh19+17 −2
-rwxr-xr-xtests/smoke.sh73+37 −36
57 files changed, 4813 insertions, 1501 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..31fe9c8 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,7 +64,7 @@ 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
@@ -86,6 +83,7 @@ jobs:
apk add --no-cache \
build-base=0.5-r3 \
cmake \
+ git=2.49.1-r0 \
python3 \
zlib-dev=1.3.2-r0 \
zlib-static=1.3.2-r0
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.toml b/Cargo.toml
index fc8833e..2f300b2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,8 +12,8 @@ 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"] }
diff --git a/Dockerfile b/Dockerfile
index a625eb1..7dd66ba 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,7 +13,7 @@ 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"
@@ -44,17 +44,22 @@ RUN apk add --no-cache \
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/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..5e09b9b 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ platforms but aren't ready to host complex services like Forgejo.
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
+- Password authentication, shells, forwarding, and tunnels are disabled; cgit
filters are not supported. Optional unauthenticated HTTP writes must be
enabled explicitly.
- Gilti is a single-replica service backed by one POSIX persistent volume. It is
@@ -44,9 +44,10 @@ docker run --rm \
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_CGIT_ROOT_TITLE` (default: `Gilti`);
+- `GILTI_CGIT_ROOT_DESCRIPTION` (default: `A tiny Git server`);
+- `GILTI_CGIT_CLONE_PREFIX` (empty by default);
+- `GILTI_CGIT_CACHE` (default: `5`, maximum: `3600` seconds);
- `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
@@ -67,7 +68,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 +76,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,25 @@
+# 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_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,400 @@
+/* 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 "html.h"
+#include "ui-shared.h"
+
+const char *cgit_version = CGIT_VERSION;
+
+static const char *config_value(const char *name)
+{
+ const char *value = getenv(name);
+
+ if (!value) {
+ fprintf(stderr, "gilti-cgit: missing required environment variable %s\n",
+ name);
+ exit(1);
+ }
+ return value;
+}
+
+static char *config_string(const char *name)
+{
+ return xstrdup(config_value(name));
+}
+
+static char *config_optional_string(const char *name)
+{
+ const char *value = config_value(name);
+
+ return *value ? xstrdup(value) : NULL;
+}
+
+static int config_integer(const char *name)
+{
+ const char *value = config_value(name);
+ char *end;
+ long result;
+
+ errno = 0;
+ result = strtol(value, &end, 10);
+ if (errno || end == value || *end || result < INT_MIN || result > INT_MAX) {
+ fprintf(stderr, "gilti-cgit: environment variable %s must be an integer\n",
+ name);
+ exit(1);
+ }
+ return result;
+}
+
+static char *request_optional_string(const char *name)
+{
+ const char *value = getenv(name);
+
+ return value && *value ? xstrdup(value) : NULL;
+}
+
+static int request_optional_integer(const char *name)
+{
+ const char *value = getenv(name);
+ char *end;
+ long result;
+
+ if (!value || !*value)
+ return 0;
+ errno = 0;
+ result = strtol(value, &end, 10);
+ if (errno || end == value || *end || result < INT_MIN || result > INT_MAX) {
+ fprintf(stderr, "gilti-cgit: environment variable %s must be an integer\n",
+ name);
+ exit(1);
+ }
+ return result;
+}
+
+static void prepare_context(void)
+{
+ const char *value;
+
+ memset(&ctx, 0, sizeof(ctx));
+ ctx.cfg.clone_prefix = config_optional_string("CGIT_CLONE_PREFIX");
+ ctx.cfg.clone_url = config_optional_string("CGIT_CLONE_URL");
+ ctx.cfg.commit_sort = config_integer("CGIT_COMMIT_SORT");
+ ctx.cfg.difftype = config_integer("CGIT_DIFFTYPE");
+ ctx.cfg.embedded = config_integer("CGIT_EMBEDDED");
+ ctx.cfg.enable_commit_graph = config_integer("CGIT_ENABLE_COMMIT_GRAPH");
+ ctx.cfg.enable_follow_links = config_integer("CGIT_ENABLE_FOLLOW_LINKS");
+ ctx.cfg.enable_log_filecount = config_integer("CGIT_ENABLE_LOG_FILECOUNT");
+ ctx.cfg.enable_log_linecount = config_integer("CGIT_ENABLE_LOG_LINECOUNT");
+ ctx.cfg.enable_remote_branches = config_integer("CGIT_ENABLE_REMOTE_BRANCHES");
+ ctx.cfg.favicon = config_string("CGIT_FAVICON");
+ ctx.cfg.footer = config_optional_string("CGIT_FOOTER");
+ ctx.cfg.head_include = config_optional_string("CGIT_HEAD_INCLUDE");
+ ctx.cfg.header = config_optional_string("CGIT_HEADER");
+ ctx.cfg.local_time = config_integer("CGIT_LOCAL_TIME");
+ ctx.cfg.logo = config_string("CGIT_LOGO");
+ ctx.cfg.logo_link = config_optional_string("CGIT_LOGO_LINK");
+ ctx.cfg.max_atom_items = config_integer("CGIT_MAX_ATOM_ITEMS");
+ ctx.cfg.max_commit_count = config_integer("CGIT_MAX_COMMIT_COUNT");
+ ctx.cfg.max_msg_len = config_integer("CGIT_MAX_MESSAGE_LENGTH");
+ ctx.cfg.noheader = config_integer("CGIT_NOHEADER");
+ ctx.cfg.noplainemail = config_integer("CGIT_NOPLAINEMAIL");
+ cgit_default_repo_desc = config_string("CGIT_REPO_DEFAULT_DESC");
+ ctx.cfg.renamelimit = config_integer("CGIT_RENAMELIMIT");
+ ctx.cfg.robots = config_string("CGIT_ROBOTS");
+ ctx.cfg.root_desc = config_string("CGIT_ROOT_DESC");
+ ctx.cfg.root_title = config_string("CGIT_ROOT_TITLE");
+ ctx.cfg.script_name = config_string("SCRIPT_NAME");
+ ctx.cfg.section = config_string("CGIT_SECTION");
+ ctx.cfg.virtual_root = ensure_end(config_value("CGIT_VIRTUAL_ROOT"), '/');
+ string_list_init_dup(&ctx.cfg.css);
+ string_list_init_dup(&ctx.cfg.js);
+ string_list_init_dup(&ctx.cfg.readme);
+ value = config_value("CGIT_CSS");
+ if (*value)
+ string_list_append(&ctx.cfg.css, value);
+ value = config_value("CGIT_JS");
+ if (*value)
+ string_list_append(&ctx.cfg.js, value);
+ value = config_value("CGIT_README_0");
+ if (*value)
+ string_list_append(&ctx.cfg.readme, value);
+ value = config_value("CGIT_README_1");
+ if (*value)
+ string_list_append(&ctx.cfg.readme, value);
+
+ ctx.env.http_host = getenv("HTTP_HOST");
+ ctx.env.https = getenv("HTTPS");
+ ctx.env.path_info = getenv("PATH_INFO");
+ ctx.env.query_string = getenv("QUERY_STRING");
+ ctx.env.request_method = getenv("REQUEST_METHOD");
+ 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.modified = time(NULL);
+}
+
+static void prepare_request(void)
+{
+ ctx.qry.repo = request_optional_string("GILTI_REPOSITORY");
+ ctx.qry.page = config_string("GILTI_PAGE");
+ ctx.qry.url = request_optional_string("GILTI_CURRENT_URL");
+ ctx.qry.head = request_optional_string("GILTI_REVISION");
+ if (ctx.qry.head) {
+ ctx.qry.oid = xstrdup(ctx.qry.head);
+ ctx.qry.has_oid = 1;
+ }
+ ctx.qry.path = request_optional_string("GILTI_PATH");
+ ctx.qry.search = request_optional_string("GILTI_QUERY_SEARCH");
+ ctx.qry.grep = request_optional_string("GILTI_QUERY_GREP");
+ ctx.qry.ofs = request_optional_integer("GILTI_QUERY_OFFSET");
+ ctx.qry.showmsg = request_optional_integer("GILTI_QUERY_SHOWMSG");
+ ctx.qry.context = request_optional_integer("GILTI_QUERY_CONTEXT");
+ ctx.qry.ignorews = request_optional_integer("GILTI_QUERY_IGNOREWS");
+ ctx.qry.follow = request_optional_integer("GILTI_QUERY_FOLLOW");
+ ctx.qry.difftype = request_optional_integer("GILTI_QUERY_DIFFTYPE");
+}
+
+static void prepare_repository(void)
+{
+ char *description = NULL;
+ char *description_path;
+ char *separator;
+ struct passwd *owner;
+ struct stat stat;
+ size_t size;
+
+ ctx.repo = cgit_add_repo(ctx.qry.repo);
+ ctx.repo->path = config_string("GILTI_REPOSITORY_PATH");
+ description_path = fmtalloc("%s/description", ctx.repo->path);
+ if (!read_first_line(description_path, &description, &size))
+ ctx.repo->desc = description;
+ free(description_path);
+ if (!lstat(ctx.repo->path, &stat) && (owner = getpwuid(stat.st_uid))) {
+ ctx.repo->owner = strdup_first_line(owner->pw_gecos && *owner->pw_gecos ?
+ owner->pw_gecos : owner->pw_name);
+ separator = strchr(ctx.repo->owner, ',');
+ if (separator)
+ *separator = '\0';
+ }
+}
+
+static char *guess_defbranch(void)
+{
+ const char *ref;
+ struct object_id oid;
+
+ ref = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
+ "HEAD", 0, &oid, NULL);
+ if (!ref)
+ return xstrdup("HEAD");
+ return xstrdup(ref);
+}
+
+struct walk_tree_context {
+ const char *match_path;
+ unsigned int found_path:1;
+};
+
+static int find_path(const struct object_id *oid UNUSED, struct strbuf *base,
+ const char *pathname, unsigned mode, void *cbdata)
+{
+ struct walk_tree_context *walk_tree_ctx = cbdata;
+
+ if (!S_ISREG(mode))
+ return READ_TREE_RECURSIVE;
+ if (strncmp(base->buf, walk_tree_ctx->match_path, base->len) ||
+ strcmp(walk_tree_ctx->match_path + base->len, pathname))
+ return READ_TREE_RECURSIVE;
+ walk_tree_ctx->found_path = 1;
+ return 0;
+}
+
+static int ref_path_exists(const char *path, const char *ref)
+{
+ struct object_id oid;
+ unsigned long size;
+ struct pathspec_item path_item = {
+ .match = xstrdup(path),
+ .len = strlen(path)
+ };
+ struct pathspec paths = {
+ .nr = 1,
+ .items = &path_item
+ };
+ struct walk_tree_context walk_tree_ctx = {
+ .match_path = path,
+ .found_path = 0
+ };
+
+ if (!repo_get_oid(the_repository, ref, &oid) &&
+ odb_read_object_info(the_repository->objects, &oid, &size) == OBJ_COMMIT)
+ read_tree(the_repository,
+ repo_get_commit_tree(the_repository,
+ lookup_commit_reference(the_repository, &oid)),
+ &paths, find_path, &walk_tree_ctx);
+ free(path_item.match);
+ return walk_tree_ctx.found_path;
+}
+
+static void parse_readme(const char *readme, char **filename, char **ref)
+{
+ const char *colon;
+
+ *filename = NULL;
+ *ref = NULL;
+ if (!readme || !*readme)
+ return;
+ colon = strchr(readme, ':');
+ if (colon && colon[1]) {
+ if (colon == readme)
+ *ref = xstrdup(ctx.qry.head ? ctx.qry.head : ctx.repo->defbranch);
+ else
+ *ref = xstrndup(readme, colon - readme);
+ readme = colon + 1;
+ }
+ *filename = *ref || readme[0] == '/' ? xstrdup(readme) :
+ fmtalloc("%s/%s", ctx.repo->path, readme);
+}
+
+static void choose_readme(void)
+{
+ char *filename, *ref;
+ struct string_list_item *entry;
+
+ for_each_string_list_item(entry, &ctx.repo->readme) {
+ parse_readme(entry->string, &filename, &ref);
+ if ((ref && ref_path_exists(filename, ref)) ||
+ (!ref && !access(filename, R_OK))) {
+ ctx.repo->readme.strdup_strings = 1;
+ string_list_clear(&ctx.repo->readme, 0);
+ ctx.repo->readme.strdup_strings = 0;
+ string_list_append(&ctx.repo->readme, filename)->util = ref;
+ return;
+ }
+ free(filename);
+ free(ref);
+ }
+ ctx.repo->readme.strdup_strings = 1;
+ string_list_clear(&ctx.repo->readme, 0);
+ ctx.repo->readme.strdup_strings = 0;
+}
+
+static void prepare_repo_env(int *nongit)
+{
+ /* 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.head = xstrdup("HEAD");
+
+ if (repo_get_oid(the_repository, ctx.qry.head, &oid)) {
+ cgit_print_error_page(404, "Not found",
+ "Invalid revision: %s", ctx.qry.head);
+ return 1;
+ }
+ cgit_prepare_repo_env(ctx.repo);
+ choose_readme();
+ return 0;
+}
+
+static void process_request(void)
+{
+ struct cgit_cmd *cmd;
+ int nongit = 0;
+
+ if (ctx.qry.repo && !ctx.repo) {
+ cgit_print_error_page(404, "Not found", "Repository not found");
+ return;
+ }
+
+ 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 (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 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 UNUSED, const char **argv UNUSED)
+{
+ set_die_routine(cgit_die_routine);
+
+ prepare_context();
+ prepare_request();
+ if (ctx.qry.repo)
+ prepare_repository();
+ 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,275 @@
+/* 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 (*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_repo {
+ char *url;
+ char *name;
+ char *path;
+ char *desc;
+ char *extra_head_content;
+ char *owner;
+ char *homepage;
+ char *defbranch;
+ struct string_list readme;
+ char *section;
+ char *clone_url;
+ char *logo;
+ char *logo_link;
+ int enable_commit_graph;
+ int enable_follow_links;
+ int enable_log_filecount;
+ int enable_log_linecount;
+ int enable_remote_branches;
+ int commit_sort;
+ time_t mtime;
+ int hide;
+ int ignore;
+};
+
+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;
+ char *repo;
+ char *page;
+ char *search;
+ char *grep;
+ char *head;
+ char *oid;
+ char *path;
+ char *url;
+ int ofs;
+ int showmsg;
+ diff_type difftype;
+ int show_all;
+ int context;
+ int ignorews;
+ int follow;
+ char *vpath;
+};
+
+struct cgit_config {
+ char *clone_prefix;
+ char *clone_url;
+ char *favicon;
+ char *footer;
+ char *head_include;
+ char *header;
+ char *logo;
+ char *logo_link;
+ 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 *virtual_root; /* Always ends with '/'. */
+ int embedded;
+ int enable_follow_links;
+ int enable_commit_graph;
+ int enable_log_filecount;
+ int enable_log_linecount;
+ int enable_remote_branches;
+ int local_time;
+ int max_atom_items;
+ int max_commit_count;
+ int max_msg_len;
+ int noplainemail;
+ int noheader;
+ int renamelimit;
+ diff_type difftype;
+ int commit_sort;
+ 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 *http_host;
+ const char *https;
+ const char *path_info;
+ const char *query_string;
+ const char *request_method;
+ 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;
+};
+
+extern const char *cgit_version;
+
+extern struct cgit_context ctx;
+
+extern char *cgit_default_repo_desc;
+extern struct cgit_repo *cgit_add_repo(const char *url);
+
+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 const char *cgit_repobasename(const char *reponame);
+
+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);
+
+#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,47 @@
+/* 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-atom.h"
+#include "ui-log.h"
+
+static void atom_fn(void)
+{
+ cgit_print_atom(ctx.qry.head, ctx.qry.path, ctx.cfg.max_atom_items);
+}
+
+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);
+}
+
+#define def_cmd(name, want_repo, want_vpath) \
+ {#name, name##_fn, want_repo, want_vpath}
+
+struct cgit_cmd *cgit_get_cmd(void)
+{
+ static struct cgit_cmd cmds[] = {
+ def_cmd(atom, 1, 0),
+ def_cmd(log, 1, 1),
+ };
+ int i;
+
+ 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,19 @@
+/* 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;
+};
+
+extern struct cgit_cmd *cgit_get_cmd(void);
+
+#endif /* CMD_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/html.c b/cgit/html.c
new file mode 100644
--- /dev/null
+++ b/cgit/html.c
@@ -0,0 +1,350 @@
+/* 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;
+}
diff --git a/cgit/html.h b/cgit/html.h
new file mode 100644
--- /dev/null
+++ b/cgit/html.h
@@ -0,0 +1,41 @@
+/* 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);
+
+
+#endif /* HTML_H */
diff --git a/cgit/parsing.c b/cgit/parsing.c
new file mode 100644
--- /dev/null
+++ b/cgit/parsing.c
@@ -0,0 +1,181 @@
+/* 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"
+
+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/shared.c b/cgit/shared.c
new file mode 100644
--- /dev/null
+++ b/cgit/shared.c
@@ -0,0 +1,482 @@
+/* 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_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;
+struct cgit_repo *cgit_add_repo(const char *url)
+{
+ struct cgit_repo *ret = xcalloc(1, sizeof(*ret));
+
+ 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->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->commit_sort = ctx.cfg.commit_sort;
+ ret->readme = ctx.cfg.readme;
+ ret->mtime = -1;
+ ret->clone_url = ctx.cfg.clone_url;
+ ret->hide = ret->ignore = 0;
+ return ret;
+}
+
+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);
+}
+
+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;
+}
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,159 @@
+/* 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 *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_revurl(ctx.repo->url, hex);
+ html_attr(pageurl);
+ 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-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-shared.c b/cgit/ui-shared.c
new file mode 100644
--- /dev/null
+++ b/cgit/ui-shared.c
@@ -0,0 +1,986 @@
+/* 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)
+{
+ return xstrdup(ctx.qry.url ? ctx.qry.url : cgit_rooturl());
+}
+
+char *cgit_currentfullurl(void)
+{
+ const char *url = ctx.qry.url ? ctx.qry.url : cgit_rooturl();
+ const char *query = ctx.env.query_string;
+
+ return query && *query ? fmtalloc("%s?%s", url, query) : xstrdup(url);
+}
+
+const char *cgit_rooturl(void)
+{
+ if (ctx.cfg.virtual_root)
+ return ctx.cfg.virtual_root;
+ else
+ return ctx.cfg.script_name;
+}
+
+static void add_url_path(struct strbuf *url, const char *value)
+{
+ const unsigned char *p = (const unsigned char *)(value ? value : "");
+
+ for (; *p; p++) {
+ if (isalnum(*p) || *p == '/' || *p == '_')
+ strbuf_addch(url, *p);
+ else
+ strbuf_addf(url, "%%%02x", *p);
+ }
+}
+
+static void add_repo_url(struct strbuf *url, const char *reponame)
+{
+ strbuf_addch(url, '/');
+ add_url_path(url, reponame);
+}
+
+static const char *link_revision(const char *head, const char *rev)
+{
+ return rev ? rev : head ? head : "HEAD";
+}
+
+static void add_revision(struct strbuf *url, const char *revision)
+{
+ struct object_id oid;
+
+ if (!strcmp(revision, "HEAD") || starts_with(revision, "refs/") ||
+ !get_oid_hex(revision, &oid))
+ add_url_path(url, revision);
+ else {
+ strbuf_addstr(url, "refs/heads/");
+ add_url_path(url, revision);
+ }
+}
+
+static char *repo_view_url(const char *reponame, const char *page,
+ const char *revision, const char *path)
+{
+ struct strbuf url = STRBUF_INIT;
+
+ add_repo_url(&url, reponame);
+ if (!page || !strcmp(page, "summary"))
+ return strbuf_detach(&url, NULL);
+ strbuf_addstr(&url, "/+/");
+ if (!strcmp(page, "about") || !strcmp(page, "stats") ||
+ !strcmp(page, "refs")) {
+ strbuf_addstr(&url, page);
+ return strbuf_detach(&url, NULL);
+ }
+ add_revision(&url, revision ? revision : "HEAD");
+ if (strcmp(page, "commit")) {
+ strbuf_addstr(&url, "/+/");
+ strbuf_addstr(&url, !strcmp(page, "plain") ? "tree" : page);
+ if (path) {
+ strbuf_addch(&url, '/');
+ add_url_path(&url, path);
+ }
+ }
+ return strbuf_detach(&url, NULL);
+}
+
+char *cgit_repourl(const char *reponame)
+{
+ return repo_view_url(reponame, NULL, NULL, NULL);
+}
+
+char *cgit_fileurl(const char *reponame, const char *pagename,
+ const char *filename, const char *query)
+{
+ char *url = repo_view_url(reponame,
+ !strcmp(pagename, "atom") ? "feed/atom" : pagename,
+ ctx.qry.head, filename);
+ char *result;
+
+ if (!query)
+ return url;
+ result = fmtalloc("%s?%s", url, query);
+ free(url);
+ return result;
+}
+
+char *cgit_pageurl(const char *reponame, const char *pagename,
+ const char *query)
+{
+ return cgit_fileurl(reponame, pagename, NULL, query);
+}
+
+char *cgit_revurl(const char *reponame, const char *revision)
+{
+ return repo_view_url(reponame, "commit", revision, NULL);
+}
+
+char *cgit_treeurl(const char *reponame, const char *revision,
+ const char *path, const char *fragment)
+{
+ char *url = repo_view_url(reponame, "tree", revision, path);
+ char *result;
+
+ if (!fragment)
+ return url;
+ result = fmtalloc("%s#%s", url, fragment);
+ free(url);
+ return result;
+}
+
+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;
+}
+
+static void site_url(const char *page, const char *search, const char *sort, int ofs, int always_root)
+{
+ char *delim = "?";
+
+ if (page)
+ htmlf("/-/%s", page);
+ else if (always_root)
+ html_attr(cgit_rooturl());
+ else {
+ char *currenturl = cgit_currenturl();
+ html_attr(currenturl);
+ free(currenturl);
+ }
+
+ 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 void link_start(const char *url, const char *title, const char *class)
+{
+ html("<a");
+ if (title) {
+ html(" title='");
+ html_attr(title);
+ html("'");
+ }
+ if (class) {
+ html(" class='");
+ html_attr(class);
+ html("'");
+ }
+ html(" href='");
+ html_attr(url);
+}
+
+static char *repolink(const char *title, const char *class, const char *page,
+ const char *head, const char *path)
+{
+ char *url = repo_view_url(ctx.repo->url, page, head, path);
+
+ link_start(url, title, class);
+ free(url);
+ return "?";
+}
+
+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 *url = repo_view_url(ctx.repo->url, page,
+ link_revision(head, rev), path);
+
+ if (!strcmp(page ? page : "", "plain")) {
+ char *raw = fmtalloc("%s?format=raw", url);
+ free(url);
+ url = raw;
+ }
+ link_start(url, title, class);
+ free(url);
+ 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)
+{
+ char *ref = starts_with(tag, "refs/tags/") ? xstrdup(tag) :
+ fmtalloc("refs/tags/%s", tag);
+
+ reporevlink("commit", name, title, class, ref, NULL, NULL);
+ free(ref);
+}
+
+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_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", link_revision(head, rev), path);
+ 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", link_revision(head, rev), NULL);
+ 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_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)
+{
+ struct strbuf url = STRBUF_INIT;
+ char *delim = "?";
+
+ add_repo_url(&url, ctx.repo->url);
+ strbuf_addstr(&url, "/+/diff/");
+ add_revision(&url, old_rev ? old_rev : "HEAD");
+ strbuf_addstr(&url, "..");
+ add_revision(&url, link_revision(head, new_rev));
+ if (path) {
+ strbuf_addstr(&url, "/+/");
+ add_url_path(&url, path);
+ }
+ link_start(url.buf, title, class);
+ strbuf_release(&url);
+ 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_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, "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 {
+ repolink(title, class, ctx.qry.page, ctx.qry.head, ctx.qry.path);
+ html("'>");
+ html_txt(name);
+ html("</a>");
+ }
+}
+
+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.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);
+}
+
+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, "/-/assets/cgit.css");
+
+ if (ctx.cfg.js.items)
+ for_each_string_list(&ctx.cfg.js, emit_js_link, NULL);
+ else
+ emit_js_link(NULL, "/-/assets/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 && starts_with(ctx.qry.head, "refs/")) {
+ char *fileurl;
+
+ html("<link rel='alternate' title='Atom feed' href='");
+ html(cgit_httpscheme());
+ html_attr(host);
+ fileurl = cgit_fileurl(ctx.repo->url, "atom", ctx.qry.vpath, NULL);
+ html_attr(fileurl);
+ html("' type='application/atom+xml'/>\n");
+ 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) {
+ char *suffix = fmtalloc("%s.git", ctx.repo->url);
+ add_clone_urls(fn, ctx.cfg.clone_prefix, suffix);
+ free(suffix);
+ } else {
+ char *host = cgit_hosturl();
+ struct strbuf url = STRBUF_INIT;
+
+ if (!host)
+ return;
+ strbuf_addf(&url, "%s%s", cgit_httpscheme(), host);
+ add_repo_url(&url, ctx.repo->url);
+ strbuf_addstr(&url, ".git");
+ fn(url.buf);
+ strbuf_release(&url);
+ free(host);
+ }
+}
+
+static int print_branch_option(const struct reference *ref, void *cb_data UNUSED)
+{
+ char *url = repo_view_url(ctx.repo->url, "tree", ref->name,
+ ctx.qry.vpath);
+
+ html_option(url, ref->name,
+ ctx.qry.head && !strcmp(ref->name, ctx.qry.head) ? url : NULL);
+ free(url);
+ return 0;
+}
+
+void cgit_add_hidden_formfields(int incl_head UNUSED, int incl_search,
+ const char *page UNUSED)
+{
+ 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 blame = !strcmp(ctx.qry.page, "blame");
+ int levels = 0;
+
+ ctx.qry.path = NULL;
+ if (blame)
+ cgit_tree_link("root", NULL, NULL, ctx.qry.head,
+ ctx.qry.oid, NULL);
+ else
+ 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("/");
+ if (blame && q < end)
+ cgit_tree_link(p, NULL, NULL, ctx.qry.head,
+ ctx.qry.oid, ctx.qry.path);
+ else
+ 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("<select onchange='window.location.href=this.value'>\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>");
+ } 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);
+ cgit_tree_link("tree", NULL, hc("tree"), ctx.qry.head,
+ ctx.qry.oid, ctx.qry.vpath);
+ cgit_commit_link("commit", NULL,
+ ctx.qry.page && !strcmp(ctx.qry.page, "revision") ? "active" : hc("commit"),
+ ctx.qry.head, ctx.qry.oid, ctx.qry.vpath);
+ cgit_diff_link("diff", NULL, hc("diff"), ctx.qry.head,
+ ctx.qry.oid, NULL, ctx.qry.vpath);
+ 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_attr(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'>");
+}
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,69 @@
+/* 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 char *cgit_revurl(const char *reponame, const char *revision);
+extern char *cgit_treeurl(const char *reponame, const char *revision,
+ const char *path, const char *fragment);
+
+
+extern void cgit_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_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_refs_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *rev, const char *path);
+extern void cgit_diff_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *new_rev, const char *old_rev,
+ const char *path);
+extern void cgit_stats_link(const char *name, const char *title,
+ const char *class, const char *head,
+ const char *path);
+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_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_add_clone_urls(void (*fn)(const char *));
+extern void cgit_add_hidden_formfields(int incl_head, int incl_search,
+ const char *page);
+
+#endif /* UI_SHARED_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..f8e5476 100644
--- a/charts/gilti/README.md
+++ b/charts/gilti/README.md
@@ -53,11 +53,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 +76,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..7cb6f72 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 }}
diff --git a/charts/gilti/templates/deployment.yaml b/charts/gilti/templates/deployment.yaml
index 08bd3c7..58bf5d0 100644
--- a/charts/gilti/templates/deployment.yaml
+++ b/charts/gilti/templates/deployment.yaml
@@ -48,12 +48,14 @@ spec:
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_CGIT_ROOT_TITLE
+ value: {{ .Values.cgit.rootTitle | quote }}
+ - name: GILTI_CGIT_ROOT_DESCRIPTION
+ value: {{ .Values.cgit.rootDescription | quote }}
+ - name: GILTI_CGIT_CLONE_PREFIX
+ value: {{ .Values.cgit.clonePrefix | quote }}
+ - name: GILTI_CGIT_CACHE
+ value: {{ .Values.cgit.cache | quote }}
- name: GILTI_HTTP_WRITE
value: {{ ternary "1" "0" .Values.http.write | quote }}
ports:
diff --git a/charts/gilti/values.schema.json b/charts/gilti/values.schema.json
index 3fc6618..a22ed4e 100644
--- a/charts/gilti/values.schema.json
+++ b/charts/gilti/values.schema.json
@@ -22,12 +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]*$" }
+ "clonePrefix": { "type": "string", "pattern": "^[^\\r\\n]*$" },
+ "cache": { "type": "integer", "minimum": 0, "maximum": 3600 }
}
},
"http": {
diff --git a/charts/gilti/values.yaml b/charts/gilti/values.yaml
index 2924782..60e492e 100644
--- a/charts/gilti/values.yaml
+++ b/charts/gilti/values.yaml
@@ -17,10 +17,12 @@ ssh:
# Every listed key has read/write access to every repository.
authorizedKeys: []
-web:
+cgit:
rootTitle: Gilti
rootDescription: A tiny Git server
clonePrefix: ""
+ # In-memory CGI response lifetime in seconds; zero disables the cache.
+ cache: 5
http:
# Allow unauthenticated Git receive-pack and LFS uploads.
diff --git a/crates/gilti/Cargo.toml b/crates/gilti/Cargo.toml
index 8296cd9..c98b0a2 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
diff --git a/crates/gilti/src/cgi.rs b/crates/gilti/src/cgi.rs
index ce5989a..38d0693 100644
--- a/crates/gilti/src/cgi.rs
+++ b/crates/gilti/src/cgi.rs
@@ -3,10 +3,195 @@
//! HTTP-to-CGI Tower service.
+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, Default)]
pub struct Environment(pub Vec<(std::ffi::OsString, std::ffi::OsString)>);
#[derive(Clone, Copy)]
+pub struct NoCache;
+
+#[derive(Clone, Copy)]
pub struct RemoteAddr(pub std::net::SocketAddr);
#[derive(Clone)]
@@ -15,6 +200,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,7 +214,15 @@ 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(
@@ -52,6 +246,20 @@ impl Cgi {
let body = axum::body::to_bytes(body, 1024 * 1024 * 1024)
.await
.map_err(std::io::Error::other)?;
+ let cache_key = parts
+ .extensions
+ .get::<NoCache>()
+ .is_none()
+ .then(|| CacheKey::from_request(&parts, &body))
+ .flatten();
+ 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"));
@@ -140,12 +348,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 +430,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/main.rs b/crates/gilti/src/main.rs
index 70a0b3d..1d42963 100644
--- a/crates/gilti/src/main.rs
+++ b/crates/gilti/src/main.rs
@@ -8,8 +8,12 @@ pub mod router;
mod views;
const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8080";
+const DEFAULT_CACHE_SECONDS: &str = "5";
const DEFAULT_ROOT_TITLE: &str = "Gilti";
const DEFAULT_ROOT_DESCRIPTION: &str = "A tiny Git server";
+const MAX_CACHE_SECONDS: u64 = 3600;
+
+const CGIT: &str = "/usr/local/bin/gilti-cgit";
const GIT_HTTP_BACKENDS: &[&str] = &[
"/usr/libexec/git-core/git-http-backend",
"/usr/lib/git-core/git-http-backend",
@@ -24,13 +28,49 @@ const ARCHIVE_COMPRESSORS: &[&str] = &[
"/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 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";
+
+const CGIT_ENVIRONMENT: &[(&str, &str)] = &[
+ ("CGIT_CLONE_URL", ""),
+ ("CGIT_COMMIT_SORT", "0"),
+ ("CGIT_CSS", "/-/assets/cgit.css"),
+ ("CGIT_DIFFTYPE", "0"),
+ ("CGIT_EMBEDDED", "0"),
+ ("CGIT_ENABLE_COMMIT_GRAPH", "1"),
+ ("CGIT_ENABLE_FOLLOW_LINKS", "0"),
+ ("CGIT_ENABLE_LOG_FILECOUNT", "1"),
+ ("CGIT_ENABLE_LOG_LINECOUNT", "1"),
+ ("CGIT_ENABLE_REMOTE_BRANCHES", "0"),
+ ("CGIT_FAVICON", "/-/assets/favicon.ico"),
+ ("CGIT_FOOTER", ""),
+ ("CGIT_HEADER", ""),
+ ("CGIT_HEAD_INCLUDE", ""),
+ ("CGIT_JS", "/-/assets/cgit.js"),
+ ("CGIT_LOCAL_TIME", "0"),
+ ("CGIT_LOGO", "/-/assets/cgit.png"),
+ ("CGIT_LOGO_LINK", ""),
+ ("CGIT_MAX_ATOM_ITEMS", "10"),
+ ("CGIT_MAX_COMMIT_COUNT", "50"),
+ ("CGIT_MAX_MESSAGE_LENGTH", "80"),
+ ("CGIT_NOHEADER", "0"),
+ ("CGIT_NOPLAINEMAIL", "0"),
+ ("CGIT_README_0", ":README.md"),
+ ("CGIT_README_1", ":README"),
+ ("CGIT_REPO_DEFAULT_DESC", "[no description]"),
+ ("CGIT_RENAMELIMIT", "-1"),
+ ("CGIT_ROBOTS", "index, nofollow"),
+ ("CGIT_SECTION", ""),
+ ("CGIT_VIRTUAL_ROOT", "/"),
+ ("GIT_ATTR_NOSYSTEM", "1"),
+ ("GIT_CONFIG_NOSYSTEM", "1"),
+];
#[derive(Clone)]
struct RepositoryService {
+ cgit: cgi::Cgi,
git: cgi::Cgi,
views: views::shared::Context,
write_enabled: bool,
@@ -38,6 +78,7 @@ struct RepositoryService {
struct Config {
listen_addr: std::net::SocketAddr,
+ cache: std::time::Duration,
root_title: String,
root_description: String,
clone_prefix: String,
@@ -49,11 +90,13 @@ impl Config {
let listen_addr = environment("GILTI_HTTP_ADDR", DEFAULT_LISTEN_ADDR)?
.parse()
.map_err(|_| invalid_config("GILTI_HTTP_ADDR must be a socket address"))?;
+ let cache = parse_cache(&environment("GILTI_CGIT_CACHE", DEFAULT_CACHE_SECONDS)?)?;
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", "")?,
+ cache,
+ root_title: environment("GILTI_CGIT_ROOT_TITLE", DEFAULT_ROOT_TITLE)?,
+ root_description: environment("GILTI_CGIT_ROOT_DESCRIPTION", DEFAULT_ROOT_DESCRIPTION)?,
+ clone_prefix: environment("GILTI_CGIT_CLONE_PREFIX", "")?,
http_write: parse_bool("GILTI_HTTP_WRITE", &environment("GILTI_HTTP_WRITE", "0")?)?,
})
}
@@ -77,6 +120,18 @@ fn parse_bool(name: &str, value: &str) -> std::io::Result<bool> {
}
}
+fn parse_cache(value: &str) -> std::io::Result<std::time::Duration> {
+ let seconds = value
+ .parse::<u64>()
+ .map_err(|_| invalid_config("GILTI_CGIT_CACHE must be an integer number of seconds"))?;
+ if seconds > MAX_CACHE_SECONDS {
+ return Err(invalid_config(format!(
+ "GILTI_CGIT_CACHE must not exceed {MAX_CACHE_SECONDS} seconds"
+ )));
+ }
+ Ok(std::time::Duration::from_secs(seconds))
+}
+
fn invalid_config(message: impl Into<String>) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, message.into())
}
@@ -98,6 +153,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
root_description: std::sync::Arc::from(config.root_description.clone()),
clone_prefix: std::sync::Arc::from(config.clone_prefix.clone()),
};
+ let mut cgit = cgi::Cgi::new(CGIT, GIT_HOME, config.listen_addr)
+ .cache(config.cache)
+ .env("CGIT_ROOT_TITLE", config.root_title)
+ .env("CGIT_ROOT_DESC", config.root_description)
+ .env("CGIT_CLONE_PREFIX", config.clone_prefix)
+ .env("PATH", "/usr/bin:/bin");
+ for (name, value) in CGIT_ENVIRONMENT {
+ cgit = cgit.env(*name, *value);
+ }
let git = cgi::Cgi::new(git_http_backend, GIT_HOME, config.listen_addr)
.env("GIT_PROJECT_ROOT", REPOSITORIES)
.env("GIT_HTTP_EXPORT_ALL", "1")
@@ -108,6 +172,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("PATH", "/usr/bin:/bin");
let repositories = RepositoryService {
+ cgit,
git,
views,
write_enabled: config.http_write,
@@ -136,20 +201,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}),
)
.route(
- "/-/assets/gilti.css",
- axum::routing::get(async || static_file(ASSET_CSS, "text/css")),
+ "/-/assets/cgit.css",
+ axum::routing::get(async || static_file(CGIT_CSS, "text/css")),
)
.route(
- "/-/assets/gilti.js",
- axum::routing::get(async || static_file(ASSET_JS, "text/javascript")),
+ "/-/assets/cgit.js",
+ axum::routing::get(async || static_file(CGIT_JS, "text/javascript")),
)
.route(
- "/-/assets/gilti.png",
- axum::routing::get(async || static_file(ASSET_LOGO, "image/png")),
+ "/-/assets/cgit.png",
+ axum::routing::get(async || static_file(CGIT_LOGO, "image/png")),
)
.route(
"/-/assets/favicon.ico",
- axum::routing::get(async || static_file(ASSET_FAVICON, "image/x-icon")),
+ axum::routing::get(async || static_file(CGIT_FAVICON, "image/x-icon")),
)
.fallback_service(repositories);
@@ -177,7 +242,8 @@ fn git_http_backend() -> std::io::Result<std::path::PathBuf> {
}
fn check_files(git_http_backend: &std::path::Path) -> std::io::Result<()> {
- for path in std::iter::once(git_http_backend)
+ for path in std::iter::once(std::path::Path::new(CGIT))
+ .chain(std::iter::once(git_http_backend))
.chain(std::iter::once(std::path::Path::new(GIT)))
.chain(ARCHIVE_COMPRESSORS.iter().map(std::path::Path::new))
{
@@ -191,7 +257,7 @@ fn check_files(git_http_backend: &std::path::Path) -> std::io::Result<()> {
)));
}
}
- for path in [ASSET_CSS, ASSET_JS, ASSET_LOGO, ASSET_FAVICON] {
+ for path in [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"
@@ -255,7 +321,7 @@ impl RepositoryService {
{
request.extensions_mut().insert(cgi::RemoteAddr(remote));
}
- let browser_view = matches!(
+ let migrated = matches!(
&route,
router::Route::Repositories
| router::Route::Overview(_)
@@ -270,10 +336,8 @@ impl RepositoryService {
| router::Route::ArchiveSignature(_)
| router::Route::Diff(_)
| router::Route::Patch(_)
- | router::Route::Log(_)
- | router::Route::AtomFeed(_)
);
- let query = if browser_view {
+ let query = if migrated {
let query = match request_query(request.uri().query()) {
Ok(query) => query,
Err(()) => {
@@ -387,23 +451,6 @@ impl RepositoryService {
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),
@@ -439,6 +486,7 @@ impl RepositoryService {
)
.await
}
+ route => self.cgit(request, route).await,
}
}
@@ -462,11 +510,73 @@ impl RepositoryService {
request
.extensions_mut()
.insert(cgi::Environment(environment));
+ request.extensions_mut().insert(cgi::NoCache);
match tower::ServiceExt::oneshot(self.git.clone(), request).await {
Ok(response) => response,
Err(error) => internal_error("git-http-backend", error),
}
}
+
+ async fn cgit(
+ &self,
+ mut request: axum::extract::Request,
+ route: router::Route,
+ ) -> axum::response::Response {
+ if request.method() != axum::http::Method::GET
+ && request.method() != axum::http::Method::HEAD
+ {
+ return plain_response(
+ axum::http::StatusCode::METHOD_NOT_ALLOWED,
+ "method not allowed\n",
+ );
+ }
+ 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");
+ }
+ let mut environment = match cgit_environment(route, query, request.uri().path()) {
+ Ok(environment) => environment,
+ Err(()) => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"),
+ };
+ let repository = environment
+ .iter()
+ .find(|(name, _)| name == std::ffi::OsStr::new("GILTI_REPOSITORY"))
+ .and_then(|(_, value)| value.to_str());
+ let repository_path = match repository {
+ Some(repository) => crate::models::repository::path(
+ std::path::Path::new(self.views.repositories),
+ repository,
+ ),
+ None => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"),
+ };
+ let repository_path = match repository_path {
+ Ok(path) => path,
+ Err(crate::models::Error::NotFound) => {
+ return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n");
+ }
+ Err(crate::models::Error::Internal(error)) => {
+ eprintln!("gilti: repository lookup failed: {error}");
+ return plain_response(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "internal server error\n",
+ );
+ }
+ };
+ environment.push((
+ "GILTI_REPOSITORY_PATH".into(),
+ repository_path.into_os_string(),
+ ));
+ request
+ .extensions_mut()
+ .insert(cgi::Environment(environment));
+ match tower::ServiceExt::oneshot(self.cgit.clone(), request).await {
+ Ok(response) => response,
+ Err(error) => internal_error("cgit", error),
+ }
+ }
}
#[derive(Default)]
@@ -552,6 +662,44 @@ fn valid_format(route: &router::Route, format: Option<&str>) -> bool {
}
}
+fn cgit_environment(
+ route: router::Route,
+ query: RequestQuery,
+ current_url: &str,
+) -> Result<Vec<(std::ffi::OsString, std::ffi::OsString)>, ()> {
+ let mut environment = query.environment;
+ let mut set = |name: &str, value: String| environment.push((name.into(), value.into()));
+ set("GILTI_CURRENT_URL", current_url.to_owned());
+
+ match route {
+ router::Route::Log(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "log".to_owned());
+ set("GILTI_REVISION", revision(route.params.rev));
+ if let Some(path) = route.params.path {
+ set("GILTI_PATH", path);
+ }
+ }
+ router::Route::AtomFeed(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "atom".to_owned());
+ set("GILTI_REVISION", route.params.reference);
+ if let Some(path) = route.params.path {
+ set("GILTI_PATH", path);
+ }
+ }
+ _ => return Err(()),
+ }
+ Ok(environment)
+}
+
+fn revision(revision: router::Revision) -> String {
+ match revision {
+ router::Revision::Head => "HEAD".to_owned(),
+ router::Revision::Ref(reference) | router::Revision::Commit(reference) => reference,
+ }
+}
+
fn safe_repository(repo: &str) -> bool {
!repo.is_empty()
&& !repo.starts_with('/')
@@ -615,10 +763,45 @@ fn plain_response(
#[cfg(test)]
mod tests {
+ fn environment<'a>(
+ values: &'a [(std::ffi::OsString, std::ffi::OsString)],
+ name: &str,
+ ) -> &'a str {
+ values
+ .iter()
+ .find(|(key, _)| key == std::ffi::OsStr::new(name))
+ .and_then(|(_, value)| value.to_str())
+ .unwrap()
+ }
+
+ #[test]
+ fn route_parameters_become_trusted_cgit_environment() {
+ let route = super::router::parse("/group/repo/+/refs/heads/main/+/log/src/lib.rs").unwrap();
+ let query = super::request_query(Some("ofs=5&follow=1")).unwrap();
+ let values = super::cgit_environment(route, query, "/group/repo").unwrap();
+ assert_eq!(environment(&values, "GILTI_REPOSITORY"), "group/repo");
+ assert_eq!(environment(&values, "GILTI_PAGE"), "log");
+ assert_eq!(environment(&values, "GILTI_REVISION"), "refs/heads/main");
+ assert_eq!(environment(&values, "GILTI_PATH"), "src/lib.rs");
+ assert_eq!(environment(&values, "GILTI_QUERY_OFFSET"), "5");
+ assert_eq!(environment(&values, "GILTI_QUERY_FOLLOW"), "1");
+ }
+
#[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());
}
+
+ #[test]
+ fn cache_is_bounded() {
+ assert_eq!(
+ super::parse_cache("5").unwrap(),
+ std::time::Duration::from_secs(5)
+ );
+ assert!(super::parse_cache("0").unwrap().is_zero());
+ assert!(super::parse_cache("3601").is_err());
+ assert!(super::parse_cache("forever").is_err());
+ }
}
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
index b507017..f60f9f3 100644
--- a/crates/gilti/src/models/mod.rs
+++ b/crates/gilti/src/models/mod.rs
@@ -7,7 +7,6 @@ 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;
@@ -17,7 +16,6 @@ pub mod repository;
pub mod revision;
pub mod stats;
pub mod tag;
-pub mod time;
pub mod tree;
#[derive(Debug)]
@@ -119,25 +117,6 @@ mod migration_tests {
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
index 68afdc0..36c7d67 100644
--- a/crates/gilti/src/models/object.rs
+++ b/crates/gilti/src/models/object.rs
@@ -36,7 +36,7 @@ mod tests {
}
#[test]
- fn reads_raw_text_and_binary_objects_like_browser_blob() {
+ fn reads_raw_text_and_binary_objects_like_cgit_blob() {
let (root, repository) = fixture();
let odb = repository.odb().unwrap();
let text_oid = odb.write(git2::ObjectType::Blob, b"hello\n").unwrap();
diff --git a/crates/gilti/src/models/stats.rs b/crates/gilti/src/models/stats.rs
index deaca92..c4b026e 100644
--- a/crates/gilti/src/models/stats.rs
+++ b/crates/gilti/src/models/stats.rs
@@ -118,8 +118,12 @@ fn label(period: Period, timestamp: i64) -> String {
}
fn tm(timestamp: i64) -> libc::tm {
- super::time::utc(timestamp)
- .unwrap_or_else(|| super::time::utc(0).expect("the Unix epoch is representable"))
+ let mut result = std::mem::MaybeUninit::<libc::tm>::uninit();
+ // SAFETY: both pointers are valid for the duration of the call.
+ unsafe {
+ libc::gmtime_r(&timestamp, result.as_mut_ptr());
+ result.assume_init()
+ }
}
fn normalize(time: &mut libc::tm) {
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/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/diff.rs b/crates/gilti/src/views/diff.rs
index aedaf9d..30be8af 100644
--- a/crates/gilti/src/views/diff.rs
+++ b/crates/gilti/src/views/diff.rs
@@ -134,7 +134,7 @@ pub fn content(
}
pub fn content_controls(query: Query) -> Markup {
- html! { div class="gilti-panel" {
+ html! { div class="cgit-panel" {
b { "diff options" }
form method="get" { table {
tr { td colspan="2" {} }
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
index 46c6d4a..3cb192d 100644
--- a/crates/gilti/src/views/mod.rs
+++ b/crates/gilti/src/views/mod.rs
@@ -4,10 +4,8 @@
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;
@@ -35,7 +33,7 @@ pub(crate) fn bad_request(message: &'static str) -> axum::response::Response {
.expect("static response is valid")
}
-pub(crate) fn bytes_response(
+fn bytes_response(
content_type: &'static str,
content_disposition: Option<String>,
etag: Option<String>,
diff --git a/crates/gilti/src/views/repositories.rs b/crates/gilti/src/views/repositories.rs
index d692306..0722d5c 100644
--- a/crates/gilti/src/views/repositories.rs
+++ b/crates/gilti/src/views/repositories.rs
@@ -63,14 +63,14 @@ pub async fn serve(
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="stylesheet" type="text/css" href="/-/assets/cgit.css";
+ script type="text/javascript" src="/-/assets/cgit.js" {}
link rel="shortcut icon" href="/-/assets/favicon.ico";
}
- body { div id="gilti" {
+ body { div id="cgit" {
table id="header" {
tr {
- td class="logo" rowspan="2" { a href="/" { img src="/-/assets/gilti.png" alt="gilti logo"; } }
+ td class="logo" rowspan="2" { a href="/" { img src="/-/assets/cgit.png" alt="cgit logo"; } }
td class="main" { (&*context.root_title) }
}
tr { td class="sub" { (&*context.root_description) } }
diff --git a/crates/gilti/src/views/revision.rs b/crates/gilti/src/views/revision.rs
index f4466c5..7f086f0 100644
--- a/crates/gilti/src/views/revision.rs
+++ b/crates/gilti/src/views/revision.rs
@@ -96,8 +96,12 @@ fn content(model: &crate::models::commit::Commit, query: super::diff::Query) ->
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 mut value = std::mem::MaybeUninit::<libc::tm>::uninit();
+ // SAFETY: both pointers are valid for the duration of the call.
+ let value = unsafe {
+ libc::gmtime_r(&adjusted, value.as_mut_ptr());
+ value.assume_init()
+ };
let sign = if identity.offset_minutes < 0 {
'-'
} else {
diff --git a/crates/gilti/src/views/shared.rs b/crates/gilti/src/views/shared.rs
index d18d79c..198ecf7 100644
--- a/crates/gilti/src/views/shared.rs
+++ b/crates/gilti/src/views/shared.rs
@@ -61,14 +61,14 @@ pub fn render_titled(
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="stylesheet" type="text/css" href="/-/assets/cgit.css";
+ script type="text/javascript" src="/-/assets/cgit.js" {}
link rel="shortcut icon" href="/-/assets/favicon.ico";
}
- body { div id="gilti" {
+ body { div id="cgit" {
table id="header" {
tr {
- td class="logo" rowspan="2" { a href="/" { img src="/-/assets/gilti.png" alt="gilti logo"; } }
+ td class="logo" rowspan="2" { a href="/" { img src="/-/assets/cgit.png" alt="cgit logo"; } }
td class="main" { (&*context.root_title) " : " a href=(&repo) { (&repository.name) } }
}
tr { td class="sub" { (&repository.description) } }
diff --git a/crates/gilti/src/views/stats.rs b/crates/gilti/src/views/stats.rs
index 4c6de35..a15c716 100644
--- a/crates/gilti/src/views/stats.rs
+++ b/crates/gilti/src/views/stats.rs
@@ -75,7 +75,7 @@ fn content(model: &crate::models::stats::Stats, query: &Query) -> Markup {
.unwrap_or(model.authors.len())
.min(model.authors.len());
html! {
- div class="gilti-panel" {
+ div class="cgit-panel" {
b { "stat options" }
form method="get" { table {
tr { td colspan="2" {} }
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/tests/chart.sh b/tests/chart.sh
index 42f6910..b5df06b 100755
--- a/tests/chart.sh
+++ b/tests/chart.sh
@@ -28,15 +28,25 @@ 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 'name: GILTI_CGIT_CACHE' "$rendered"
+grep -q 'value: "5"' "$rendered"
+if grep -q 'cgitrc' "$rendered"; then
+ echo 'chart still provisions a cgit configuration file' >&2
+ exit 1
+fi
+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 +54,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..e883e20 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 \
@@ -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" test -e /etc/cgitrc; then
+ echo 'legacy cgit configuration file is installed' >&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 \
@@ -105,18 +118,18 @@ status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:$http
echo "POST to repository browser returned HTTP $status instead of 405" >&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/-/assets/cgit.css" | grep -q 'cgit'
+content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/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/-/assets/cgit.js" | grep -q 'function'
+content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/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
@@ -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
@@ -237,29 +250,6 @@ curl -fsS "http://127.0.0.1:$http_port/testing/+/HEAD/+/tree/README%2emd" | grep
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\.'
@@ -290,6 +280,17 @@ printf '%s' "$lfs_response" | grep -q '"code":404' || {
echo 'unexpected LFS batch response' >&2
exit 1
}
+cache_url="http://127.0.0.1:$http_port/testing/+/HEAD/+/log"
+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