Repositories / gitweb2.git
gitweb2.git
Clone (read-only): git clone http://git.guha-anderson.com/git/gitweb2.git
@@ -0,0 +1,13 @@ +.PHONY: all build test clean + +all: build + +build: + dune build + +test: + dune test + +clean: + dune clean + rm -rf .build
@@ -1,34 +1,37 @@ # gitweb2 -A Kotlin/Native, read-only web viewer for local Git repositories. +An OCaml, read-only web viewer for local Git repositories. -The app uses Ktor Native with the CIO engine for HTTP and the local -`/media/external0/arjun/repos/homebox/kotlin-git` library for repository reads. +The app uses `cohttp-eio` for HTTP and the local `ocaml-git` OCaml package for +repository reads. ## Requirements -- Kotlin/Native through the Gradle wrapper -- system `libgit2` visible through `pkg-config` -- the local `kotlin-git` klibs built at - `/media/external0/arjun/repos/homebox/kotlin-git/.build/klib` -- the included Gradle wrapper +- OCaml 5.4 or newer +- opam +- dune +- system `git` +- the local `ocaml-git` package installed or pinned ## Build +From this repository, after installing or pinning `ocaml-git`: + ```sh -./gradlew build +opam install . --deps-only --with-test +dune build ``` ## Run ```sh -./build/bin/native/releaseExecutable/gitweb2.kexe ~/repos +dune exec gitweb2 -- ~/repos ``` Options: ```sh -./build/bin/native/releaseExecutable/gitweb2.kexe ~/repos --host 127.0.0.1 --port 8080 --pygments "uv run --with pygments pygmentize" +dune exec gitweb2 -- ~/repos --host 127.0.0.1 --port 8080 --pygments "uv run --with pygments pygmentize" ``` Press `Ctrl+C` to stop the server. @@ -38,6 +41,6 @@ repository, branch, file, directory, and commit history pages. Nested repository paths and branch names that contain slashes are percent-encoded in URLs: ```text -/repo/homebox%2Fkotlin-git/main/README.md -/repo/homebox%2Fkotlin-git/main/-/commits +/repo/homebox%2Focaml-git/main/README.md +/repo/homebox%2Focaml-git/main/-/commits ```
@@ -1,68 +0,0 @@ -plugins { - kotlin("multiplatform") version "2.3.20" -} - -group = "gitweb" -version = "0.1.0" - -val ktorVersion = "3.4.2" -val okioVersion = "3.17.0" -val processVersion = "0.5.0" -val kotlinGitDir = providers.gradleProperty("kotlinGitDir") - .orElse("/media/external0/arjun/repos/homebox/kotlin-git") -val kotlinGitKlib = kotlinGitDir.map { file("$it/.build/klib/kotlin-git.klib") } -val libgit2Klib = kotlinGitDir.map { file("$it/.build/klib/libgit2.klib") } - -fun pkgConfig(vararg args: String): List<String> = - providers.exec { - commandLine("pkg-config", *args) - }.standardOutput.asText.get().trim().split(Regex("\\s+")).filter { it.isNotBlank() } - -fun pkgConfigValue(vararg args: String): String = - providers.exec { - commandLine("pkg-config", *args) - }.standardOutput.asText.get().trim() - -kotlin { - val hostOs = System.getProperty("os.name") - val arch = System.getProperty("os.arch") - val nativeTarget = when { - hostOs == "Linux" && (arch == "x86_64" || arch == "amd64") -> linuxX64("native") - hostOs == "Linux" && arch == "aarch64" -> linuxArm64("native") - else -> throw GradleException("Unsupported Kotlin/Native host: $hostOs $arch") - } - - nativeTarget.apply { - binaries { - all { - linkerOpts("-L${pkgConfigValue("--variable=libdir", "libgit2")}") - linkerOpts(pkgConfig("--libs", "libgit2")) - linkerOpts("-lutil") - } - executable { - baseName = "gitweb2" - entryPoint = "main" - } - } - } - - sourceSets { - val nativeMain by getting { - kotlin.srcDir("src/main/kotlin") - dependencies { - implementation("io.ktor:ktor-server-core:$ktorVersion") - implementation("io.ktor:ktor-server-cio:$ktorVersion") - implementation("com.squareup.okio:okio:$okioVersion") - implementation("io.matthewnelson.kmp-process:process:$processVersion") - implementation(files(kotlinGitKlib)) - implementation(files(libgit2Klib)) - } - } - val nativeTest by getting { - dependencies { - implementation(kotlin("test")) - implementation("io.ktor:ktor-server-test-host:$ktorVersion") - } - } - } -}
@@ -0,0 +1,15 @@ +(lang dune 3.22) +(name gitweb2) +(generate_opam_files true) + +(package + (name gitweb2) + (synopsis "Read-only local Git web viewer") + (depends + (ocaml (>= 5.4)) + dune + ocaml-git + cohttp-eio + eio_main + uri + alcotest))
@@ -0,0 +1,28 @@ +# This file is generated by dune, edit dune-project instead +opam-version: "2.0" +synopsis: "Read-only local Git web viewer" +depends: [ + "ocaml" {>= "5.4"} + "dune" {>= "3.22"} + "ocaml-git" + "cohttp-eio" + "eio_main" + "uri" + "alcotest" + "odoc" {with-doc} +] +build: [ + ["dune" "subst"] {dev} + [ + "dune" + "build" + "-p" + name + "-j" + jobs + "@install" + "@runtest" {with-test} + "@doc" {with-doc} + ] +] +x-maintenance-intent: ["(latest)"]
@@ -1,3 +0,0 @@ -org.gradle.parallel=true -kotlin.code.style=official -kotlin.mpp.applyDefaultHierarchyTemplate=false
@@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists
@@ -1,248 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@"
@@ -1,93 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega
@@ -0,0 +1,10 @@ +# gitweb2 service + +The service file runs the OCaml `gitweb2` executable. Build the project first: + +```sh +dune build +``` + +Then point `ExecStart` at the built executable or an installed `gitweb2` on +`PATH`.
@@ -0,0 +1,16 @@ +[Unit] +Description=gitweb2 OCaml Git repository browser +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=1 + +[Service] +Type=simple +WorkingDirectory=/home/arjun/repos/homebox/gitweb2 +ExecStart=/usr/bin/env jai -j agents dune exec gitweb2 -- /home/git/repos --host 0.0.0.0 --port 8002 --pygments "uv run --with pygments pygmentize" +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target
@@ -1,15 +0,0 @@ -pluginManagement { - repositories { - gradlePluginPortal() - mavenCentral() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - mavenCentral() - } -} - -rootProject.name = "gitweb2"
@@ -0,0 +1,11 @@ +(library + (name gitweb2) + (public_name gitweb2) + (modules gitweb2) + (libraries ocaml-git unix uri cohttp-eio eio_main)) + +(executable + (name main) + (public_name gitweb2) + (modules main) + (libraries gitweb2))
@@ -0,0 +1,406 @@ +type response = { + status : int; + body : string; +} + +type config = { + root : string; + host : string; + port : int; + pygments_command : string; +} + +type repo_info = { + key : string; + name : string; + path : string; +} + +type repo_summary = { + branch : string; + summary : string; + short_id : string; +} + +let default_host = "127.0.0.1" +let default_port = 8080 +let default_pygments_command = "pygmentize" +let max_scan_depth = 5 + +let html value = + let buffer = Buffer.create (String.length value) in + String.iter + (function + | '&' -> Buffer.add_string buffer "&" + | '<' -> Buffer.add_string buffer "<" + | '>' -> Buffer.add_string buffer ">" + | '"' -> Buffer.add_string buffer """ + | '\'' -> Buffer.add_string buffer "'" + | char -> Buffer.add_char buffer char) + value; + Buffer.contents buffer + +let url_encode value = Uri.pct_encode value +let url_decode value = Uri.pct_decode value + +let join_path base child = + match (base, child) with + | "", child -> child + | base, "" -> base + | base, child when String.ends_with ~suffix:"/" base -> base ^ child + | base, child -> base ^ "/" ^ child + +let split_path path = String.split_on_char '/' path |> List.filter (( <> ) "") + +let command_exists command = + let executable = String.trim command |> String.split_on_char ' ' |> List.hd in + if executable = "" then false + else if String.contains executable '/' then Sys.file_exists executable + else + match Sys.getenv_opt "PATH" with + | None -> false + | Some paths -> + String.split_on_char ':' paths + |> List.exists (fun dir -> Sys.file_exists (join_path dir executable)) + +let read_all channel = + let buffer = Buffer.create 4096 in + let bytes = Bytes.create 4096 in + let rec loop () = + match input channel bytes 0 (Bytes.length bytes) with + | 0 -> Buffer.contents buffer + | n -> + Buffer.add_subbytes buffer bytes 0 n; + loop () + in + loop () + +let read_command_output ?input command = + let stdout, stdin, stderr = Unix.open_process_full command (Unix.environment ()) in + Option.iter (output_string stdin) input; + close_out stdin; + let out = read_all stdout in + let err = read_all stderr in + ignore err; + match Unix.close_process_full (stdout, stdin, stderr) with + | Unix.WEXITED 0 -> Some (String.map (function '\r' -> '\n' | c -> c) out) + | _ -> None + +let shell_quote = Filename.quote + +let file_render ?(pygments_command = default_pygments_command) ~file_path text = + let plain () = "<pre><code>" ^ html text ^ "</code></pre>" in + if not (command_exists pygments_command) then plain () + else + let lexer = + read_command_output (pygments_command ^ " -N " ^ shell_quote file_path ^ " 2>/dev/null") + |> Option.map String.trim + in + let lexer_option = + match lexer with + | Some lexer when lexer <> "" -> "-l " ^ shell_quote lexer + | _ -> "-g" + in + let command = pygments_command ^ " -f html -O nowrap,noclasses=True " ^ lexer_option ^ " 2>/dev/null" in + match read_command_output ~input:text command with + | Some highlighted when String.trim highlighted <> "" -> "<div class=\"highlight\">" ^ highlighted ^ "</div>" + | _ -> plain () + +let is_readme name = name = "README.md" || name = "README.txt" || name = "README" + +let page title body = + "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\ + <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>" + ^ html title + ^ " · gitweb2</title>\n<style>\n\ + :root { color-scheme: light; --ink: #1f2428; --muted: #586069; --line: #d0d7de; --wash: #f6f8fa; --link: #0969da; --accent: #1a7f37; }\n\ + * { box-sizing: border-box; }\n\ + body { margin: 0; font: 15px/1.5 -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; color: var(--ink); background: #fff; }\n\ + a { color: var(--link); text-decoration: none; } a:hover { text-decoration: underline; }\n\ + main { max-width: 1180px; margin: 0 auto; padding: 28px 20px 56px; }\n\ + .hero, .repo-head { border-bottom: 1px solid var(--line); margin-bottom: 20px; padding-bottom: 16px; }\n\ + .eyebrow, .repo-head p, .file-meta, .kind, small, .commits span, .repo-list span { color: var(--muted); }\n\ + h1 { margin: 4px 0 8px; font-size: 28px; line-height: 1.2; overflow-wrap: anywhere; }\n\ + h2 { margin: 0 0 10px; font-size: 20px; line-height: 1.25; overflow-wrap: anywhere; }\n\ + .repo-list, .commits { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }\n\ + .repo-list li, .commits li { border-top: 1px solid var(--line); } .repo-list li:first-child, .commits li:first-child { border-top: 0; }\n\ + .repo-list a, .commits li { display: grid; gap: 3px; padding: 14px 16px; }\n\ + .repo-list strong, .commits strong { font-size: 16px; overflow-wrap: anywhere; } .repo-list small, .commits span { display: block; overflow-wrap: anywhere; }\n\ + .panel { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }\n\ + table { width: 100%; border-collapse: collapse; } td { padding: 10px 12px; border-top: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; }\n\ + tr:first-child td { border-top: 0; } .kind { width: 120px; text-align: right; }\n\ + .toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0; } .toolbar nav, .tabs { display: flex; gap: 8px; flex-wrap: wrap; }\n\ + .toolbar a, .tabs a { border: 1px solid var(--line); border-radius: 8px; padding: 5px 9px; color: var(--ink); background: #fff; }\n\ + .toolbar a.active, .tabs a.active { border-color: var(--accent); color: var(--accent); font-weight: 600; }\n\ + .file pre { margin: 0; padding: 16px; overflow: auto; background: var(--wash); } .file code { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; }\n\ + .highlight { margin: 0; padding: 16px; overflow: auto; background: var(--wash); } .highlight pre { margin: 0; padding: 0; background: transparent; }\n\ + .highlight, .highlight pre { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; }\n\ + .readme { margin-top: 24px; } .file-meta { padding: 10px 12px; border-bottom: 1px solid var(--line); background: #fff; }\n\ + .notice { padding: 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--wash); }\n\ + @media (max-width: 640px) { main { padding: 20px 12px 40px; } h1 { font-size: 23px; } .kind { width: 78px; } }\n\ + </style>\n</head>\n<body><main>" + ^ body ^ "</main></body>\n</html>" + +let normalize_root root = + let expanded = + if root = "~" then Sys.getenv_opt "HOME" |> Option.value ~default:root + else if String.starts_with ~prefix:"~/" root then + match Sys.getenv_opt "HOME" with + | Some home -> home ^ "/" ^ String.sub root 2 (String.length root - 2) + | None -> root + else root + in + try Unix.realpath expanded |> String.trim with _ -> String.trim expanded + +let is_git_repo path = + Sys.file_exists (join_path path ".git") + || (Sys.file_exists (join_path path "HEAD") && Sys.file_exists (join_path path "objects") + && Sys.file_exists (join_path path "refs")) + +let is_openable_git_repo path = + try Ocaml_git.with_repo path (fun _ -> true) with _ -> false + +let discover_repos root = + let root = normalize_root root in + let found = ref [] in + let rec scan path relative depth = + if is_git_repo path then ( + if is_openable_git_repo path then + let key = if relative = "" then Filename.basename path else relative in + found := { key; name = key; path } :: !found) + else if depth < max_scan_depth then + try + Sys.readdir path + |> Array.to_list + |> List.iter (fun name -> + if name <> "." && name <> ".." && name <> ".git" then + scan (join_path path name) (if relative = "" then name else relative ^ "/" ^ name) (depth + 1)) + with _ -> () + in + scan root "" 0; + List.sort (fun a b -> String.compare a.name b.name) !found + +let local_branches repo = Ocaml_git.branches repo |> List.filter (fun (b : Ocaml_git.branch) -> b.kind = Ocaml_git.Local) + +let repo_url repo branch path = + let prefix = "/repo/" ^ url_encode repo.key ^ "/" ^ url_encode branch in + if path = "" then prefix else prefix ^ "/" ^ (split_path path |> List.map url_encode |> String.concat "/") + +let repo_summary repo = + try + Ocaml_git.with_repo repo.path @@ fun git -> + let branches = local_branches git in + let branch = + match List.find_opt (fun b -> b.Ocaml_git.is_head) branches with + | Some b -> b.name + | None -> ( + match branches with + | b :: _ -> b.name + | [] -> "main") + in + let commit = try Some (Ocaml_git.branch_commit git branch) with _ -> None in + { + branch; + summary = (match commit with Some c -> c.summary | None -> "No commits"); + short_id = (match commit with Some c -> String.sub c.id 0 (min 12 (String.length c.id)) | None -> ""); + } + with _ -> { branch = "main"; summary = "Unavailable"; short_id = "" } + +let entries_for_display entries = + List.sort (fun (a : Ocaml_git.tree_entry) b -> String.compare (String.lowercase_ascii a.name) (String.lowercase_ascii b.name)) entries + +let rec repo_header repo branch path branches selected = + let buffer = Buffer.create 512 in + Buffer.add_string buffer ("<section class=\"repo-head\"><p><a href=\"/\">Repositories</a> / " ^ html repo.name ^ "</p>"); + Buffer.add_string buffer ("<h1>" ^ html (if path = "" then repo.name else path) ^ "</h1>"); + Buffer.add_string buffer "<div class=\"toolbar\"><span>Branch</span><nav>"; + List.iter + (fun (item : Ocaml_git.branch) -> + let active = if item.name = branch then " class=\"active\"" else "" in + Buffer.add_string buffer ("<a" ^ active ^ " href=\"" ^ repo_url repo item.name path ^ "\">" ^ html item.name ^ "</a>")) + branches; + Buffer.add_string buffer "</nav></div><div class=\"tabs\">"; + let tab id label href = + let active = if id = selected then " class=\"active\"" else "" in + Buffer.add_string buffer ("<a" ^ active ^ " href=\"" ^ href ^ "\">" ^ label ^ "</a>") + in + tab "code" "Code" (repo_url repo branch path); + tab "history" "History" ("/repo/" ^ url_encode repo.key ^ "/" ^ url_encode branch ^ "/-/commits"); + Buffer.add_string buffer "</div></section>"; + Buffer.contents buffer + +let tree_view ~pygments_command repo git branch path branches = + let commit = Ocaml_git.branch_commit git branch in + let listing = Ocaml_git.tree ~commit ~path git () in + let buffer = Buffer.create 4096 in + Buffer.add_string buffer (repo_header repo branch path branches "code"); + Buffer.add_string buffer "<div class=\"panel\"><table><tbody>"; + if path <> "" then ( + let parent = try Filename.dirname path with _ -> "" in + let parent = if parent = "." then "" else parent in + Buffer.add_string buffer ("<tr><td><a href=\"" ^ repo_url repo branch parent ^ "\">..</a></td><td class=\"kind\">tree</td></tr>") + ) else (); + let display_entries = entries_for_display listing.entries in + List.iter + (fun (entry : Ocaml_git.tree_entry) -> + let child = join_path path entry.name in + Buffer.add_string buffer ("<tr><td><a href=\"" ^ repo_url repo branch child ^ "\">" ^ html entry.name ^ "</a></td>"); + let kind = + match entry.kind with Blob -> "blob" | Tree -> "tree" | Commit -> "commit" | Tag -> "tag" | Other -> "other" + in + Buffer.add_string buffer ("<td class=\"kind\">" ^ kind ^ "</td></tr>")) + display_entries; + Buffer.add_string buffer "</tbody></table></div>"; + (match List.find_opt (fun (entry : Ocaml_git.tree_entry) -> is_readme entry.name && entry.kind = Blob) display_entries with + | None -> () + | Some readme -> + let readme_path = join_path path readme.name in + let blob = Ocaml_git.blob ~commit git readme_path in + if not (Ocaml_git.blob_is_binary blob) then ( + Buffer.add_string buffer "<section class=\"readme\">"; + Buffer.add_string buffer ("<h2>" ^ html readme.name ^ "</h2><div class=\"panel file\">"); + Buffer.add_string buffer (file_render ~pygments_command ~file_path:(join_path repo.path readme_path) (Ocaml_git.blob_text blob)); + Buffer.add_string buffer "</div></section>") + else ()); + Buffer.contents buffer + +let blob_view ~pygments_command repo blob branch path branches = + let buffer = Buffer.create 4096 in + Buffer.add_string buffer (repo_header repo branch path branches "code"); + Buffer.add_string buffer "<div class=\"panel file\">"; + Buffer.add_string buffer + ("<div class=\"file-meta\">" ^ string_of_int (String.length blob.Ocaml_git.bytes) ^ " bytes · " + ^ html (String.sub blob.id 0 (min 12 (String.length blob.id))) ^ "</div>"); + if Ocaml_git.blob_is_binary blob then Buffer.add_string buffer "<p class=\"notice\">Binary file</p>" + else Buffer.add_string buffer (file_render ~pygments_command ~file_path:(join_path repo.path path) (Ocaml_git.blob_text blob)); + Buffer.add_string buffer "</div>"; + Buffer.contents buffer + +let commits_page repo branch = + Ocaml_git.with_repo repo.path @@ fun git -> + let buffer = Buffer.create 4096 in + Buffer.add_string buffer (repo_header repo branch "" (local_branches git) "history"); + Buffer.add_string buffer "<ol class=\"commits\">"; + List.iter + (fun (commit : Ocaml_git.commit) -> + Buffer.add_string buffer + ("<li><strong>" ^ html commit.summary ^ "</strong><span>" + ^ html (String.sub commit.id 0 (min 12 (String.length commit.id))) + ^ " · " ^ html commit.author.name ^ "</span></li>")) + (Ocaml_git.commits ~limit:100 git branch); + Buffer.add_string buffer "</ol>"; + page (branch ^ " history") (Buffer.contents buffer) + +let repo_page ~pygments_command repo branch path = + Ocaml_git.with_repo repo.path @@ fun git -> + let branches = local_branches git in + let commit = Ocaml_git.branch_commit git branch in + let entry = if path = "" then None else try Some (Ocaml_git.tree_entry ~commit git path) with _ -> None in + let content = + match entry with + | None | Some { kind = Tree; _ } -> tree_view ~pygments_command repo git branch path branches + | Some _ -> blob_view ~pygments_command repo (Ocaml_git.blob ~commit git path) branch path branches + in + page repo.name content + +let not_found message = { status = 404; body = page "Not found" ("<p class=\"notice\">" ^ message ^ "</p>") } + +let route ?(pygments_command = default_pygments_command) ~root raw_path = + let repos = discover_repos root in + let repo_by_key key = List.find_opt (fun repo -> repo.key = key) repos in + let parts = split_path raw_path in + try + match parts with + | [] -> + let buffer = Buffer.create 4096 in + Buffer.add_string buffer ("<section class=\"hero\"><p class=\"eyebrow\">" ^ html root ^ "</p><h1>Repositories</h1></section>"); + if repos = [] then Buffer.add_string buffer "<p class=\"notice\">No Git repositories were found under this path.</p>" + else ( + Buffer.add_string buffer "<ol class=\"repo-list\">"; + List.iter + (fun repo -> + let summary = repo_summary repo in + Buffer.add_string buffer ("<li><a href=\"" ^ repo_url repo summary.branch "" ^ "\"><strong>" ^ html repo.name ^ "</strong>"); + Buffer.add_string buffer ("<span>" ^ html summary.branch); + if summary.short_id <> "" then Buffer.add_string buffer (" · " ^ html summary.short_id); + Buffer.add_string buffer ("</span><small>" ^ html summary.summary ^ "</small></a></li>")) + repos; + Buffer.add_string buffer "</ol>"); + { status = 200; body = page "Repositories" (Buffer.contents buffer) } + | "repo" :: repo_key :: rest -> ( + let repo_key = url_decode repo_key in + match repo_by_key repo_key with + | None -> not_found ("Unknown repository " ^ html repo_key) + | Some repo -> ( + match rest with + | [] -> + let branch = + Ocaml_git.with_repo repo.path @@ fun git -> + match List.find_opt (fun b -> b.Ocaml_git.is_head) (local_branches git) with + | Some b -> b.name + | None -> ( + match local_branches git with + | b :: _ -> b.name + | [] -> "main") + in + { status = 200; body = repo_page ~pygments_command repo branch "" } + | branch :: "-" :: "commits" :: [] -> { status = 200; body = commits_page repo (url_decode branch) } + | branch :: path_parts -> + let path = path_parts |> List.map url_decode |> String.concat "/" in + { status = 200; body = repo_page ~pygments_command repo (url_decode branch) path })) + | _ -> not_found ("No route for " ^ html raw_path) + with + | Ocaml_git.Git_error message -> { status = 404; body = page "Not found" ("<p class=\"notice\">" ^ html message ^ "</p>") } + | exn -> { status = 500; body = page "Server error" ("<p class=\"notice\">" ^ html (Printexc.to_string exn) ^ "</p>") } + +let start config = + Eio_main.run @@ fun env -> + Eio.Switch.run @@ fun sw -> + let net = Eio.Stdenv.net env in + let ipaddr = + match config.host with + | "127.0.0.1" | "localhost" -> Eio.Net.Ipaddr.V4.loopback + | "0.0.0.0" -> Eio.Net.Ipaddr.V4.any + | "::1" -> Eio.Net.Ipaddr.V6.loopback + | "::" -> Eio.Net.Ipaddr.V6.any + | other -> invalid_arg ("unsupported listen host: " ^ other) + in + let sockaddr = `Tcp (ipaddr, config.port) in + let socket = Eio.Net.listen ~sw ~reuse_addr:true ~backlog:128 net sockaddr in + Printf.printf "gitweb2 serving repositories from %s at http://%s:%d/\n%!" config.root config.host config.port; + let server = + Cohttp_eio.Server.make + ~callback:(fun _conn request _body -> + let path = Cohttp.Request.resource request in + let response = route ~pygments_command:config.pygments_command ~root:config.root path in + let headers = Cohttp.Header.init_with "Cache-Control" "no-store" |> fun h -> Cohttp.Header.add h "Content-Type" "text/html; charset=utf-8" in + Cohttp_eio.Server.respond_string ~headers ~status:(`Code response.status) ~body:response.body ()) + () + in + Cohttp_eio.Server.run socket server ~on_error:(fun exn -> prerr_endline (Printexc.to_string exn)) + +let usage = "usage: gitweb2 <repo-root> [--host 127.0.0.1] [--port 8080] [--pygments pygmentize]" + +let parse_args argv = + let args = Array.to_list argv |> List.tl in + match args with + | [] | ("-h" | "--help") :: _ -> Error usage + | root :: rest -> + let rec loop host port pygments = function + | [] -> Ok { root; host; port; pygments_command = pygments } + | "--host" :: value :: tail -> loop value port pygments tail + | "--port" :: value :: tail -> ( + match int_of_string_opt value with + | Some port -> loop host port pygments tail + | None -> Error "missing or invalid --port value") + | "--pygments" :: value :: tail -> loop host port value tail + | flag :: _ -> Error ("unknown argument " ^ flag) + in + loop default_host default_port default_pygments_command rest + +let run argv = + match parse_args argv with + | Ok config -> start config + | Error message -> + prerr_endline message; + if message <> usage then exit 2
@@ -0,0 +1,20 @@ +type response = { + status : int; + body : string; +} + +type config = { + root : string; + host : string; + port : int; + pygments_command : string; +} + +val default_host : string +val default_port : int +val default_pygments_command : string +val parse_args : string array -> (config, string) result +val route : ?pygments_command:string -> root:string -> string -> response +val start : config -> unit +val file_render : ?pygments_command:string -> file_path:string -> string -> string +val run : string array -> unit
@@ -0,0 +1 @@ +let () = Gitweb2.run Sys.argv
@@ -1,3 +0,0 @@ -fun main(args: Array<String>) { - gitweb.runGitWeb2(args) -}
@@ -1,513 +0,0 @@ -package gitweb - -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import io.ktor.http.withCharset -import io.ktor.server.application.call -import io.ktor.server.cio.CIO -import io.ktor.server.engine.embeddedServer -import io.ktor.server.request.path -import io.ktor.server.response.header -import io.ktor.server.response.respondText -import io.ktor.server.routing.get -import io.ktor.server.routing.routing -import io.ktor.utils.io.charsets.Charsets -import io.matthewnelson.kmp.process.Process as KmpProcess -import kotlinx.git.Blob -import kotlinx.git.Branch -import kotlinx.git.BranchType -import kotlinx.git.Git -import kotlinx.git.GitException -import kotlinx.git.Repository -import kotlinx.git.TreeEntryKind -import okio.FileSystem -import okio.Path.Companion.toPath -import kotlin.system.exitProcess - -private const val DefaultHost = "127.0.0.1" -private const val DefaultPort = 8080 -private const val MaxScanDepth = 5 -private const val DefaultPygmentsCommand = "pygmentize" - -fun runGitWeb2(args: Array<String>) { - val config = parseArgs(args) ?: return - GitWebServer(config.root, config.host, config.port, config.pygmentsCommand).start() -} - -private data class Config(val root: String, val host: String, val port: Int, val pygmentsCommand: String) - -private data class RepoInfo(val key: String, val name: String, val path: String) - -internal data class Response(val status: Int, val body: String) - -internal class GitWebServer( - private val root: String, - private val host: String, - private val port: Int, - private val pygmentsCommand: String = DefaultPygmentsCommand, -) { - private val repos: List<RepoInfo> = discoverRepos(root) - private val reposByKey: Map<String, RepoInfo> = repos.associateBy { it.key } - private val fileRenderer = FileRenderer(pygmentsCommand) - - fun start() { - println("gitweb2 serving ${repos.size} repositories from $root at http://$host:$port/") - val server = embeddedServer(CIO, host = host, port = port) { - routing { - get("{...}") { - val response = route(call.request.path()) - call.response.header("Cache-Control", "no-store") - call.respondText( - text = response.body, - contentType = ContentType.Text.Html.withCharset(Charsets.UTF_8), - status = HttpStatusCode.fromValue(response.status), - ) - } - } - } - try { - withTerminateSignalsHandled(onTerminate = { server.stop(500, 1000) }) { - server.start(wait = true) - } - } finally { - server.stop() - Git.shutdown() - } - } - - fun route(rawPath: String): Response { - val parts = rawPath.split('/').filter { it.isNotEmpty() } - return try { - when { - parts.isEmpty() -> index() - parts.size >= 2 && parts[0] == "repo" -> repo(parts.drop(1)) - else -> notFound("No route for ${html(rawPath)}") - } - } catch (error: GitException) { - Response(404, page("Not found", "<p class=\"notice\">${html(error.message ?: "Git error")}</p>")) - } catch (error: Throwable) { - Response(500, page("Server error", "<p class=\"notice\">${html(error.message ?: "Unexpected error")}</p>")) - } - } - - private fun index(): Response { - val body = buildString { - append("<section class=\"hero\"><p class=\"eyebrow\">${html(root)}</p><h1>Repositories</h1></section>") - if (repos.isEmpty()) { - append("<p class=\"notice\">No Git repositories were found under this path.</p>") - return@buildString - } - append("<ol class=\"repo-list\">") - for (repo in repos) { - val summary = repoSummary(repo) - append("<li><a href=\"${repoUrl(repo, summary.branch)}\"><strong>${html(repo.name)}</strong>") - append("<span>${html(summary.branch)}") - if (summary.shortId.isNotEmpty()) append(" · ${html(summary.shortId)}") - append("</span><small>${html(summary.summary)}</small></a></li>") - } - append("</ol>") - } - return Response(200, page("Repositories", body)) - } - - private fun repoSummary(repo: RepoInfo): RepoSummary = - runCatching { - openRepo(repo) { git -> - val branches = git.localBranches() - val head = branches.firstOrNull { it.isHead }?.name ?: branches.firstOrNull()?.name ?: "main" - val commit = runCatching { git.branchCommit(head) }.getOrNull() - RepoSummary(head, commit?.summary ?: "No commits", commit?.id?.value?.take(12) ?: "") - } - }.getOrElse { - RepoSummary("main", "Unavailable", "") - } - - private fun repo(parts: List<String>): Response { - val repoKey = urlDecode(parts[0]) - val repo = reposByKey[repoKey] ?: return notFound("Unknown repository ${html(repoKey)}") - if (parts.size == 1) { - val branch = openRepo(repo) { git -> - val branches = git.localBranches() - branches.firstOrNull { it.isHead }?.name ?: branches.firstOrNull()?.name ?: "main" - } - return Response(200, repoPage(repo, branch, "")) - } - val branch = urlDecode(parts[1]) - val pathParts = parts.drop(2) - if (pathParts.size == 2 && pathParts[0] == "-" && pathParts[1] == "commits") { - return Response(200, commitsPage(repo, branch)) - } - val path = pathParts.joinToString("/") { urlDecode(it) } - return Response(200, repoPage(repo, branch, path)) - } - - private fun repoPage(repo: RepoInfo, branch: String, path: String): String = openRepo(repo) { git -> - val branches = git.localBranches() - val commit = git.branchCommit(branch) - val entry = if (path.isBlank()) null else runCatching { git.treeEntry(path, commit) }.getOrNull() - val content = if (entry == null || entry.kind == TreeEntryKind.Tree) { - treeView(repo, git, branch, path, branches) - } else { - blobView(repo, git.blob(path, commit), branch, path, branches) - } - page(repo.name, content) - } - - private fun treeView( - repo: RepoInfo, - git: Repository, - branch: String, - path: String, - branches: List<Branch>, - ): String { - val commit = git.branchCommit(branch) - val listing = git.tree(path, commit) - return buildString { - append(repoHeader(repo, branch, path, branches, "code")) - append("<div class=\"panel\"><table><tbody>") - val parent = path.substringBeforeLast('/', "") - if (path.isNotBlank()) { - append("<tr><td><a href=\"${repoUrl(repo, branch, parent)}\">..</a></td><td class=\"kind\">tree</td></tr>") - } - for (entry in listing.entries.entriesForDisplay()) { - val child = joinPath(path, entry.name) - append("<tr><td><a href=\"${repoUrl(repo, branch, child)}\">${html(entry.name)}</a></td>") - append("<td class=\"kind\">${entry.kind.name.lowercase()}</td></tr>") - } - append("</tbody></table></div>") - val readme = listing.entries.entriesForDisplay().firstOrNull { isReadme(it.name) && it.kind == TreeEntryKind.Blob } - if (readme != null) { - val readmePath = joinPath(path, readme.name) - val blob = git.blob(readmePath, commit) - if (!blob.isBinary) { - append("<section class=\"readme\">") - append("<h2>${html(readme.name)}</h2>") - append("<div class=\"panel file\">") - append(fileRenderer.render(joinPath(repo.path, readmePath), blob.text())) - append("</div>") - append("</section>") - } - } - } - } - - private fun blobView( - repo: RepoInfo, - blob: Blob, - branch: String, - path: String, - branches: List<Branch>, - ): String = buildString { - append(repoHeader(repo, branch, path, branches, "code")) - append("<div class=\"panel file\">") - append("<div class=\"file-meta\">${blob.bytes.size} bytes · ${html(blob.id.value.take(12))}</div>") - if (blob.isBinary) { - append("<p class=\"notice\">Binary file</p>") - } else { - append(fileRenderer.render(joinPath(repo.path, path), blob.text())) - } - append("</div>") - } - - private fun commitsPage(repo: RepoInfo, branch: String): String = openRepo(repo) { git -> - buildString { - append(repoHeader(repo, branch, "", git.localBranches(), "history")) - append("<ol class=\"commits\">") - for (commit in git.commits(branch, 100)) { - append("<li><strong>${html(commit.summary)}</strong>") - append("<span>${html(commit.id.value.take(12))} · ${html(commit.author.name)}</span>") - append("</li>") - } - append("</ol>") - }.let { page("$branch history", it) } - } - - private fun repoHeader( - repo: RepoInfo, - branch: String, - path: String, - branches: List<Branch>, - selected: String, - ): String = buildString { - append("<section class=\"repo-head\"><p><a href=\"/\">Repositories</a> / ${html(repo.name)}</p>") - append("<h1>${html(if (path.isBlank()) repo.name else path)}</h1>") - append("<div class=\"toolbar\"><span>Branch</span><nav>") - for (item in branches) { - val active = if (item.name == branch) " class=\"active\"" else "" - append("<a$active href=\"${repoUrl(repo, item.name, path)}\">${html(item.name)}</a>") - } - append("</nav></div><div class=\"tabs\">") - tab("code", "Code", repoUrl(repo, branch, path), selected) - tab("history", "History", "/repo/${urlEncode(repo.key)}/${urlEncode(branch)}/-/commits", selected) - append("</div></section>") - } - - private fun StringBuilder.tab(id: String, label: String, href: String, selected: String) { - val active = if (id == selected) " class=\"active\"" else "" - append("<a$active href=\"$href\">$label</a>") - } - - private fun <T> openRepo(repo: RepoInfo, block: (Repository) -> T): T = - Git.open(repo.path).use(block) - - private fun notFound(message: String): Response = - Response(404, page("Not found", "<p class=\"notice\">$message</p>")) - - private data class RepoSummary(val branch: String, val summary: String, val shortId: String) -} - -private fun page(title: String, body: String): String = """ -<!doctype html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1"> -<title>${html(title)} · gitweb2</title> -<style> -:root { color-scheme: light; --ink: #1f2428; --muted: #586069; --line: #d0d7de; --wash: #f6f8fa; --link: #0969da; --accent: #1a7f37; } -* { box-sizing: border-box; } -body { margin: 0; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: #fff; } -a { color: var(--link); text-decoration: none; } -a:hover { text-decoration: underline; } -main { max-width: 1180px; margin: 0 auto; padding: 28px 20px 56px; } -.hero, .repo-head { border-bottom: 1px solid var(--line); margin-bottom: 20px; padding-bottom: 16px; } -.eyebrow, .repo-head p, .file-meta, .kind, small, .commits span, .repo-list span { color: var(--muted); } -h1 { margin: 4px 0 8px; font-size: 28px; line-height: 1.2; overflow-wrap: anywhere; } -h2 { margin: 0 0 10px; font-size: 20px; line-height: 1.25; overflow-wrap: anywhere; } -.repo-list, .commits { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } -.repo-list li, .commits li { border-top: 1px solid var(--line); } -.repo-list li:first-child, .commits li:first-child { border-top: 0; } -.repo-list a, .commits li { display: grid; gap: 3px; padding: 14px 16px; } -.repo-list strong, .commits strong { font-size: 16px; overflow-wrap: anywhere; } -.repo-list small, .commits span { display: block; overflow-wrap: anywhere; } -.panel { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } -table { width: 100%; border-collapse: collapse; } -td { padding: 10px 12px; border-top: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; } -tr:first-child td { border-top: 0; } -.kind { width: 120px; text-align: right; } -.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0; } -.toolbar nav, .tabs { display: flex; gap: 8px; flex-wrap: wrap; } -.toolbar a, .tabs a { border: 1px solid var(--line); border-radius: 8px; padding: 5px 9px; color: var(--ink); background: #fff; } -.toolbar a.active, .tabs a.active { border-color: var(--accent); color: var(--accent); font-weight: 600; } -.file pre { margin: 0; padding: 16px; overflow: auto; background: var(--wash); } -.file code { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; } -.highlight { margin: 0; padding: 16px; overflow: auto; background: var(--wash); } -.highlight pre { margin: 0; padding: 0; background: transparent; } -.highlight, .highlight pre { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; } -.readme { margin-top: 24px; } -.file-meta { padding: 10px 12px; border-bottom: 1px solid var(--line); background: #fff; } -.notice { padding: 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--wash); } -@media (max-width: 640px) { - main { padding: 20px 12px 40px; } - h1 { font-size: 23px; } - .kind { width: 78px; } -} -</style> -</head> -<body><main>$body</main></body> -</html> -""".trimIndent() - -internal class FileRenderer(private val pygmentsCommand: String = DefaultPygmentsCommand) { - fun render(filePath: String, text: String): String = - renderWithPygments(filePath, text) ?: plain(text) - - private fun renderWithPygments(filePath: String, text: String): String? { - if (!commandExists(pygmentsCommand)) return null - val lexer = readCommandOutput("$pygmentsCommand -N ${shellQuote(filePath)} 2>/dev/null")?.trim() - val lexerOption = if (lexer.isNullOrBlank()) "-g" else "-l ${shellQuote(lexer)}" - val command = "$pygmentsCommand -f html -O nowrap,noclasses=True $lexerOption 2>/dev/null" - val highlighted = readCommandOutput(command, text) ?: return null - if (highlighted.isBlank()) return null - return "<div class=\"highlight\">$highlighted</div>" - } - - private fun plain(text: String): String = - "<pre><code>${html(text)}</code></pre>" -} - -private fun List<kotlinx.git.TreeEntry>.entriesForDisplay(): List<kotlinx.git.TreeEntry> = - sortedBy { it.name.lowercase() } - -private fun isReadme(name: String): Boolean = - name == "README.md" || name == "README.txt" || name == "README" - -private fun shellQuote(value: String): String = - "'" + value.replace("'", "'\"'\"'") + "'" - -private fun commandExists(command: String): Boolean { - val executable = command.trim().substringBefore(' ') - if (executable.isBlank()) return false - if ('/' in executable) return pathExists(executable) - return KmpProcess.Current.environment()["PATH"] - ?.split(':') - ?.any { pathExists(joinPath(it, executable)) } == true -} - -private fun readCommandOutput(command: String, input: String? = null): String? { - val output = runCatching { - KmpProcess.Builder("/bin/sh") - .args("-c", command) - .createOutput { - timeoutMillis = 10_000 - if (input != null) inputUtf8 { input } - } - }.getOrNull() ?: return null - if (output.processError != null || output.processInfo.exitCode != 0) return null - return output.stdout.replace("\r\n", "\n").replace('\r', '\n') -} - -private fun discoverRepos(root: String): List<RepoInfo> { - val fileSystem = FileSystem.SYSTEM - val found = mutableListOf<RepoInfo>() - fun scan(path: String, relative: String, depth: Int) { - if (isGitRepo(path)) { - if (isOpenableGitRepo(path)) { - val key = relative.ifBlank { path.trimEnd('/').substringAfterLast('/') } - found += RepoInfo(key = key, name = key, path = path) - } - return - } - if (depth >= MaxScanDepth) return - val entries = runCatching { fileSystem.list(path.toPath()) }.getOrElse { return } - for (entry in entries) { - val name = entry.name - if (name == "." || name == ".." || name == ".git") continue - val child = entry.toString() - val childRelative = if (relative.isBlank()) name else "$relative/$name" - scan(child, childRelative, depth + 1) - } - } - scan(normalizeRoot(root), "", 0) - return found.sortedBy { it.name } -} - -private fun Repository.localBranches(): List<Branch> = - branches().filter { it.type == BranchType.Local } - -private fun isGitRepo(path: String): Boolean = - pathExists(joinPath(path, ".git")) || - pathExists(joinPath(path, "HEAD")) && - pathExists(joinPath(path, "objects")) && - pathExists(joinPath(path, "refs")) - -private fun pathExists(path: String): Boolean = - FileSystem.SYSTEM.metadataOrNull(path.toPath()) != null - -private fun isOpenableGitRepo(path: String): Boolean = - runCatching { Git.open(path).use { } }.isSuccess - -private fun repoUrl(repo: RepoInfo, branch: String, path: String = ""): String { - val prefix = "/repo/${urlEncode(repo.key)}/${urlEncode(branch)}" - return if (path.isBlank()) prefix else "$prefix/${path.split('/').joinToString("/") { urlEncode(it) }}" -} - -private fun joinPath(base: String, child: String): String = - when { - base.isBlank() -> child - child.isBlank() -> base - base.endsWith("/") -> base + child - else -> "$base/$child" - } - -private fun html(value: String): String = buildString(value.length) { - for (char in value) { - when (char) { - '&' -> append("&") - '<' -> append("<") - '>' -> append(">") - '"' -> append(""") - '\'' -> append("'") - else -> append(char) - } - } -} - -private fun urlEncode(value: String): String { - val bytes = value.encodeToByteArray() - val hex = "0123456789ABCDEF" - return buildString { - for (byte in bytes) { - val int = byte.toInt() and 0xff - val char = int.toChar() - if (char in 'A'..'Z' || char in 'a'..'z' || char in '0'..'9' || char == '-' || char == '_' || char == '.' || char == '~') { - append(char) - } else { - append('%') - append(hex[int shr 4]) - append(hex[int and 15]) - } - } - } -} - -private fun urlDecode(value: String): String { - val bytes = mutableListOf<Byte>() - var index = 0 - while (index < value.length) { - val char = value[index] - if (char == '%' && index + 2 < value.length) { - val hex = value.substring(index + 1, index + 3).toIntOrNull(16) - if (hex != null) { - bytes += hex.toByte() - index += 3 - continue - } - } - bytes += if (char == '+') ' '.code.toByte() else char.code.toByte() - index++ - } - return bytes.toByteArray().decodeToString() -} - -private fun expandHome(path: String): String { - if (path == "~") return homeDirectory() ?: path - if (path.startsWith("~/")) return "${homeDirectory() ?: return path}/${path.drop(2)}" - return path -} - -private fun normalizeRoot(path: String): String { - val expanded = expandHome(path) - return runCatching { - FileSystem.SYSTEM.canonicalize(expanded.toPath()).toString() - }.getOrElse { - expanded - }.trimEnd('/') -} - -private fun homeDirectory(): String? = - KmpProcess.Current.environment()["HOME"]?.takeIf { it.isNotBlank() } - -private fun parseArgs(args: Array<String>): Config? { - if (args.isEmpty() || args[0] == "-h" || args[0] == "--help") { - println("usage: gitweb2 <repo-root> [--host 127.0.0.1] [--port 8080] [--pygments pygmentize]") - return null - } - var host = DefaultHost - var port = DefaultPort - var pygmentsCommand = DefaultPygmentsCommand - var index = 1 - while (index < args.size) { - when (args[index]) { - "--host" -> host = args.getOrNull(++index) ?: die("missing --host value") - "--port" -> port = args.getOrNull(++index)?.toIntOrNull() ?: die("missing or invalid --port value") - "--pygments" -> pygmentsCommand = args.getOrNull(++index) ?: die("missing --pygments value") - else -> die("unknown argument ${args[index]}") - } - index++ - } - return Config(root = args[0], host = host, port = port, pygmentsCommand = pygmentsCommand) -} - -private fun die(message: String): Nothing { - println(message) - exitProcess(2) -} - -private inline fun <T : AutoCloseable, R> T.use(block: (T) -> R): R { - try { - return block(this) - } finally { - close() - } -}
@@ -1,39 +0,0 @@ -@file:OptIn(ExperimentalForeignApi::class) - -package gitweb - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.staticCFunction -import platform.posix.SIG_DFL -import platform.posix.SIGINT -import platform.posix.SIGTERM -import platform.posix.signal -import kotlin.system.exitProcess - -/** - * Runs [block] with SIGINT and SIGTERM wired to [onTerminate]. Restores default handlers and - * clears state afterward. All POSIX / [staticCFunction] details stay in this file. - */ -internal inline fun withTerminateSignalsHandled(crossinline onTerminate: () -> Unit, block: () -> Unit) { - terminateAction = { onTerminate() } - signal(SIGINT, terminateSignalHandler) - signal(SIGTERM, terminateSignalHandler) - try { - block() - } finally { - signal(SIGINT, SIG_DFL) - signal(SIGTERM, SIG_DFL) - terminateAction = null - } -} - -private var terminateAction: (() -> Unit)? = null - -private val terminateSignalHandler = staticCFunction<Int, Unit> { _ -> - val action = terminateAction - if (action != null) { - action() - } else { - exitProcess(0) - } -}
@@ -1,54 +0,0 @@ -package gitweb - -import okio.FileSystem -import okio.Path.Companion.toPath -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals - -class FileRendererTest { - private val repoRoot: String = FileSystem.SYSTEM.canonicalize(".".toPath()).toString() - - @Test - fun filesCanBeRenderedThroughCustomPygmentsCommand() { - val html = FileRenderer("uv run --with pygments pygmentize").render( - "$repoRoot/src/main/kotlin/Main.kt", - """ - private class Example { - fun answer(): Int = 42 - } - """.trimIndent(), - ) - - assertContains(html, "<div class=\"highlight\">") - assertContains(html, "<span style=\"color: #008000; font-weight: bold\">private</span>") - assertContains(html, "<span style=\"color: #008000; font-weight: bold\">class</span>") - assertContains(html, "<span style=\"color: #008000; font-weight: bold\">fun</span>") - assertContains(html, "Example") - } - - @Test - fun pygmentsUsesProvidedBlobTextEvenWhenThePathIsNotOnDisk() { - val html = FileRenderer("uv run --with pygments pygmentize").render( - "$repoRoot/not-checked-out/Example.kt", - """ - private class BlobOnly { - fun answer(): Int = 42 - } - """.trimIndent(), - ) - - assertContains(html, "<div class=\"highlight\">") - assertContains(html, "<span style=\"color: #008000; font-weight: bold\">private</span>") - assertContains(html, "<span style=\"color: #008000; font-weight: bold\">class</span>") - assertContains(html, "<span style=\"color: #008000; font-weight: bold\">fun</span>") - assertContains(html, "BlobOnly") - } - - @Test - fun filesFallBackToEscapedPlainTextWhenPygmentsIsUnavailable() { - val html = FileRenderer("__missing_pygmentize__").render("$repoRoot/README.md", "<plain>") - - assertEquals("<pre><code><plain></code></pre>", html) - } -}
@@ -1,46 +0,0 @@ -package gitweb - -import okio.FileSystem -import okio.Path.Companion.toPath -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertTrue - -class GitWebServerTest { - private val repoRoot: String = FileSystem.SYSTEM.canonicalize(".".toPath()).toString() - private val repoName: String = repoRoot.trimEnd('/').substringAfterLast('/') - - @Test - fun directoryEntriesStayAlphabetical() { - val response = GitWebServer(repoRoot, "127.0.0.1", 0).route("/repo/$repoName") - - assertEquals(200, response.status) - val buildIndex = response.body.indexOf(">build.gradle.kts</a>") - val settingsIndex = response.body.indexOf(">settings.gradle.kts</a>") - val readmeIndex = response.body.indexOf(">README.md</a>") - - assertNotEquals(-1, buildIndex) - assertNotEquals(-1, settingsIndex) - assertNotEquals(-1, readmeIndex) - assertTrue(readmeIndex > buildIndex, "README.md should be listed after build.gradle.kts") - assertTrue(readmeIndex < settingsIndex, "README.md should be listed before settings.gradle.kts") - } - - @Test - fun directoriesRenderTheirReadmeBelowTheListing() { - val response = GitWebServer(repoRoot, "127.0.0.1", 0, "uv run --with pygments pygmentize").route("/repo/$repoName") - - assertEquals(200, response.status) - val listingIndex = response.body.indexOf(">README.md</a>") - val renderedIndex = response.body.indexOf("<h2>README.md</h2>") - - assertNotEquals(-1, listingIndex) - assertNotEquals(-1, renderedIndex) - assertTrue(renderedIndex > listingIndex, "Rendered README should appear below the directory listing") - assertContains(response.body, "<div class=\"highlight\">") - assertContains(response.body, "gitweb2") - assertContains(response.body, "A Kotlin/Native, read-only web viewer") - } -}
@@ -0,0 +1,11 @@ +(executable + (name test_gitweb2) + (libraries alcotest gitweb2 unix)) + +(rule + (alias runtest) + (deps test_gitweb2.exe) + (action + (chdir + %{workspace_root} + (run test/test_gitweb2.exe))))
@@ -0,0 +1,86 @@ +let work_root = ".build/test-work" +let repo_root = work_root ^ "/repo" +let repo_name = "repo" + +let sh command = + match Sys.command command with + | 0 -> () + | code -> Alcotest.failf "command failed with %d: %s" code command + +let reset_repo () = + sh ("rm -rf " ^ work_root ^ " && mkdir -p " ^ repo_root); + sh ("git -C " ^ repo_root ^ " init -b main >/dev/null 2>&1"); + sh ("git -C " ^ repo_root ^ " config user.name 'Fixture Author'"); + sh ("git -C " ^ repo_root ^ " config user.email fixture@example.com"); + sh ("printf 'plugins { }\\n' > " ^ repo_root ^ "/build.gradle.kts"); + sh ("printf '# gitweb2\\n\\nA Kotlin/Native, read-only web viewer for local Git repositories.\\n' > " ^ repo_root ^ "/README.md"); + sh ("printf 'pluginManagement { }\\n' > " ^ repo_root ^ "/settings.gradle.kts"); + sh ("git -C " ^ repo_root ^ " add . && git -C " ^ repo_root ^ " commit -m 'Initial fixture commit' >/dev/null 2>&1") + +let contains haystack needle = + let hlen = String.length haystack and nlen = String.length needle in + let rec loop index = + index + nlen <= hlen && (String.sub haystack index nlen = needle || loop (index + 1)) + in + nlen = 0 || loop 0 + +let assert_contains label body needle = Alcotest.(check bool) label true (contains body needle) + +let directory_entries_stay_alphabetical () = + reset_repo (); + let response = Gitweb2.route ~root:work_root ("/repo/" ^ repo_name) in + Alcotest.(check int) "status" 200 response.status; + let index needle = try String.index_from response.body 0 needle.[0] with _ -> -1 in + let find needle = + let rec loop index = + if index + String.length needle > String.length response.body then -1 + else if String.sub response.body index (String.length needle) = needle then index + else loop (index + 1) + in + loop 0 + in + let build_index = find ">build.gradle.kts</a>" in + let settings_index = find ">settings.gradle.kts</a>" in + let readme_index = find ">README.md</a>" in + ignore index; + Alcotest.(check bool) "build present" true (build_index >= 0); + Alcotest.(check bool) "settings present" true (settings_index >= 0); + Alcotest.(check bool) "readme present" true (readme_index >= 0); + Alcotest.(check bool) "readme after build" true (readme_index > build_index); + Alcotest.(check bool) "readme before settings" true (readme_index < settings_index) + +let directories_render_their_readme_below_the_listing () = + reset_repo (); + let response = Gitweb2.route ~pygments_command:"uv run --with pygments pygmentize" ~root:work_root ("/repo/" ^ repo_name) in + Alcotest.(check int) "status" 200 response.status; + let find needle = + let rec loop index = + if index + String.length needle > String.length response.body then -1 + else if String.sub response.body index (String.length needle) = needle then index + else loop (index + 1) + in + loop 0 + in + let listing_index = find ">README.md</a>" in + let rendered_index = find "<h2>README.md</h2>" in + Alcotest.(check bool) "listing present" true (listing_index >= 0); + Alcotest.(check bool) "rendered present" true (rendered_index >= 0); + Alcotest.(check bool) "below listing" true (rendered_index > listing_index); + assert_contains "highlight" response.body "<div class=\"highlight\">"; + assert_contains "name" response.body "gitweb2"; + assert_contains "readme text" response.body "read-only web viewer" + +let pygments_fallback () = + let html = Gitweb2.file_render ~pygments_command:"__missing_pygmentize__" ~file_path:"README.md" "<plain>" in + Alcotest.(check string) "fallback" "<pre><code><plain></code></pre>" html + +let () = + Alcotest.run "gitweb2" + [ + ( "routing", + [ + Alcotest.test_case "directory entries stay alphabetical" `Quick directory_entries_stay_alphabetical; + Alcotest.test_case "directories render their readme below the listing" `Quick directories_render_their_readme_below_the_listing; + Alcotest.test_case "pygments fallback" `Quick pygments_fallback; + ] ); + ]