Files
werkator/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt
T
mhoennigandClaude 9b992c71ed Fast-forward local branch refs at the end of each poll cycle
Build worktrees share the primary checkout's .git, so build tools can read
refs/heads there. Since the rewrite never moved those refs, they stayed frozen at
the last checkout: hs.hsadmin.ng's prQuickCheck compares master with
origin/master and therefore failed every build once origin moved on.

The sync runs after the enqueue decision on purpose — a local ref lagging behind
origin is exactly how the watcher recognizes new commits, so keeping the refs in
sync earlier (cron job, mirroring refspec, or this step moved up) would silence
the branch instead of building it.

Fast-forward only, as a compare-and-swap against the commit just read: diverged
or ahead branches stay untouched, and the checked-out branch is advanced with
merge --ff-only, which refuses to overwrite conflicting uncommitted changes.
Switched off with watcher.fastForwardLocalRefs: false.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 11:29:42 +02:00

294 lines
14 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.
builds:
# how many branches may build at the same time (at most one build per branch regardless)
maxConcurrent: 1
# 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
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]) }
}
}
}