From 8b6c6de1ea564891242281763fe20b1bf34baeae Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Wed, 10 Jun 2026 17:15:33 +0200 Subject: [PATCH] add bootstrapping.md --- .idea/misc.xml | 3 + docs/GitTally-Konzept.md | 10 +- docs/bootstrapping.md | 127 ++++++++++++++++ docs/configuration.md | 18 +-- .../hoennig/gittally/commands/InitCommand.kt | 140 +++++++++++++++++- .../hoennig/gittally/config/ConfigLoader.kt | 2 +- .../hoennig/gittally/config/GitTallyConfig.kt | 8 +- .../de/hoennig/gittally/git/GitService.kt | 38 +++++ .../gittally/commands/InitCommandTest.kt | 97 ++++++++++++ .../gittally/config/ConfigLoaderTest.kt | 13 +- 10 files changed, 432 insertions(+), 24 deletions(-) create mode 100644 docs/bootstrapping.md create mode 100644 src/main/kotlin/de/hoennig/gittally/git/GitService.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt diff --git a/.idea/misc.xml b/.idea/misc.xml index 08896a7..388f853 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -4,6 +4,9 @@ + + + diff --git a/docs/GitTally-Konzept.md b/docs/GitTally-Konzept.md index 1fd0ec9..bf459c4 100644 --- a/docs/GitTally-Konzept.md +++ b/docs/GitTally-Konzept.md @@ -127,7 +127,7 @@ Jeder Build erhält einen eigenen temporären Worktree. 1. Eingebaute Defaults 2. Globale Server-Konfiguration -3. Repository-Installation (.git/gittally/config.yml) +3. Repository-Installation (.git/gittally/.gittally.yml) 4. Projektkonfiguration (.gittally.yml) 5. Branchprofile @@ -143,20 +143,20 @@ Jeder Build erhält einen eigenen temporären Worktree. ### Initialisierung ```bash -java -jar gittally.jar init +java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar init ``` ### Serverstart ```bash -java -jar gittally.jar server +java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar server ``` ### Konfigurationsanzeige ```bash -java -jar gittally.jar config:print -java -jar gittally.jar config:print --full +java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar config:print +java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar config:print --full ``` ## Erweiterungen diff --git a/docs/bootstrapping.md b/docs/bootstrapping.md new file mode 100644 index 0000000..2ba6e66 --- /dev/null +++ b/docs/bootstrapping.md @@ -0,0 +1,127 @@ +# GitTally Bootstrapping + +Bootstrapping prepares a git repository for use with GitTally. +It creates the config files described in [configuration.md](configuration.md) and optionally installs GitTally as a system service. + +Run `init` once per repository, from within a checked-out working tree. + +## Prerequisites + +- Git repository with at least one commit +- A remote named `origin` (used for auto-detection) +- Java runtime available (JRE 21) + +## Running `init` + +First, in ``, build the application to generate the executable JAR file: + +```bash +./gradlew build +``` + +Then run `init` using the generated JAR (not the `-plain.jar`): + +```bash +java -jar /build/libs/gittally-0.1.0-SNAPSHOT.jar init +``` + +`init` performs the following steps in order: + +### 1. Detect the Repository Root + +GitTally resolves the repository root by running `git rev-parse --show-toplevel`. +If the current directory is not inside a git repository, `init` exits with an error. + +### 2. Auto-detect Gitea Connection from `origin` + +If `gitea.baseUrl`, `gitea.owner`, and `gitea.repo` are already set in `.gittally.yml`, these values are used. + +Otherwise, GitTally inspects the `origin` remote URL and derives the Gitea connection defaults: + +| Origin URL form | Detected values | +|--------------------------------------------|----------------------------------------| +| `https://git.example.org/my-org/my-repo` | baseUrl, owner, repo | +| `git@git.example.org:my-org/my-repo.git` | baseUrl, owner, repo | + +The `.git` suffix is stripped from the repo name. The username embedded in HTTPS URLs +(e.g. `https://user@git.example.org/…`) is used as the default `git.account`. + +- **`gitea.owner`**: The Gitea user or organization owning the repository. Used for Gitea API operations, such as reporting build status checks. +- **`git.account`**: The technical username used for git HTTPS authentication. + +### 3. Create the Repo-Install Config + +Creates `.git/gittally/.gittally.yml` (and its parent directory if needed). +This file is **never committed** to the repository and is used for all branches, +as long as not overridden by a project config. + +If the file already exists, `init` prints a notice and leaves it untouched. + +The generated file contains the machine-local secrets with auto-detected values pre-filled: + +```yaml +git: + account: + token: # paste your Gitea API token here +``` + +### 4. Create the Branch/Project Config + +Creates `.gittally.yml` in the repository root with project-level defaults. + +If the file already exists, `init` prints a notice and leaves it untouched. + +The generated file is a commented template based on the defaults documented in +[configuration.md](configuration.md) and includes the auto-detected Gitea settings: + +```yaml +gitea: + baseUrl: + owner: + repo: + +... +``` + +Then, you have to configure *gitTally* by amending this config file according to [configuration.md](configuration.md). + +## Output + +`init` prints one line per action taken: + +``` +created .git/gittally/.gittally.yml +created .gittally.yml +``` + +Or, when files already exist: + +``` +.git/gittally/.gittally.yml already exists — not overwritten +.gittally.yml already exists — not overwritten +``` + +## Future: Docker-based Deployment + +GitTally is intended to run on Hostsharing Container Server environments, which provide Docker +but no Java runtime. A later development step will add a Docker image distribution where: + +- GitTally itself runs as a Docker container (image bundles the JRE + JAR) +- Builds are spawned by mounting the host Docker socket (`/var/run/docker.sock`) +- `init` then optionally generates a `docker-compose.yml`, a secrets env file, and a systemd unit + that starts the Compose stack at boot + +Until then, a Java runtime must be available on the host. + +## Next Steps After `init` + +1. Open `.git/gittally/.gittally.yml` and set `git.token` and `git.account`. +2. Review `.gittally.yml` and add/adjust any branch build settings. +3. Verify the effective configuration: + ```bash + java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar config:print --full + ``` +4. Start the server: + ```bash + java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar server + ``` diff --git a/docs/configuration.md b/docs/configuration.md index 072b6d3..bd6725c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,15 +7,15 @@ GitTally is configured via YAML files. Settings are merged from two sources in o | Layer | Path | Committed to Git | Purpose | |--------------------------|----------------------------|------------------|----------------------------------------------| | Project config | `.gittally.yml` | Yes | Shared team settings | -| Repo installation config | `.git/gittally/config.yml` | No | Machine- or user-specific overrides, secrets | +| Repo installation config | `.git/gittally/.gittally.yml` | No | Machine- or user-specific overrides, secrets | -The repo install config (`.git/gittally/config.yml`) wins on any key present in both files. Typically used to set `gitea.token` without committing it. +The repo install config (`.git/gittally/.gittally.yml`) wins on any key present in both files. Typically used to set `git.token` and `git.account` without committing them. ## Inspect the Effective Config ```bash -java -jar gittally.jar config:print # only explicitly set values -java -jar gittally.jar config:print --full # all values including defaults +java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar config:print # only explicitly set values +java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar config:print --full # all values including defaults ``` ## `.gittally.yml` @@ -30,7 +30,7 @@ server: # Gitea integration for fetching commits and posting build statuses. gitea: baseUrl: https://git.example.org # base URL of the Gitea instance - owner: my-org # repository owner (user or organisation) + owner: my-org # repository owner (user or organisation) for Gitea API (e.g. status checks) repo: my-repo # repository name statusContext: GitTally # label shown on Gitea commit status checks (default: GitTally) @@ -79,11 +79,11 @@ branches: - "04:00" ``` -## `.git/gittally/config.yml` (not committed) +## `.git/gittally/.gittally.yml` (not committed) ```yaml -# Machine- or user-specific overrides. Keys here win over .gittally.yml. -gitea: - gitUsername: my-user # git username for HTTPS authentication +# Machine- or user-specific overrides and secrets. Keys here win over .gittally.yml. +git: + account: my-user # technical username for git HTTPS authentication token: glpat-xxxxxxxxxxxxxxxxxxxx # Gitea API token — never commit this ``` diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index 55cb684..e6f0456 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -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 = "", + ) } diff --git a/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt b/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt index 7a306ec..ca8de2b 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt @@ -29,7 +29,7 @@ class ConfigLoader { } fun loadRaw(workingDir: Path = Paths.get(".")): Map { - 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) } diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt index dca09c7..0c2bceb 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt @@ -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", ) diff --git a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt new file mode 100644 index 0000000..d5c8688 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt @@ -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() + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt b/src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt new file mode 100644 index 0000000..65bff1b --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt @@ -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() + 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() + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt index 3bbaac2..3b92510 100644 --- a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt @@ -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:"