implemented 02-git-gateway.md: added Git access layer with credentials bridge, duration parser, and extensive tests
This commit is contained in:
@@ -56,7 +56,7 @@ Three places must stay in sync when config keys change: the `GitTallyConfig` dat
|
|||||||
|
|
||||||
### Git Access
|
### Git Access
|
||||||
|
|
||||||
`GitService` shells out to the `git` CLI via `ProcessBuilder` (no JGit). Commands that need repo information take it as a constructor dependency so tests can mock it.
|
`GitService` shells out to the `git` CLI via `GitCommandRunner` (a thin `ProcessBuilder` wrapper; no JGit). Commands that need repo information take it as a constructor dependency so tests can mock it. HTTPS fetches authenticate via a temporary, secret-free `GIT_ASKPASS` script (`GitAskPass`) with credentials from config passed through environment variables.
|
||||||
|
|
||||||
### Package Structure
|
### Package Structure
|
||||||
|
|
||||||
|
|||||||
@@ -43,3 +43,21 @@ Parse the `newBranchMaxAge` duration format (`5d`, `12h`) in a small dedicated p
|
|||||||
|
|
||||||
- `./gradlew ktlintFormat` then `./gradlew build` is green.
|
- `./gradlew ktlintFormat` then `./gradlew build` is green.
|
||||||
- No temp askpass files leak (created per invocation or cleaned via try/finally; legacy leaked them).
|
- No temp askpass files leak (created per invocation or cleaned via try/finally; legacy leaked them).
|
||||||
|
|
||||||
|
## Execution Notes (done 2026-07-07)
|
||||||
|
|
||||||
|
Implemented as specified; build green, 34 new tests
|
||||||
|
(`GitCommandRunnerTest`, `GitAskPassTest`, `DurationParserTest`, `GitServiceTest` with fixture repos).
|
||||||
|
Deviations and details:
|
||||||
|
|
||||||
|
- `resetHardToOrigin(branch)` only runs `git reset --hard origin/<branch>`.
|
||||||
|
The fetch half of legacy `pull_branch_if_possible` is the separate `fetchBranch(branch)`; the watcher/builder composes both.
|
||||||
|
- `GitService` gained a `ConfigLoader` constructor dependency to resolve `git.account`/`git.token` for the askpass bridge.
|
||||||
|
Config is loaded from the repo top level, so fetches work from subdirectories too.
|
||||||
|
- The askpass script (`GitAskPass`) contains no secrets; credentials are passed via environment variables.
|
||||||
|
The script file is created per invocation with owner-only permissions and deleted in `finally`.
|
||||||
|
- `GIT_TERMINAL_PROMPT=0` is set on all fetches, including unauthenticated ones, so a fetch can never hang on a credential prompt (legacy only disabled prompts when a token was configured).
|
||||||
|
- `fetchOrigin()` uses `fetch --prune origin`, matching the legacy main-loop fetch (line 4969).
|
||||||
|
- The duration parser lives at `config/DurationParser.kt` since it parses the `watcher.newBranchMaxAge` config format.
|
||||||
|
Lowercase `d`/`h` only, like legacy.
|
||||||
|
- `GitService` methods take the target repo as a trailing `workingDir: Path = Paths.get(".")` parameter, keeping the pre-existing convention.
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ Revisit them in an ADR if a step uncovers problems.
|
|||||||
Foundation:
|
Foundation:
|
||||||
|
|
||||||
- [x] `01-build-state-domain.md` — build result domain model and persistent repository
|
- [x] `01-build-state-domain.md` — build result domain model and persistent repository
|
||||||
- [ ] `02-git-gateway.md` — full git access layer (fetch, branches, commits, checkout)
|
- [x] `02-git-gateway.md` — full git access layer (fetch, branches, commits, checkout)
|
||||||
- [ ] `03-gitea-client.md` — Gitea API client for commit statuses
|
- [ ] `03-gitea-client.md` — Gitea API client for commit statuses
|
||||||
|
|
||||||
Core engine:
|
Core engine:
|
||||||
|
|||||||
@@ -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
|
package de.hoennig.gittally.git
|
||||||
|
|
||||||
|
import de.hoennig.gittally.config.ConfigLoader
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class GitService {
|
class GitService(
|
||||||
|
private val runner: GitCommandRunner,
|
||||||
|
private val configLoader: ConfigLoader,
|
||||||
|
) {
|
||||||
fun getTopLevel(workingDir: Path = Paths.get(".")): Path {
|
fun getTopLevel(workingDir: Path = Paths.get(".")): Path {
|
||||||
val process =
|
val result = runner.run(listOf("git", "rev-parse", "--show-toplevel"), workingDir)
|
||||||
ProcessBuilder("git", "rev-parse", "--show-toplevel")
|
if (!result.isSuccess) {
|
||||||
.directory(workingDir.toFile())
|
|
||||||
.start()
|
|
||||||
if (process.waitFor() != 0) {
|
|
||||||
throw RuntimeException("Not a git repository")
|
throw RuntimeException("Not a git repository")
|
||||||
}
|
}
|
||||||
return Paths.get(
|
return Paths.get(result.stdout.trim())
|
||||||
process.inputStream
|
|
||||||
.bufferedReader()
|
|
||||||
.readText()
|
|
||||||
.trim(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getOriginUrl(workingDir: Path = Paths.get(".")): String? {
|
fun getOriginUrl(workingDir: Path = Paths.get(".")): String? {
|
||||||
val process =
|
val result = runner.run(listOf("git", "remote", "get-url", "origin"), workingDir)
|
||||||
ProcessBuilder("git", "remote", "get-url", "origin")
|
return if (result.isSuccess) result.stdout.trim() else null
|
||||||
.directory(workingDir.toFile())
|
|
||||||
.start()
|
|
||||||
if (process.waitFor() != 0) {
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
return process.inputStream
|
|
||||||
.bufferedReader()
|
fun fetchOrigin(workingDir: Path = Paths.get(".")) {
|
||||||
.readText()
|
authenticated(workingDir) { environment ->
|
||||||
|
runner.runOrThrow(listOf("git", "fetch", "--prune", "origin"), workingDir, environment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
.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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user