From a427665ce876a9f455d790ba44d7259e306475ab Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 7 Jul 2026 07:10:38 +0200 Subject: [PATCH] implemented 02-git-gateway.md: added Git access layer with credentials bridge, duration parser, and extensive tests --- CLAUDE.md | 2 +- docs/plan/02-git-gateway.md | 18 ++ docs/plan/README.md | 2 +- .../hoennig/gittally/config/DurationParser.kt | 19 ++ .../de/hoennig/gittally/git/GitAskPass.kt | 51 ++++ .../hoennig/gittally/git/GitCommandRunner.kt | 52 +++++ .../de/hoennig/gittally/git/GitService.kt | 188 +++++++++++++-- .../gittally/config/DurationParserTest.kt | 38 +++ .../de/hoennig/gittally/git/GitAskPassTest.kt | 78 +++++++ .../gittally/git/GitCommandRunnerTest.kt | 62 +++++ .../de/hoennig/gittally/git/GitServiceTest.kt | 220 ++++++++++++++++++ 11 files changed, 707 insertions(+), 23 deletions(-) create mode 100644 src/main/kotlin/de/hoennig/gittally/config/DurationParser.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/git/GitAskPass.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/git/GitCommandRunner.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/config/DurationParserTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/git/GitAskPassTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/git/GitCommandRunnerTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 4b1d1fd..ac28ca0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ Three places must stay in sync when config keys change: the `GitTallyConfig` dat ### 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 diff --git a/docs/plan/02-git-gateway.md b/docs/plan/02-git-gateway.md index 88d46ba..e28d33d 100644 --- a/docs/plan/02-git-gateway.md +++ b/docs/plan/02-git-gateway.md @@ -43,3 +43,21 @@ Parse the `newBranchMaxAge` duration format (`5d`, `12h`) in a small dedicated p - `./gradlew ktlintFormat` then `./gradlew build` is green. - 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/`. + 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. diff --git a/docs/plan/README.md b/docs/plan/README.md index 9ef2caf..d04a3b6 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -40,7 +40,7 @@ Revisit them in an ADR if a step uncovers problems. Foundation: - [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 Core engine: diff --git a/src/main/kotlin/de/hoennig/gittally/config/DurationParser.kt b/src/main/kotlin/de/hoennig/gittally/config/DurationParser.kt new file mode 100644 index 0000000..d1f565d --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/config/DurationParser.kt @@ -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 d or h, e.g. 5d or 12h") + val (amount, unit) = match.destructured + return when (unit) { + "d" -> Duration.ofDays(amount.toLong()) + else -> Duration.ofHours(amount.toLong()) + } + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/git/GitAskPass.kt b/src/main/kotlin/de/hoennig/gittally/git/GitAskPass.kt new file mode 100644 index 0000000..7c6cb14 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/git/GitAskPass.kt @@ -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 withAskPass( + account: String, + token: String, + block: (environment: Map) -> 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) + } + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/git/GitCommandRunner.kt b/src/main/kotlin/de/hoennig/gittally/git/GitCommandRunner.kt new file mode 100644 index 0000000..f80bab2 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/git/GitCommandRunner.kt @@ -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 = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() } +} + +class GitCommandException( + command: List, + result: GitCommandResult, +) : RuntimeException( + "Command failed with exit code ${result.exitCode}: ${command.joinToString(" ")}\n${result.stderr.trim()}", + ) + +@Component +class GitCommandRunner { + fun run( + command: List, + workingDir: Path, + environment: Map = 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, + workingDir: Path, + environment: Map = emptyMap(), + ): GitCommandResult { + val result = run(command, workingDir, environment) + if (!result.isSuccess) { + throw GitCommandException(command, result) + } + return result + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt index d5c8688..411e74c 100644 --- a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt +++ b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt @@ -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 = + runner + .runOrThrow(listOf("git", "for-each-ref", "--format=%(refname:strip=2)", "refs/heads"), workingDir) + .lines() + + fun originBranches(workingDir: Path = Paths.get(".")): List = + 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 { + 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 authenticated( + workingDir: Path, + block: (environment: Map) -> 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) } } diff --git a/src/test/kotlin/de/hoennig/gittally/config/DurationParserTest.kt b/src/test/kotlin/de/hoennig/gittally/config/DurationParserTest.kt new file mode 100644 index 0000000..2677f64 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/config/DurationParserTest.kt @@ -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 { DurationParser.parse(value) } + exception.message shouldContain "invalid duration" + } + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/git/GitAskPassTest.kt b/src/test/kotlin/de/hoennig/gittally/git/GitAskPassTest.kt new file mode 100644 index 0000000..6c1a560 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/git/GitAskPassTest.kt @@ -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, + 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 { + GitAskPass.withAskPass("builder", "secret-token") { environment -> + script = Path.of(environment.getValue("GIT_ASKPASS")) + error("boom") + } + } + + script!!.exists().shouldBeFalse() + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/git/GitCommandRunnerTest.kt b/src/test/kotlin/de/hoennig/gittally/git/GitCommandRunnerTest.kt new file mode 100644 index 0000000..fe1d47f --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/git/GitCommandRunnerTest.kt @@ -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 { + 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" + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt b/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt new file mode 100644 index 0000000..ed2e42c --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt @@ -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 = emptyMap(), + ): GitCommandResult = runner.runOrThrow(listOf("git") + args, dir, gitEnvironment + environment) + + fun commitFile( + dir: Path, + name: String, + content: String, + environment: Map = 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 = 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}") + } + } +}