implemented 02-git-gateway.md: added Git access layer with credentials bridge, duration parser, and extensive tests
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user