BuildPropertiesGenerator.kt1 package org.projectforge 2 3 import java.io.File 4 import java.time.OffsetDateTime 5 import java.time.ZoneOffset 6 import java.time.format.DateTimeFormatter 7 8 /** Generates a properties file containing git information as well as gradle information */ 9 class BuildPropertiesGenerator( 10 private val rootDirPath: String, // ← project root (where .git/ lives) 11 private val outputFilePath: String, // ← where to write the .properties file 12 private val projectVersion: String // ← e.g. "8.2-SNAPSHOT" 13 ) {
Three constructor parameters — injected by the Gradle task that calls this class:
| Parameter | Source | Example |
|---|---|---|
rootDirPath | Gradle's project.rootDir | /home/dev/projectforge |
outputFilePath | Configurable path in build script | build/resources/main/build.properties |
projectVersion | From convention plugin line 18 | 8.2-SNAPSHOT |
This class is not a Gradle Task — it's a plain Kotlin class called by a Gradle task registered in each subproject's build.gradle.kts.
generate() method — git interrogation20 val branch = "git rev-parse --abbrev-ref HEAD".runCommand(rootDir) ?: "unknown" 21 val commitIdFull = "git rev-parse HEAD".runCommand(rootDir) ?: "unknown" 22 val commitIdAbbrev = commitIdFull.take(7) 23 val commitTime = "git show -s --format=%ci HEAD".runCommand(rootDir) ?: "unknown" 24 val isDirty = "git status --porcelain".runCommand(rootDir)?.isNotBlank().toString() 25 val now = OffsetDateTime.now(ZoneOffset.UTC) 26 val buildDate = now.format(DateTimeFormatter.ISO_OFFSET_DATE) 27 val buildTimeStamp = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"))
Five git commands, run via runCommand():
| Line | Git Command | Purpose | Example Output |
|---|---|---|---|
| 20 | git rev-parse --abbrev-ref HEAD | Current branch name | fix/vite-eslint-upgrade |
| 21 | git rev-parse HEAD | Full commit SHA | e67067aa7f00... |
| 22 | .take(7) on the full SHA | Short commit SHA (7 chars) | e67067a |
| 23 | git show -s --format=%ci HEAD | Commit timestamp | 2026-05-10 12:30:00 +0100 |
| 24 | git status --porcelain | Is working tree dirty? | true or false |
Line 24 — dirty flag: git status --porcelain returns empty string for clean working tree, non-empty for dirty. .isNotBlank() converts that to boolean, .toString() to "true"/"false". This is the single most useful field for debugging: a build with git.dirty=true means the running code doesn't exactly match any commit.
Lines 25–27: Build timestamp — captured at build time, not commit time. Uses OffsetDateTime.now(UTC) for consistent timezone.
29 outputFile.parentFile.mkdirs() 30 outputFile.writeText( 31 """ 32 # Generated by Gradle 33 gradle.version=$projectVersion 34 gradle.build.date=$buildDate 35 gradle.build.timestamp=$buildTimeStamp 36 git.branch=$branch 37 git.commit.id.abbrev=$commitIdAbbrev 38 git.commit.id.full=$commitIdFull 39 git.commit.time=$commitTime 40 git.dirty=$isDirty 41 """.trimIndent() 42 )
Line 29: Creates parent directories if they don't exist — important for CI where the build output directory may not exist yet.
Lines 31–41: A Kotlin raw string (""") with trimIndent() — removes leading whitespace from each line for clean output.
Line 43: Prints confirmation to build log: Git properties written to /path/to/build.properties.
runCommand() — the shell executor46 private fun String.runCommand(workingDir: File): String? { 47 return try { 48 val process = ProcessBuilder(*split(" ").toTypedArray()) 49 .directory(workingDir) 50 .redirectErrorStream(true) 51 .start() 52 process.inputStream.bufferedReader().readText().trim() 53 .takeIf { process.waitFor() == 0 } 54 } catch (e: Exception) { null } 55 }
Extension function on String — allows calling git commands with natural syntax: "git rev-parse HEAD".runCommand(rootDir).
Line 48: ProcessBuilder(*split(" ").toTypedArray()) — splits the command string by spaces into arguments. ProcessBuilder is the Java standard library way to spawn external processes.
Line 49: .directory(workingDir) — sets the working directory for the git command. Must point to the project root (where .git/ lives).
Line 50: .redirectErrorStream(true) — merges stderr into stdout. Why? If stderr were separate and unread, the process could hang waiting for the buffer to be consumed.
Line 53: .takeIf { process.waitFor() == 0 } — only returns output if the process exited with code 0 (success). If git fails (e.g. not a git repo), returns null → the caller uses ?: "unknown" as fallback.
Line 54: catch (e: Exception) { null } — silences all exceptions. If git is not installed, the build doesn't crash — it just gets "unknown" values.
The generated build.properties is read at runtime by Spring Boot's @PropertySource or application.properties placeholder resolution. ProjectForge uses it for:
GET /rs/userStatus returns { systemData: { version, buildTimestamp } } — the React frontend shows this in the footergit.dirty is gold: Tells you instantly if the build includes uncommitted changes."unknown" — the build never breaks because git isn't available.doLast { } block in subproject build scripts.outputFilePath.
Every time ProjectForge is built, this task runs five git commands and writes the results to a
.propertiesfile. That file is then packaged into the JAR and read at runtime to display version information (the "About" dialog, system status endpoint, etc.).The generated output looks like:
The
git.dirty=truefield is especially useful — it tells you whether the build was made from clean sources or had uncommitted changes.