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)
}
}
@@ -0,0 +1,38 @@
package de.hoennig.gittally.config
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import java.time.Duration
class DurationParserTest : FunSpec() {
init {
test("parses days") {
DurationParser.parse("5d") shouldBe Duration.ofDays(5)
}
test("parses hours") {
DurationParser.parse("12h") shouldBe Duration.ofHours(12)
}
test("parses multi-digit amounts") {
DurationParser.parse("120h") shouldBe Duration.ofHours(120)
}
test("parses zero") {
DurationParser.parse("0d") shouldBe Duration.ZERO
}
test("tolerates surrounding whitespace") {
DurationParser.parse(" 5d ") shouldBe Duration.ofDays(5)
}
test("rejects invalid values") {
listOf("", "5", "d", "5x", "-5d", "5D", "1.5d", "5 d").forEach { value ->
val exception = shouldThrow<IllegalArgumentException> { DurationParser.parse(value) }
exception.message shouldContain "invalid duration"
}
}
}
}
@@ -0,0 +1,78 @@
package de.hoennig.gittally.git
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.shouldBe
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermission
import kotlin.io.path.exists
class GitAskPassTest : FunSpec() {
private val runner = GitCommandRunner()
private val tempDir = Files.createTempDirectory("gittally-askpass-test")
private fun runScript(
environment: Map<String, String>,
prompt: String,
): String {
val script = environment.getValue("GIT_ASKPASS")
return runner.runOrThrow(listOf("sh", script, prompt), tempDir, environment).stdout.trim()
}
init {
test("script answers username prompts with the account") {
GitAskPass.withAskPass("builder", "secret-token") { environment ->
runScript(environment, "Username for 'https://git.example.com':") shouldBe "builder"
}
}
test("script answers password prompts with the token") {
GitAskPass.withAskPass("builder", "secret-token") { environment ->
runScript(environment, "Password for 'https://builder@git.example.com':") shouldBe "secret-token"
}
}
test("environment disables terminal prompts") {
GitAskPass.withAskPass("builder", "secret-token") { environment ->
environment["GIT_TERMINAL_PROMPT"] shouldBe "0"
}
}
test("script file is only accessible by the owner and contains no secrets") {
GitAskPass.withAskPass("builder", "secret-token") { environment ->
val script = Path.of(environment.getValue("GIT_ASKPASS"))
Files.getPosixFilePermissions(script) shouldBe
setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
)
Files.readString(script).contains("secret-token").shouldBeFalse()
}
}
test("script file is deleted after the block") {
val script =
GitAskPass.withAskPass("builder", "secret-token") { environment ->
Path.of(environment.getValue("GIT_ASKPASS"))
}
script.exists().shouldBeFalse()
}
test("script file is deleted when the block throws") {
var script: Path? = null
shouldThrow<IllegalStateException> {
GitAskPass.withAskPass("builder", "secret-token") { environment ->
script = Path.of(environment.getValue("GIT_ASKPASS"))
error("boom")
}
}
script!!.exists().shouldBeFalse()
}
}
}
@@ -0,0 +1,62 @@
package de.hoennig.gittally.git
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import java.nio.file.Files
class GitCommandRunnerTest : FunSpec() {
private val runner = GitCommandRunner()
private val tempDir = Files.createTempDirectory("gittally-runner-test")
init {
test("captures stdout, stderr and exit code") {
val result = runner.run(listOf("sh", "-c", "echo out; echo err >&2; exit 3"), tempDir)
result.exitCode shouldBe 3
result.stdout.trim() shouldBe "out"
result.stderr.trim() shouldBe "err"
result.isSuccess.shouldBeFalse()
}
test("runs the command in the given working directory") {
val result = runner.run(listOf("sh", "-c", "pwd"), tempDir)
result.isSuccess.shouldBeTrue()
result.stdout.trim() shouldBe tempDir.toRealPath().toString()
}
test("passes extra environment variables") {
val result = runner.run(listOf("sh", "-c", "echo \"\$GITTALLY_TEST_VAR\""), tempDir, mapOf("GITTALLY_TEST_VAR" to "hello"))
result.stdout.trim() shouldBe "hello"
}
test("lines splits stdout and drops blank lines") {
val result = runner.run(listOf("sh", "-c", "printf 'a\\n\\n b \\n'"), tempDir)
result.lines() shouldContainExactly listOf("a", "b")
}
test("runOrThrow returns the result on success") {
val result = runner.runOrThrow(listOf("sh", "-c", "echo ok"), tempDir)
result.stdout.trim() shouldBe "ok"
}
test("runOrThrow throws with command and stderr on failure") {
val exception =
shouldThrow<GitCommandException> {
runner.runOrThrow(listOf("sh", "-c", "echo broken >&2; exit 1"), tempDir)
}
exception.message shouldContain "exit code 1"
exception.message shouldContain "sh -c"
exception.message shouldContain "broken"
}
}
}
@@ -0,0 +1,220 @@
package de.hoennig.gittally.git
import de.hoennig.gittally.config.ConfigLoader
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldNotContain
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldMatch
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
import java.time.Instant
/**
* Integration tests against local fixture repositories:
* a bare "origin", a "seed" clone that pushes to it, and a "work" clone under test.
* No network access needed.
*/
class GitServiceTest : FunSpec() {
private val runner = GitCommandRunner()
private val service = GitService(runner, ConfigLoader())
// hermetic git: fixed identity, no user/system config (hooks, gpg signing, ...)
private val gitEnvironment =
mapOf(
"GIT_AUTHOR_NAME" to "GitTally Test",
"GIT_AUTHOR_EMAIL" to "test@example.com",
"GIT_COMMITTER_NAME" to "GitTally Test",
"GIT_COMMITTER_EMAIL" to "test@example.com",
"GIT_CONFIG_GLOBAL" to "/dev/null",
"GIT_CONFIG_SYSTEM" to "/dev/null",
)
private inner class Fixture {
val root: Path = Files.createTempDirectory("gittally-git-test")
val origin: Path = root.resolve("origin.git")
val seed: Path = root.resolve("seed")
val work: Path = root.resolve("work")
init {
git(root, "init", "--bare", "-b", "main", "origin.git")
git(root, "init", "-b", "main", "seed")
commitFile(seed, "README.md", "hello")
git(seed, "remote", "add", "origin", origin.toString())
git(seed, "push", "origin", "main")
git(root, "clone", origin.toString(), "work")
}
fun git(
dir: Path,
vararg args: String,
environment: Map<String, String> = emptyMap(),
): GitCommandResult = runner.runOrThrow(listOf("git") + args, dir, gitEnvironment + environment)
fun commitFile(
dir: Path,
name: String,
content: String,
environment: Map<String, String> = emptyMap(),
) {
Files.writeString(dir.resolve(name), content)
git(dir, "add", name)
git(dir, "commit", "-m", "add $name", environment = environment)
}
/** Creates a branch with one commit in the seed clone and pushes it to origin. */
fun pushNewSeedBranch(
branch: String,
environment: Map<String, String> = emptyMap(),
) {
git(seed, "switch", "-c", branch)
commitFile(seed, branch.replace('/', '-') + ".txt", branch, environment)
git(seed, "push", "origin", branch)
git(seed, "switch", "main")
}
}
init {
test("getTopLevel returns the repo root from a subdirectory") {
val fixture = Fixture()
val subDir = Files.createDirectories(fixture.work.resolve("sub/dir"))
service.getTopLevel(subDir).toRealPath() shouldBe fixture.work.toRealPath()
}
test("getOriginUrl returns the origin URL, or null without an origin") {
val fixture = Fixture()
fixture.git(fixture.root, "init", "-b", "main", "plain")
service.getOriginUrl(fixture.work) shouldBe fixture.origin.toString()
service.getOriginUrl(fixture.root.resolve("plain")).shouldBeNull()
}
test("localBranches and originBranches list branches, without HEAD") {
val fixture = Fixture()
service.localBranches(fixture.work) shouldContainExactly listOf("main")
service.originBranches(fixture.work) shouldContainExactly listOf("main")
}
test("fetchOrigin picks up new origin branches and prunes deleted ones") {
val fixture = Fixture()
fixture.pushNewSeedBranch("feature/x")
service.fetchOrigin(fixture.work)
service.originBranches(fixture.work) shouldContainExactly listOf("feature/x", "main")
fixture.git(fixture.seed, "push", "origin", "--delete", "feature/x")
service.fetchOrigin(fixture.work)
service.originBranches(fixture.work) shouldContainExactly listOf("main")
}
test("hasNewCommits is false when the branch is up to date") {
val fixture = Fixture()
service.hasNewCommits("main", fixture.work).shouldBeFalse()
}
test("hasNewCommits is true after new commits arrived on origin") {
val fixture = Fixture()
fixture.commitFile(fixture.seed, "change.txt", "change")
fixture.git(fixture.seed, "push", "origin", "main")
service.fetchBranch("main", fixture.work)
service.hasNewCommits("main", fixture.work).shouldBeTrue()
}
test("hasNewCommits is true for a branch that only exists on origin") {
val fixture = Fixture()
fixture.pushNewSeedBranch("feature/x")
service.fetchOrigin(fixture.work)
service.hasNewCommits("feature/x", fixture.work).shouldBeTrue()
}
test("hasNewCommits is false for an unknown branch") {
val fixture = Fixture()
service.hasNewCommits("no-such-branch", fixture.work).shouldBeFalse()
}
test("newOriginBranches lists recent origin-only branches") {
val fixture = Fixture()
fixture.pushNewSeedBranch("feature/x")
service.fetchOrigin(fixture.work)
val branches = service.newOriginBranches(Duration.ofDays(5), fixture.work)
branches shouldContainExactly listOf("feature/x")
}
test("newOriginBranches skips branches whose latest commit is older than maxAge") {
val fixture = Fixture()
val oldDate = mapOf("GIT_AUTHOR_DATE" to "2020-01-01T12:00:00+00:00", "GIT_COMMITTER_DATE" to "2020-01-01T12:00:00+00:00")
fixture.pushNewSeedBranch("stale", environment = oldDate)
service.fetchOrigin(fixture.work)
service.newOriginBranches(Duration.ofDays(5), fixture.work).shouldNotContain("stale")
}
test("checkout switches to an existing local branch") {
val fixture = Fixture()
fixture.git(fixture.work, "switch", "-c", "local-branch")
fixture.git(fixture.work, "switch", "main")
service.checkout("local-branch", fixture.work)
service.currentBranch(fixture.work) shouldBe "local-branch"
}
test("checkout creates a tracking branch from origin") {
val fixture = Fixture()
fixture.pushNewSeedBranch("feature/x")
service.fetchOrigin(fixture.work)
service.checkout("feature/x", fixture.work)
service.currentBranch(fixture.work) shouldBe "feature/x"
val upstream = fixture.git(fixture.work, "for-each-ref", "--format=%(upstream:short)", "refs/heads/feature/x")
upstream.stdout.trim() shouldBe "origin/feature/x"
}
test("resetHardToOrigin discards local commits") {
val fixture = Fixture()
fixture.commitFile(fixture.work, "local.txt", "local only")
val originHead = fixture.git(fixture.work, "rev-parse", "origin/main").stdout.trim()
service.resetHardToOrigin("main", fixture.work)
service.headCommit(fixture.work) shouldBe originHead
}
test("commitTimestamp returns the committer timestamp") {
val fixture = Fixture()
val date = mapOf("GIT_AUTHOR_DATE" to "2024-05-01T10:00:00+00:00", "GIT_COMMITTER_DATE" to "2024-05-01T10:00:00+00:00")
fixture.commitFile(fixture.work, "dated.txt", "dated", environment = date)
val timestamp = service.commitTimestamp(service.headCommit(fixture.work), fixture.work)
timestamp shouldBe Instant.parse("2024-05-01T10:00:00Z")
}
test("currentBranch returns null when HEAD is detached") {
val fixture = Fixture()
fixture.git(fixture.work, "checkout", "--detach")
service.currentBranch(fixture.work).shouldBeNull()
}
test("headCommit returns the full commit hash") {
val fixture = Fixture()
service.headCommit(fixture.work) shouldMatch Regex("[0-9a-f]{40}")
}
}
}