A branch's committed .gittally.yml describes that branch's CI, so it wins over the project and repo-install config — the `builds` section included. Pinning it was wrong: a new build definition can only be tried out by committing it on a branch, and pinned it neither took effect at build time nor existed for the watcher, so the job silently never ran. The watcher now decides per branch from that branch's own definitions, reading its committed config via `git show` and caching it by head commit, so the read happens only when the branch moved; an unreadable config falls back to the primary definitions instead of failing the poll cycle. A branch's definitions are evaluated for that branch alone, so a definition committed on one branch can never trigger builds of another. The pinned set is reduced to what does not describe this branch's build: secrets (`git`), the host and repository sections (`server`, `gitea`, `executor`, `watcher`), the sandbox policy (`docker.enabled`/`network`), and the trust gate (`requirePullRequest`). Letting a branch set its own build command through a definition grants no new power — `branches.*. buildCommand` always allowed exactly that — while the sandbox and the gate decide whether untrusted branch code runs on the host at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
307 lines
15 KiB
Kotlin
307 lines
15 KiB
Kotlin
package de.hoennig.gittally.commands
|
|
|
|
import de.hoennig.gittally.SecretFiles
|
|
import de.hoennig.gittally.git.GitService
|
|
import org.springframework.stereotype.Component
|
|
import picocli.CommandLine.Command
|
|
import picocli.CommandLine.Option
|
|
import java.nio.file.Path
|
|
import java.nio.file.Paths
|
|
|
|
@Component
|
|
@Command(
|
|
name = "init",
|
|
description = ["Initialize GitTally for the current repository"],
|
|
mixinStandardHelpOptions = true,
|
|
)
|
|
class InitCommand(
|
|
private val gitService: GitService,
|
|
) : Runnable {
|
|
var workingDir: Path = Paths.get(".")
|
|
|
|
@Option(
|
|
names = ["--systemd"],
|
|
description = ["also generate a systemd user unit that runs `gittally server` for this repository"],
|
|
)
|
|
var systemd: Boolean = false
|
|
|
|
/** Replaceable for tests: the jar this JVM was started from, or null when not run via `java -jar`. */
|
|
internal var jarPathResolver: () -> Path? = { runningJarPath() }
|
|
|
|
/** Replaceable for tests: the `java` binary of the current JVM. */
|
|
internal var javaExecutableResolver: () -> Path = { Paths.get(System.getProperty("java.home"), "bin", "java") }
|
|
|
|
override fun run() {
|
|
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)
|
|
if (systemd) {
|
|
createSystemdFiles(root, 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
|
|
}
|
|
SecretFiles.createDirectoriesOwnerOnly(file.parent)
|
|
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()
|
|
// this is where the operator pastes the Gitea token, so it must never exist
|
|
// world-readable — on a shared host that would hand out git push access
|
|
SecretFiles.writeOwnerOnly(file, 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: ""
|
|
# HTTP port of the `server` subcommand
|
|
port: 18080
|
|
# bind address of the `server` subcommand; loopback only, because the UI and the
|
|
# API are unauthenticated — use 0.0.0.0 only without a reverse proxy in front
|
|
# (and with the managed nginx below, which reaches GitTally from its container)
|
|
bindAddress: 127.0.0.1
|
|
# optional Impressum (legal disclosure) link in the web UI footer; empty hides the link
|
|
impressumUrl: ""
|
|
# Opt-in managed nginx+certbot Docker container for HTTPS, for hosts without
|
|
# a usable reverse proxy (see docs/deployment.md). Off by default.
|
|
nginx:
|
|
enabled: false # manage an nginx container with Let's Encrypt certificates
|
|
serverName: "" # public DNS name served by nginx; required when enabled
|
|
httpPort: 8080 # host port published as nginx port 80
|
|
httpsPort: 8443 # host port published as nginx port 443
|
|
upstreamHost: "" # host nginx proxies to; empty = serverName
|
|
containerName: "" # empty = gittally-nginx-<repo-name>
|
|
stateDir: "" # empty = XDG_STATE_HOME (or ~/.local/state) + /gittally/nginx/<repo-key>
|
|
letsencryptEmail: "" # e-mail for the Let's Encrypt account; empty registers without one
|
|
|
|
# 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 execution settings, enforced for all builds regardless of their trigger.
|
|
executor:
|
|
# how many builds may run at the same time (at most one build per branch regardless)
|
|
maxConcurrent: 1
|
|
|
|
# Named build definitions (jobs) over the branches; every key names a build.
|
|
# A branch may add or override definitions in its own committed .gittally.yml —
|
|
# they then apply to that branch alone, so a new job can be tried out on a branch.
|
|
builds:
|
|
# Example definition — triggers (onPush/atTimes), branch selector
|
|
# (branches/activeWithin), and overrides of the branch settings:
|
|
# pitest:
|
|
# atTimes: ["01:00"] # daily UTC times HH:MM
|
|
# branches: ["master"] # names or glob patterns; default: all branches
|
|
# activeWithin: 24h # only branches with recent commits
|
|
# buildCommand: ./gradlew piTestFull
|
|
|
|
# Build artifact storage and retention.
|
|
artifacts:
|
|
# root directory for stored artifacts; empty = XDG_STATE_HOME (or ~/.local/state) + /gittally/artifacts/<repo-key>
|
|
rootDir: ""
|
|
# number of builds to keep per branch
|
|
retentionPerBranch: 3
|
|
# additionally drop builds older than this age (h/d suffix, e.g. 30d); empty = no age limit;
|
|
# a branch's newest build is never age-pruned
|
|
retentionMaxAge: ""
|
|
# keep each branch's latest green build beyond the retention limits,
|
|
# so the permanent /branches/<branch-key>/... artifact URLs stay valid while newer builds fail
|
|
keepLatestGreen: true
|
|
|
|
# Controls the branch-polling loop.
|
|
watcher:
|
|
# delay between poll cycles (s/m/h/d suffix)
|
|
pollInterval: 10s
|
|
# max commit age for new origin branches to be pulled automatically
|
|
newBranchMaxAge: 5d
|
|
# honor branches.<name>.requirePullRequest; set false for a plain git origin
|
|
# without pull-request refs (refs/pull/*/head) — gated branches then build on new commits
|
|
pullRequestGate: true
|
|
# after enqueueing, fast-forward the primary checkout's local branch refs to origin,
|
|
# so build tools reading the shared .git see the same refs (diverged branches stay untouched)
|
|
fastForwardLocalRefs: true
|
|
|
|
# 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
|
|
# build only while the branch head matches a pull-request head on origin
|
|
# (refs/pull/*/head — read via plain git, no API token needed)
|
|
requirePullRequest: false
|
|
# DEPRECATED: define a build with atTimes in the builds section instead
|
|
autoBuild:
|
|
enabled: false # whether to rebuild on schedule
|
|
times: ["01:00"] # UTC times HH:MM for scheduled builds
|
|
docker:
|
|
enabled: false # run clean/build commands in a Docker container instead of natively
|
|
image: "" # image for the build container; required when enabled
|
|
dockerfile: "" # Dockerfile to (re)build the image from when missing or stale; empty pulls the image as-is
|
|
context: "." # Docker build context used with dockerfile
|
|
network: "" # Docker network mode for the build container; empty = Docker default
|
|
env: {} # additional environment variables set inside the build container
|
|
""".trimIndent()
|
|
file.toFile().writeText(content + "\n")
|
|
println("created ${file.toFile().relativeTo(normalizedWorkingDir.toFile())}")
|
|
}
|
|
|
|
private fun createSystemdFiles(
|
|
root: Path,
|
|
normalizedWorkingDir: Path,
|
|
) {
|
|
val jarPath = jarPathResolver()
|
|
if (jarPath == null) {
|
|
println("Error: cannot determine the GitTally jar path — run `init --systemd` via `java -jar <path-to>/gittally.jar`")
|
|
return
|
|
}
|
|
val gittallyDir = root.resolve(".git/gittally")
|
|
SecretFiles.createDirectoriesOwnerOnly(gittallyDir)
|
|
val unitName = SystemdServiceFiles.unitName(root)
|
|
val unitFile = gittallyDir.resolve(unitName)
|
|
val envFile = gittallyDir.resolve(SystemdServiceFiles.ENV_FILE_NAME)
|
|
|
|
unitFile.toFile().writeText(
|
|
SystemdServiceFiles.unitFileContent(
|
|
repoRoot = root,
|
|
javaExecutable = javaExecutableResolver(),
|
|
jarPath = jarPath,
|
|
envFile = envFile,
|
|
),
|
|
)
|
|
println("created ${unitFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
|
|
|
|
if (envFile.toFile().exists()) {
|
|
println("${envFile.toFile().relativeTo(normalizedWorkingDir.toFile())} already exists — not overwritten")
|
|
} else {
|
|
envFile.toFile().writeText(SystemdServiceFiles.envFileContent())
|
|
println("created ${envFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
|
|
}
|
|
|
|
// the nightly Docker cleanup is host-global: every repository generates the same
|
|
// units, so with several GitTally instances the symlinks simply coincide
|
|
val pruneServiceFile = gittallyDir.resolve(SystemdServiceFiles.PRUNE_SERVICE_NAME)
|
|
val pruneTimerFile = gittallyDir.resolve(SystemdServiceFiles.PRUNE_TIMER_NAME)
|
|
pruneServiceFile.toFile().writeText(SystemdServiceFiles.pruneServiceContent())
|
|
println("created ${pruneServiceFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
|
|
pruneTimerFile.toFile().writeText(SystemdServiceFiles.pruneTimerContent())
|
|
println("created ${pruneTimerFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
|
|
|
|
println("install and start the service and the nightly Docker cleanup with:")
|
|
println(" ln -sf $unitFile ~/.config/systemd/user/$unitName")
|
|
println(" ln -sf $pruneServiceFile ~/.config/systemd/user/${SystemdServiceFiles.PRUNE_SERVICE_NAME}")
|
|
println(" ln -sf $pruneTimerFile ~/.config/systemd/user/${SystemdServiceFiles.PRUNE_TIMER_NAME}")
|
|
println(" systemctl --user daemon-reload")
|
|
println(" systemctl --user enable --now $unitName")
|
|
println(" systemctl --user enable --now ${SystemdServiceFiles.PRUNE_TIMER_NAME}")
|
|
}
|
|
|
|
private data class DetectedValues(
|
|
val baseUrl: String = "",
|
|
val owner: String = "",
|
|
val repo: String = "",
|
|
val account: String = "",
|
|
)
|
|
|
|
companion object {
|
|
/**
|
|
* The jar this JVM was started from. With `java -jar` the launch command starts with the
|
|
* jar path; as a fallback (e.g. custom launchers) the Spring Boot loader's nested code
|
|
* source URL contains it. Null when running from classes (IDE, Gradle, tests).
|
|
*/
|
|
private fun runningJarPath(): Path? {
|
|
val launchCommand = System.getProperty("sun.java.command").orEmpty().substringBefore(' ')
|
|
if (launchCommand.endsWith(".jar")) {
|
|
return Paths.get(launchCommand).toAbsolutePath().normalize()
|
|
}
|
|
val codeSource =
|
|
InitCommand::class.java.protectionDomain.codeSource
|
|
?.location
|
|
?.toString()
|
|
.orEmpty()
|
|
return Regex("""(/[^!]*?\.jar)""")
|
|
.find(codeSource)
|
|
?.let { Paths.get(it.groupValues[1]) }
|
|
}
|
|
}
|
|
}
|