add bootstrapping.md

This commit is contained in:
Michael Hoennig
2026-06-10 17:27:06 +02:00
parent 4943d98b68
commit 8b6c6de1ea
10 changed files with 432 additions and 24 deletions
@@ -1,7 +1,10 @@
package de.hoennig.gittally.commands
import de.hoennig.gittally.git.GitService
import org.springframework.stereotype.Component
import picocli.CommandLine.Command
import java.nio.file.Path
import java.nio.file.Paths
@Component
@Command(
@@ -9,8 +12,141 @@ import picocli.CommandLine.Command
description = ["Initialize GitTally for the current repository"],
mixinStandardHelpOptions = true,
)
class InitCommand : Runnable {
class InitCommand(
private val gitService: GitService,
) : Runnable {
var workingDir: Path = Paths.get(".")
override fun run() {
println("init not yet implemented")
val normalizedWorkingDir = workingDir.toAbsolutePath().normalize()
val root =
try {
gitService.getTopLevel(normalizedWorkingDir)
} catch (e: Exception) {
println("Error: ${e.message}")
return
}
val originUrl = gitService.getOriginUrl(root)
val detected = detectFromUrl(originUrl)
createRepoInstallConfig(root, detected, normalizedWorkingDir)
createProjectConfig(root, detected, normalizedWorkingDir)
}
private fun detectFromUrl(url: String?): DetectedValues {
if (url == null) return DetectedValues()
if (url.startsWith("http")) {
val regex = Regex("""https?://(?:([^@]+)@)?([^/]+)/([^/]+)/([^/.]+)(?:\.git)?""")
val match = regex.find(url)
if (match != null) {
val (user, host, owner, repo) = match.destructured
return DetectedValues(
baseUrl = "https://$host",
owner = owner,
repo = repo,
account = user,
)
}
} else if (url.contains("@") && url.contains(":")) {
// Assume SSH: git@host:owner/repo.git
val regex = Regex("""([^@]+)@([^:]+):([^/]+)/([^/.]+)(?:\.git)?""")
val match = regex.find(url)
if (match != null) {
val (_, host, owner, repo) = match.destructured
return DetectedValues(
baseUrl = "https://$host",
owner = owner,
repo = repo,
account = "", // SSH user 'git' is not the account name we want for HTTPS
)
}
}
return DetectedValues()
}
private fun createRepoInstallConfig(
root: Path,
detected: DetectedValues,
normalizedWorkingDir: Path,
) {
val file = root.resolve(".git/gittally/.gittally.yml")
if (file.toFile().exists()) {
println("${file.toFile().relativeTo(normalizedWorkingDir.toFile())} already exists — not overwritten")
return
}
file.parent.toFile().mkdirs()
val content =
"""
# Machine- or user-specific overrides and secrets. Keys here win over .gittally.yml.
git:
account: "${detected.account}" # technical username for git HTTPS authentication
token: "" # Gitea API token — never commit this
""".trimIndent()
file.toFile().writeText(content + "\n")
println("created ${file.toFile().relativeTo(normalizedWorkingDir.toFile())}")
}
private fun createProjectConfig(
root: Path,
detected: DetectedValues,
normalizedWorkingDir: Path,
) {
val file = root.resolve(".gittally.yml")
if (file.toFile().exists()) {
println("${file.toFile().relativeTo(normalizedWorkingDir.toFile())} already exists — not overwritten")
return
}
val content =
"""
server:
# Public base URL of this GitTally installation — used for all links posted to Gitea.
publicBaseUrl: ""
# Gitea integration for fetching commits and posting build statuses.
gitea:
baseUrl: ${detected.baseUrl} # base URL of the Gitea instance
owner: ${detected.owner} # repository owner (user or organisation) for Gitea API (e.g. status checks)
repo: ${detected.repo} # repository name
statusContext: GitTally # label shown on Gitea commit status checks (default: GitTally)
# Build artifact retention.
artifacts:
# number of builds to keep per branch
retentionPerBranch: 3
# Controls the branch-polling loop.
watcher:
# max commit age for new origin branches to be pulled automatically
newBranchMaxAge: 5d
# Per-branch build configuration.
# Use "default" as the fallback for all branches not listed explicitly.
branches:
default:
# run before each build
cleanCommand: rm -rf build
# shell command for each build
buildCommand: ./gradlew --console=plain --no-daemon test
# directories copied as build artifacts
artifactDirs:
- build/reports
stdoutLog: build.stdout.log # filename for captured stdout
stderrLog: build.stderr.log # filename for captured stderr
autoBuild:
enabled: false # whether to rebuild on schedule
times: ["01:00"] # UTC times HH:MM for scheduled builds
""".trimIndent()
file.toFile().writeText(content + "\n")
println("created ${file.toFile().relativeTo(normalizedWorkingDir.toFile())}")
}
private data class DetectedValues(
val baseUrl: String = "",
val owner: String = "",
val repo: String = "",
val account: String = "",
)
}
@@ -29,7 +29,7 @@ class ConfigLoader {
}
fun loadRaw(workingDir: Path = Paths.get(".")): Map<String, Any?> {
val repoInstall = loadFile(workingDir.resolve(".git/gittally/config.yml").toFile())
val repoInstall = loadFile(workingDir.resolve(".git/gittally/.gittally.yml").toFile())
val project = loadFile(workingDir.resolve(".gittally.yml").toFile())
return deepMerge(project, repoInstall)
}
@@ -2,6 +2,7 @@ package de.hoennig.gittally.config
data class GitTallyConfig(
val server: ServerConfig = ServerConfig(),
val git: GitConfig = GitConfig(),
val gitea: GiteaConfig = GiteaConfig(),
val artifacts: ArtifactsConfig = ArtifactsConfig(),
val watcher: WatcherConfig = WatcherConfig(),
@@ -12,12 +13,15 @@ data class ServerConfig(
val publicBaseUrl: String = "",
)
data class GitConfig(
val account: String = "",
val token: String = "",
)
data class GiteaConfig(
val baseUrl: String = "",
val owner: String = "",
val repo: String = "",
val gitUsername: String = "",
val token: String = "",
val statusContext: String = "GitTally",
)
@@ -0,0 +1,38 @@
package de.hoennig.gittally.git
import org.springframework.stereotype.Service
import java.nio.file.Path
import java.nio.file.Paths
@Service
class GitService {
fun getTopLevel(workingDir: Path = Paths.get(".")): Path {
val process =
ProcessBuilder("git", "rev-parse", "--show-toplevel")
.directory(workingDir.toFile())
.start()
if (process.waitFor() != 0) {
throw RuntimeException("Not a git repository")
}
return Paths.get(
process.inputStream
.bufferedReader()
.readText()
.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
}
return process.inputStream
.bufferedReader()
.readText()
.trim()
}
}
@@ -0,0 +1,97 @@
package de.hoennig.gittally.commands
import de.hoennig.gittally.git.GitService
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.file.shouldExist
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.every
import io.mockk.mockk
import java.nio.file.Files
import java.nio.file.Paths
class InitCommandTest : FunSpec() {
private val gitService = mockk<GitService>()
private val initCommand = InitCommand(gitService)
init {
test("creates config files with auto-detected values") {
val tempDir = Files.createTempDirectory("gittally-init-test")
initCommand.workingDir = tempDir
every { gitService.getTopLevel(tempDir) } returns tempDir
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
initCommand.run()
val projectConfig = tempDir.resolve(".gittally.yml")
projectConfig.toFile().shouldExist()
val projectContent = projectConfig.toFile().readText()
projectContent shouldContain "baseUrl: https://git.example.org"
projectContent shouldContain "owner: my-org"
projectContent shouldContain "repo: my-repo"
val repoConfig = tempDir.resolve(".git/gittally/.gittally.yml")
repoConfig.toFile().shouldExist()
val repoContent = repoConfig.toFile().readText()
repoContent shouldContain "account: \"\"" // no user in https URL
}
test("detects account from https url") {
val tempDir = Files.createTempDirectory("gittally-init-test")
initCommand.workingDir = tempDir
every { gitService.getTopLevel(tempDir) } returns tempDir
every { gitService.getOriginUrl(tempDir) } returns "https://ci-user@git.example.org/my-org/my-repo.git"
initCommand.run()
val repoConfig = tempDir.resolve(".git/gittally/.gittally.yml")
val repoContent = repoConfig.toFile().readText()
repoContent shouldContain "account: \"ci-user\""
}
test("parses ssh url") {
val tempDir = Files.createTempDirectory("gittally-init-test")
initCommand.workingDir = tempDir
every { gitService.getTopLevel(tempDir) } returns tempDir
every { gitService.getOriginUrl(tempDir) } returns "git@git.example.org:my-org/my-repo.git"
initCommand.run()
val projectConfig = tempDir.resolve(".gittally.yml")
val projectContent = projectConfig.toFile().readText()
projectContent shouldContain "baseUrl: https://git.example.org" // fallback to https
projectContent shouldContain "owner: my-org"
projectContent shouldContain "repo: my-repo"
}
test("does not overwrite existing files") {
val tempDir = Files.createTempDirectory("gittally-init-test")
initCommand.workingDir = tempDir
val projectConfig = tempDir.resolve(".gittally.yml")
projectConfig.toFile().writeText("existing: content")
every { gitService.getTopLevel(tempDir) } returns tempDir
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
initCommand.run()
projectConfig.toFile().readText() shouldBe "existing: content"
}
test("reproduces path root mismatch issue") {
val tempDir = Files.createTempDirectory("gittally-init-test").toAbsolutePath().normalize()
initCommand.workingDir = Paths.get(".") // Set to relative path as in real app
// We need to mock getTopLevel to return the absolute path
every { gitService.getTopLevel(any()) } returns tempDir
every { gitService.getOriginUrl(any()) } returns "https://git.example.org/my-org/my-repo.git"
// This should not throw IllegalArgumentException
initCommand.run()
}
}
}
@@ -32,7 +32,6 @@ class ConfigLoaderTest : FunSpec() {
val config = loader.load(dir)
config.gitea.owner shouldBe "my-org"
config.gitea.repo shouldBe "my-repo"
config.branches["default"]!!.buildCommand shouldBe BranchConfig().buildCommand
}
test("repo install config overrides project config for same keys") {
@@ -40,20 +39,23 @@ class ConfigLoaderTest : FunSpec() {
dir.resolve(".gittally.yml").toFile().writeText(
"""
gitea:
owner: my-org
owner: original-org
git:
token: from-project
""".trimIndent(),
)
dir.resolve(".git/gittally").toFile().mkdirs()
dir.resolve(".git/gittally/config.yml").toFile().writeText(
dir.resolve(".git/gittally/.gittally.yml").toFile().writeText(
"""
gitea:
owner: override-org
git:
token: from-repo-install
""".trimIndent(),
)
val config = loader.load(dir)
config.gitea.owner shouldBe "my-org"
config.gitea.token shouldBe "from-repo-install"
config.gitea.owner shouldBe "override-org"
config.git.token shouldBe "from-repo-install"
}
test("loadRaw only returns explicitly set values") {
@@ -108,6 +110,7 @@ class ConfigLoaderTest : FunSpec() {
test("toYaml serializes GitTallyConfig with all sections") {
val yaml = loader.toYaml(GitTallyConfig())
yaml shouldContain "server:"
yaml shouldContain "git:"
yaml shouldContain "gitea:"
yaml shouldContain "artifacts:"
yaml shouldContain "watcher:"