implemented 02-git-gateway.md: added Git access layer with credentials bridge, duration parser, and extensive tests

This commit is contained in:
Michael Hoennig
2026-07-07 07:10:38 +02:00
parent f4a6ec9194
commit a427665ce8
11 changed files with 707 additions and 23 deletions
@@ -0,0 +1,19 @@
package de.hoennig.gittally.config
import java.time.Duration
/** Parses durations in the `watcher.newBranchMaxAge` format: `5d` (days) or `12h` (hours). */
object DurationParser {
private val pattern = Regex("""(\d+)([dh])""")
fun parse(value: String): Duration {
val match =
pattern.matchEntire(value.trim())
?: throw IllegalArgumentException("invalid duration '$value': expected <amount>d or <amount>h, e.g. 5d or 12h")
val (amount, unit) = match.destructured
return when (unit) {
"d" -> Duration.ofDays(amount.toLong())
else -> Duration.ofHours(amount.toLong())
}
}
}
@@ -0,0 +1,51 @@
package de.hoennig.gittally.git
import java.nio.file.Files
import java.nio.file.attribute.PosixFilePermissions
/**
* Credential bridge for git HTTPS authentication via `GIT_ASKPASS`.
*
* The script itself contains no secrets; credentials are passed through
* process environment variables so they never touch the filesystem.
*/
object GitAskPass {
val SCRIPT: String =
"""
#!/bin/sh
case "${'$'}1" in
*[Uu]sername*)
printf '%s\n' "${'$'}GITTALLY_GIT_ACCOUNT"
;;
*)
printf '%s\n' "${'$'}GITTALLY_GIT_TOKEN"
;;
esac
""".trimIndent() + "\n"
fun <T> withAskPass(
account: String,
token: String,
block: (environment: Map<String, String>) -> T,
): T {
val script =
Files.createTempFile(
"gittally-askpass",
".sh",
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")),
)
try {
Files.writeString(script, SCRIPT)
return block(
mapOf(
"GIT_ASKPASS" to script.toAbsolutePath().toString(),
"GIT_TERMINAL_PROMPT" to "0",
"GITTALLY_GIT_ACCOUNT" to account,
"GITTALLY_GIT_TOKEN" to token,
),
)
} finally {
Files.deleteIfExists(script)
}
}
}
@@ -0,0 +1,52 @@
package de.hoennig.gittally.git
import org.springframework.stereotype.Component
import java.nio.file.Path
import java.util.concurrent.CompletableFuture
data class GitCommandResult(
val exitCode: Int,
val stdout: String,
val stderr: String,
) {
val isSuccess: Boolean get() = exitCode == 0
fun lines(): List<String> = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() }
}
class GitCommandException(
command: List<String>,
result: GitCommandResult,
) : RuntimeException(
"Command failed with exit code ${result.exitCode}: ${command.joinToString(" ")}\n${result.stderr.trim()}",
)
@Component
class GitCommandRunner {
fun run(
command: List<String>,
workingDir: Path,
environment: Map<String, String> = emptyMap(),
): GitCommandResult {
val builder = ProcessBuilder(command).directory(workingDir.toFile())
builder.environment().putAll(environment)
val process = builder.start()
// stderr is drained concurrently so neither pipe buffer can block the process
val stderr = CompletableFuture.supplyAsync { process.errorStream.bufferedReader().readText() }
val stdout = process.inputStream.bufferedReader().readText()
val exitCode = process.waitFor()
return GitCommandResult(exitCode, stdout, stderr.get())
}
fun runOrThrow(
command: List<String>,
workingDir: Path,
environment: Map<String, String> = emptyMap(),
): GitCommandResult {
val result = run(command, workingDir, environment)
if (!result.isSuccess) {
throw GitCommandException(command, result)
}
return result
}
}
@@ -1,38 +1,184 @@
package de.hoennig.gittally.git
import de.hoennig.gittally.config.ConfigLoader
import org.springframework.stereotype.Service
import java.nio.file.Path
import java.nio.file.Paths
import java.time.Duration
import java.time.Instant
import java.time.OffsetDateTime
@Service
class GitService {
class GitService(
private val runner: GitCommandRunner,
private val configLoader: ConfigLoader,
) {
fun getTopLevel(workingDir: Path = Paths.get(".")): Path {
val process =
ProcessBuilder("git", "rev-parse", "--show-toplevel")
.directory(workingDir.toFile())
.start()
if (process.waitFor() != 0) {
val result = runner.run(listOf("git", "rev-parse", "--show-toplevel"), workingDir)
if (!result.isSuccess) {
throw RuntimeException("Not a git repository")
}
return Paths.get(
process.inputStream
.bufferedReader()
.readText()
.trim(),
)
return Paths.get(result.stdout.trim())
}
fun getOriginUrl(workingDir: Path = Paths.get(".")): String? {
val process =
ProcessBuilder("git", "remote", "get-url", "origin")
.directory(workingDir.toFile())
.start()
if (process.waitFor() != 0) {
return null
val result = runner.run(listOf("git", "remote", "get-url", "origin"), workingDir)
return if (result.isSuccess) result.stdout.trim() else null
}
fun fetchOrigin(workingDir: Path = Paths.get(".")) {
authenticated(workingDir) { environment ->
runner.runOrThrow(listOf("git", "fetch", "--prune", "origin"), workingDir, environment)
}
return process.inputStream
.bufferedReader()
.readText()
}
fun fetchBranch(
branch: String,
workingDir: Path = Paths.get("."),
) {
authenticated(workingDir) { environment ->
runner.runOrThrow(listOf("git", "fetch", "origin", branch), workingDir, environment)
}
}
fun localBranches(workingDir: Path = Paths.get(".")): List<String> =
runner
.runOrThrow(listOf("git", "for-each-ref", "--format=%(refname:strip=2)", "refs/heads"), workingDir)
.lines()
fun originBranches(workingDir: Path = Paths.get(".")): List<String> =
runner
.runOrThrow(listOf("git", "for-each-ref", "--format=%(refname:strip=3)", "refs/remotes/origin"), workingDir)
.lines()
.filter { it != "HEAD" }
/**
* A branch has new commits when its origin counterpart is ahead of the local branch,
* or when it exists only on origin.
*/
fun hasNewCommits(
branch: String,
workingDir: Path = Paths.get("."),
): Boolean {
val originRef = "refs/remotes/origin/$branch"
if (!refExists("refs/heads/$branch", workingDir)) {
return refExists(originRef, workingDir)
}
val upstream =
runner
.runOrThrow(listOf("git", "for-each-ref", "--format=%(upstream)", "refs/heads/$branch"), workingDir)
.stdout
.trim()
val compareTo =
when {
upstream.isNotEmpty() -> upstream
refExists(originRef, workingDir) -> originRef
else -> return false
}
if (!refExists(compareTo, workingDir)) {
return false
}
val count =
runner
.runOrThrow(listOf("git", "rev-list", "--count", "refs/heads/$branch..$compareTo"), workingDir)
.stdout
.trim()
return count.toLong() > 0
}
/** Origin branches without a local counterpart whose latest commit is younger than [maxAge]. */
fun newOriginBranches(
maxAge: Duration,
workingDir: Path = Paths.get("."),
): List<String> {
val cutoff = Instant.now().minus(maxAge)
val local = localBranches(workingDir).toSet()
return runner
.runOrThrow(
listOf(
"git",
"for-each-ref",
"--sort=-committerdate",
"--format=%(refname:strip=3) %(committerdate:unix)",
"refs/remotes/origin",
),
workingDir,
).lines()
.mapNotNull { line ->
val branch = line.substringBeforeLast(' ')
val epochSeconds = line.substringAfterLast(' ').toLongOrNull() ?: return@mapNotNull null
branch to Instant.ofEpochSecond(epochSeconds)
}.filter { (branch, committedAt) ->
branch != "HEAD" && branch !in local && committedAt >= cutoff
}.map { (branch, _) -> branch }
}
/** Switches to an existing local branch, or creates a tracking branch from origin. */
fun checkout(
branch: String,
workingDir: Path = Paths.get("."),
) {
if (refExists("refs/heads/$branch", workingDir)) {
runner.runOrThrow(listOf("git", "switch", branch), workingDir)
} else {
runner.runOrThrow(listOf("git", "switch", "--track", "-c", branch, "refs/remotes/origin/$branch"), workingDir)
}
}
fun resetHardToOrigin(
branch: String,
workingDir: Path = Paths.get("."),
) {
runner.runOrThrow(listOf("git", "reset", "--hard", "origin/$branch"), workingDir)
}
fun commitTimestamp(
sha: String,
workingDir: Path = Paths.get("."),
): Instant =
OffsetDateTime
.parse(
runner
.runOrThrow(listOf("git", "show", "-s", "--format=%cI", sha), workingDir)
.stdout
.trim(),
).toInstant()
fun currentBranch(workingDir: Path = Paths.get(".")): String? =
runner
.runOrThrow(listOf("git", "branch", "--show-current"), workingDir)
.stdout
.trim()
.ifEmpty { null }
fun headCommit(workingDir: Path = Paths.get(".")): String =
runner
.runOrThrow(listOf("git", "rev-parse", "HEAD"), workingDir)
.stdout
.trim()
private fun refExists(
ref: String,
workingDir: Path,
): Boolean = runner.run(listOf("git", "show-ref", "--quiet", "--verify", ref), workingDir).isSuccess
/**
* Runs [block] with askpass credentials from config for HTTPS origins.
* SSH origins and missing credentials run without auth setup;
* terminal prompts are always disabled so a fetch can never hang on stdin.
*/
private fun <T> authenticated(
workingDir: Path,
block: (environment: Map<String, String>) -> T,
): T {
val noPrompt = mapOf("GIT_TERMINAL_PROMPT" to "0")
if (getOriginUrl(workingDir)?.startsWith("http") != true) {
return block(noPrompt)
}
val git = configLoader.load(getTopLevel(workingDir)).git
if (git.account.isBlank() || git.token.isBlank()) {
return block(noPrompt)
}
return GitAskPass.withAskPass(git.account, git.token, block)
}
}