Repositories / gitweb2.git

gitweb2.git

Clone (read-only): git clone http://git.guha-anderson.com/git/gitweb2.git

Branch

Remove direct cinterop usage

Author
Arjun Guha <a.guha@northeastern.edu>
Date
2026-04-19 09:11:48 -0400
Commit
e5f67fab1fb16d9690f5e51bc6c2ad9fdd0af77d
build.gradle.kts
index 5f56cd4..53e36fb 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,6 +6,8 @@ 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("/home/arjun/repos/homebox/kotlin-git")
 val kotlinGitKlib = kotlinGitDir.map { file("$it/.build/klib/kotlin-git.klib") }
@@ -35,6 +37,7 @@ kotlin {
             all {
                 linkerOpts("-L${pkgConfigValue("--variable=libdir", "libgit2")}")
                 linkerOpts(pkgConfig("--libs", "libgit2"))
+                linkerOpts("-lutil")
             }
             executable {
                 baseName = "gitweb2"
@@ -49,6 +52,8 @@ 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))
             }
src/main/kotlin/Main.kt
index e937133..b9a4893 100644
--- a/src/main/kotlin/Main.kt
+++ b/src/main/kotlin/Main.kt
@@ -1,5 +1,3 @@
-@file:OptIn(ExperimentalForeignApi::class)
-
 package gitweb
 
 import io.ktor.http.ContentType
@@ -14,13 +12,7 @@ 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 kotlinx.cinterop.ExperimentalForeignApi
-import kotlinx.cinterop.ByteVar
-import kotlinx.cinterop.allocArray
-import kotlinx.cinterop.memScoped
-import kotlinx.cinterop.pointed
-import kotlinx.cinterop.staticCFunction
-import kotlinx.cinterop.toKString
+import io.matthewnelson.kmp.process.Process as KmpProcess
 import kotlinx.git.Blob
 import kotlinx.git.Branch
 import kotlinx.git.BranchType
@@ -28,20 +20,8 @@ import kotlinx.git.Git
 import kotlinx.git.GitException
 import kotlinx.git.Repository
 import kotlinx.git.TreeEntryKind
-import platform.posix.F_OK
-import platform.posix.SIGINT
-import platform.posix.SIGTERM
-import platform.posix.access
-import platform.posix.closedir
-import platform.posix.fgets
-import platform.posix.getenv
-import platform.posix.opendir
-import platform.posix.pclose
-import platform.posix.pause
-import platform.posix.popen
-import platform.posix.readdir
-import platform.posix.signal
-import platform.posix._exit
+import okio.FileSystem
+import okio.Path.Companion.toPath
 import kotlin.system.exitProcess
 
 private const val DefaultHost = "127.0.0.1"
@@ -86,9 +66,7 @@ internal class GitWebServer(
             }
         }
         try {
-            server.start(wait = false)
-            installSignalHandlers()
-            while (true) pause()
+            server.start(wait = true)
         } finally {
             server.stop()
             Git.shutdown()
@@ -276,15 +254,6 @@ internal class GitWebServer(
     private data class RepoSummary(val branch: String, val summary: String, val shortId: String)
 }
 
-private fun installSignalHandlers() {
-    signal(SIGINT, staticCFunction(::exitFromSignal))
-    signal(SIGTERM, staticCFunction(::exitFromSignal))
-}
-
-private fun exitFromSignal(signalNumber: Int) {
-    _exit(128 + signalNumber)
-}
-
 private fun page(title: String, body: String): String = """
 <!doctype html>
 <html lang="en">
@@ -342,10 +311,11 @@ internal class FileRenderer(private val pygmentsCommand: String = DefaultPygment
         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 = "printf %s ${shellQuote(text)} | $pygmentsCommand -f html -O nowrap,noclasses=True $lexerOption 2>/dev/null"
-        val highlighted = readCommandOutput(command) ?: return null
+        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>"
     }
@@ -363,24 +333,30 @@ private fun isReadme(name: String): Boolean =
 private fun shellQuote(value: String): String =
     "'" + value.replace("'", "'\"'\"'") + "'"
 
-private fun readCommandOutput(command: String): String? {
-    val stream = popen(command, "r") ?: return null
-    return try {
-        memScoped {
-            val buffer = allocArray<ByteVar>(4096)
-            buildString {
-                while (true) {
-                    val line = fgets(buffer, 4096, stream) ?: break
-                    append(line.toKString())
-                }
+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 }
             }
-        }
-    } finally {
-        pclose(stream)
-    }
+    }.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)) {
@@ -391,18 +367,13 @@ private fun discoverRepos(root: String): List<RepoInfo> {
             return
         }
         if (depth >= MaxScanDepth) return
-        val dir = opendir(path) ?: return
-        try {
-            while (true) {
-                val entry = readdir(dir) ?: break
-                val name = entry.pointed.d_name.toKString()
-                if (name == "." || name == ".." || name == ".git") continue
-                val child = joinPath(path, name)
-                val childRelative = if (relative.isBlank()) name else "$relative/$name"
-                scan(child, childRelative, depth + 1)
-            }
-        } finally {
-            closedir(dir)
+        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)
@@ -413,10 +384,13 @@ private fun Repository.localBranches(): List<Branch> =
     branches().filter { it.type == BranchType.Local }
 
 private fun isGitRepo(path: String): Boolean =
-    access(joinPath(path, ".git"), F_OK) == 0 ||
-        access(joinPath(path, "HEAD"), F_OK) == 0 &&
-        access(joinPath(path, "objects"), F_OK) == 0 &&
-        access(joinPath(path, "refs"), F_OK) == 0
+    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
@@ -485,21 +459,23 @@ private fun urlDecode(value: String): String {
 }
 
 private fun expandHome(path: String): String {
-    if (path == "~") return getenv("HOME")?.toKString() ?: path
-    if (path.startsWith("~/")) return "${getenv("HOME")?.toKString() ?: ""}/${path.drop(2)}"
+    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)
-    val cwd = getenv("PWD")?.toKString()
-    return when {
-        expanded == "." && cwd != null -> cwd
-        expanded.startsWith("./") && cwd != null -> joinPath(cwd, expanded.drop(2))
-        else -> expanded
+    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]")
src/nativeTest/kotlin/gitweb/FileRendererTest.kt
index 5e08dd5..781e279 100644
--- a/src/nativeTest/kotlin/gitweb/FileRendererTest.kt
+++ b/src/nativeTest/kotlin/gitweb/FileRendererTest.kt
@@ -1,15 +1,13 @@
 package gitweb
 
-import kotlinx.cinterop.ExperimentalForeignApi
-import kotlinx.cinterop.toKString
-import platform.posix.getenv
+import okio.FileSystem
+import okio.Path.Companion.toPath
 import kotlin.test.Test
 import kotlin.test.assertContains
 import kotlin.test.assertEquals
 
-@OptIn(ExperimentalForeignApi::class)
 class FileRendererTest {
-    private val repoRoot: String = getenv("PWD")!!.toKString()
+    private val repoRoot: String = FileSystem.SYSTEM.canonicalize(".".toPath()).toString()
 
     @Test
     fun filesCanBeRenderedThroughCustomPygmentsCommand() {
src/nativeTest/kotlin/gitweb/GitWebServerTest.kt
index ea21114..88a9725 100644
--- a/src/nativeTest/kotlin/gitweb/GitWebServerTest.kt
+++ b/src/nativeTest/kotlin/gitweb/GitWebServerTest.kt
@@ -1,17 +1,15 @@
 package gitweb
 
-import kotlinx.cinterop.ExperimentalForeignApi
-import kotlinx.cinterop.toKString
-import platform.posix.getenv
+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
 
-@OptIn(ExperimentalForeignApi::class)
 class GitWebServerTest {
-    private val repoRoot: String = getenv("PWD")!!.toKString()
+    private val repoRoot: String = FileSystem.SYSTEM.canonicalize(".".toPath()).toString()
     private val repoName: String = repoRoot.trimEnd('/').substringAfterLast('/')
 
     @Test