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:"