#15: BuildPropertiesGenerator.kt

buildSrc/src/main/kotlin/org.projectforge/BuildPropertiesGenerator.kt

Path: ./buildSrc/src/main/kotlin/org.projectforge/BuildPropertiesGenerator.kt

Type: Kotlin class — custom Gradle task

Lines: 57

Purpose: Generates a build.properties file with git commit info, branch, build timestamp, and version — injected into the application at runtime

Source: GitHub

57 lines · 48 code · 4 comments · 5 blank

What it does

Every time ProjectForge is built, this task runs five git commands and writes the results to a .properties file. 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:

# Generated by Gradle gradle.version=8.2-SNAPSHOT gradle.build.date=2026-05-10 gradle.build.timestamp=2026-05-10T12:34:56Z git.branch=fix/vite-eslint-upgrade git.commit.id.abbrev=e67067a git.commit.id.full=e67067aa7f00b3a6b6afc7ffe9af69718e277fc1 git.commit.time=2026-05-10 12:30:00 +0100 git.dirty=true

The git.dirty=true field is especially useful — it tells you whether the build was made from clean sources or had uncommitted changes.

Line-by-line

Lines 1–15: Class declaration

 1 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:

ParameterSourceExample
rootDirPathGradle's project.rootDir/home/dev/projectforge
outputFilePathConfigurable path in build scriptbuild/resources/main/build.properties
projectVersionFrom convention plugin line 188.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.

Lines 16–29: The generate() method — git interrogation

20 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():

LineGit CommandPurposeExample Output
20git rev-parse --abbrev-ref HEADCurrent branch namefix/vite-eslint-upgrade
21git rev-parse HEADFull commit SHAe67067aa7f00...
22.take(7) on the full SHAShort commit SHA (7 chars)e67067a
23git show -s --format=%ci HEADCommit timestamp2026-05-10 12:30:00 +0100
24git status --porcelainIs 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.

Lines 29–43: File output

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.

Lines 46–56: runCommand() — the shell executor

46 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.

Where the output is used

The generated build.properties is read at runtime by Spring Boot's @PropertySource or application.properties placeholder resolution. ProjectForge uses it for:

Key takeaways