Files
werkator/src/main/kotlin/de/hoennig/gittally/git/GitService.kt
T
mhoennigandClaude Fable 5 0e8db18e69 Build definitions with onPush/atTimes replace branch-owned schedules
ADR 0007: the YAML builds section (next to the reserved maxConcurrent
key) defines named builds (jobs) with onPush/atTimes triggers, branch
selectors (name globs, activeWithin age filter), and build-setting
overrides applied last over the merged branch config. The implicit
default build (onPush over all branches) preserves the previous
behavior; the section is pinned against the worktree layer.

Results record the job name; restart, retry, and startup recovery
re-run by it, resolving settings from the current config. A non-default
build records under the <branch>@<build> pool with its own row,
retention count, and permanent latest-green link.

branches.*.autoBuild stays as a deprecated alias (plain times only);
the unreleased-in-practice v0.9.13 per-slot buildCommand/name syntax is
removed again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 20:06:02 +02:00

314 lines
13 KiB
Kotlin

package de.hoennig.gittally.git
import de.hoennig.gittally.config.ConfigLoader
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.nio.file.Path
import java.nio.file.Paths
import java.time.Duration
import java.time.Instant
import java.time.OffsetDateTime
@Service
class GitService(
private val runner: GitCommandRunner,
private val configLoader: ConfigLoader,
) {
private val log = LoggerFactory.getLogger(GitService::class.java)
fun getTopLevel(workingDir: Path = Paths.get(".")): Path {
val result = runner.run(listOf("git", "rev-parse", "--show-toplevel"), workingDir)
if (!result.isSuccess) {
throw RuntimeException("Not a git repository")
}
return Paths.get(result.stdout.trim())
}
fun getOriginUrl(workingDir: Path = Paths.get(".")): String? {
val result = runner.run(listOf("git", "remote", "get-url", "origin"), workingDir)
return if (result.isSuccess) result.stdout.trim() else null
}
fun fetchOrigin(workingDir: Path = Paths.get(".")) {
authenticated(workingDir) { environment ->
runner.runOrThrow(listOf("git", "fetch", "--prune", "origin"), workingDir, environment)
}
}
fun fetchBranch(
branch: String,
workingDir: Path = Paths.get("."),
) {
authenticated(workingDir) { environment ->
// `--` guards against refnames starting with `-` being read as git options
runner.runOrThrow(listOf("git", "fetch", "origin", "--", branch), workingDir, environment)
}
}
fun localBranches(workingDir: Path = Paths.get(".")): List<String> =
runner
.runOrThrow(listOf("git", "for-each-ref", "--format=%(refname:strip=2)", "refs/heads"), workingDir)
.lines()
fun originBranches(workingDir: Path = Paths.get(".")): List<String> =
runner
.runOrThrow(listOf("git", "for-each-ref", "--format=%(refname:strip=3)", "refs/remotes/origin"), workingDir)
.lines()
.filter { it != "HEAD" }
/** All origin branches with their head commit, in one git call; refnames cannot contain spaces. */
fun originBranchHeads(workingDir: Path = Paths.get(".")): Map<String, String> =
runner
.runOrThrow(
listOf("git", "for-each-ref", "--format=%(refname:strip=3) %(objectname)", "refs/remotes/origin"),
workingDir,
).lines()
.map { it.substringBeforeLast(' ') to it.substringAfterLast(' ') }
.filter { (branch, _) -> branch != "HEAD" }
.toMap()
/**
* Head commits of the remote's pull-request refs (`refs/pull/<n>/head`), which Gitea
* exposes to plain git clients — no API token needed. A branch whose head appears
* here has a pull request open for exactly this commit.
*/
fun pullRequestHeads(workingDir: Path = Paths.get(".")): Set<String> =
authenticated(workingDir) { environment ->
runner.runOrThrow(listOf("git", "ls-remote", "origin", "refs/pull/*/head"), workingDir, environment)
}.lines()
.map { it.substringBefore('\t') }
.toSet()
/**
* A branch has new commits when its origin counterpart is ahead of the local branch,
* or when it exists only on origin.
*/
fun hasNewCommits(
branch: String,
workingDir: Path = Paths.get("."),
): Boolean {
val originRef = "refs/remotes/origin/$branch"
if (!refExists("refs/heads/$branch", workingDir)) {
return refExists(originRef, workingDir)
}
val upstream =
runner
.runOrThrow(listOf("git", "for-each-ref", "--format=%(upstream)", "refs/heads/$branch"), workingDir)
.stdout
.trim()
val compareTo =
when {
upstream.isNotEmpty() -> upstream
refExists(originRef, workingDir) -> originRef
else -> return false
}
if (!refExists(compareTo, workingDir)) {
return false
}
val count =
runner
.runOrThrow(listOf("git", "rev-list", "--count", "refs/heads/$branch..$compareTo"), workingDir)
.stdout
.trim()
return count.toLong() > 0
}
/** Origin branches without a local counterpart whose latest commit is younger than [maxAge]. */
fun newOriginBranches(
maxAge: Duration,
workingDir: Path = Paths.get("."),
): List<String> {
val cutoff = Instant.now().minus(maxAge)
val local = localBranches(workingDir).toSet()
return runner
.runOrThrow(
listOf(
"git",
"for-each-ref",
"--sort=-committerdate",
"--format=%(refname:strip=3) %(committerdate:unix)",
"refs/remotes/origin",
),
workingDir,
).lines()
.mapNotNull { line ->
val branch = line.substringBeforeLast(' ')
val epochSeconds = line.substringAfterLast(' ').toLongOrNull() ?: return@mapNotNull null
branch to Instant.ofEpochSecond(epochSeconds)
}.filter { (branch, committedAt) ->
branch != "HEAD" && branch !in local && committedAt >= cutoff
}.map { (branch, _) -> branch }
}
/** Committer timestamps of all origin branch heads, in one git call; used by `activeWithin` build selectors. */
fun originBranchCommitTimes(workingDir: Path = Paths.get(".")): Map<String, Instant> =
runner
.runOrThrow(
listOf("git", "for-each-ref", "--format=%(refname:strip=3) %(committerdate:unix)", "refs/remotes/origin"),
workingDir,
).lines()
.mapNotNull { line ->
val branch = line.substringBeforeLast(' ')
val epochSeconds = line.substringAfterLast(' ').toLongOrNull() ?: return@mapNotNull null
if (branch == "HEAD") null else branch to Instant.ofEpochSecond(epochSeconds)
}.toMap()
/** Switches to an existing local branch, or creates a tracking branch from origin. */
fun checkout(
branch: String,
workingDir: Path = Paths.get("."),
) {
if (refExists("refs/heads/$branch", workingDir)) {
runner.runOrThrow(listOf("git", "switch", "--", branch), workingDir)
} else {
runner.runOrThrow(listOf("git", "switch", "--track", "-c", branch, "refs/remotes/origin/$branch"), workingDir)
}
}
/**
* Fast-forwards local branch refs to their origin counterparts and returns the
* branches whose ref moved. Build worktrees share the primary checkout's `.git`,
* so tools running inside a build see these refs — checks that compare a local
* branch against its origin counterpart (hs.hsadmin.ng's `prQuickCheck` compares
* `master` with `origin/master`) otherwise fail on every build once origin moves on.
*
* Strictly non-destructive: a local ref that is not an ancestor of its origin
* counterpart (diverged, or ahead) is left alone, the ref update is a
* compare-and-swap against the commit just read, and the branch checked out in
* [workingDir] is advanced with `merge --ff-only`, which refuses to run over
* conflicting uncommitted changes.
*
* Call this only *after* the poll cycle picked its due branches: a local ref lagging
* behind origin is the watcher's change signal ([hasNewCommits], `newOriginBranches`),
* so syncing beforehand would silence the branch instead of building it.
*/
fun fastForwardLocalBranches(workingDir: Path = Paths.get(".")): List<String> {
val originHeads = originBranchHeads(workingDir)
val checkedOut = currentBranch(workingDir)
return localBranches(workingDir).filter { branch ->
val origin = originHeads[branch] ?: return@filter false
val local = localHeadCommit(branch, workingDir) ?: return@filter false
if (local == origin || !isAncestor(local, origin, workingDir)) {
return@filter false
}
// the `refs/` prefix keeps the refname from being read as a git option
val result =
if (branch == checkedOut) {
runner.run(listOf("git", "merge", "--ff-only", "refs/remotes/origin/$branch"), workingDir)
} else {
runner.run(listOf("git", "update-ref", "refs/heads/$branch", origin, local), workingDir)
}
if (!result.isSuccess) {
log.warn("not fast-forwarding local branch {}: {}", branch, result.stderr.trim())
}
result.isSuccess
}
}
/** True when [ancestor] is reachable from [descendant]; false for diverged or unrelated commits. */
private fun isAncestor(
ancestor: String,
descendant: String,
workingDir: Path,
): Boolean = runner.run(listOf("git", "merge-base", "--is-ancestor", ancestor, descendant), workingDir).isSuccess
fun resetHardToOrigin(
branch: String,
workingDir: Path = Paths.get("."),
) {
// no `--` here: with paths `git reset --hard` refuses to run; the `origin/` prefix
// already keeps the argument from looking like an option
runner.runOrThrow(listOf("git", "reset", "--hard", "origin/$branch"), workingDir)
}
fun commitTimestamp(
sha: String,
workingDir: Path = Paths.get("."),
): Instant =
OffsetDateTime
.parse(
runner
.runOrThrow(listOf("git", "show", "-s", "--format=%cI", sha), workingDir)
.stdout
.trim(),
).toInstant()
fun currentBranch(workingDir: Path = Paths.get(".")): String? =
runner
.runOrThrow(listOf("git", "branch", "--show-current"), workingDir)
.stdout
.trim()
.ifEmpty { null }
/** The commit `refs/heads/[branch]` points at, or null when there is no such local branch. */
fun localHeadCommit(
branch: String,
workingDir: Path = Paths.get("."),
): String? {
val result = runner.run(listOf("git", "rev-parse", "--verify", "refs/heads/$branch"), workingDir)
return if (result.isSuccess) result.stdout.trim() else null
}
/** The commit `refs/remotes/origin/[branch]` points at, or null when the branch is not on origin. */
fun originHeadCommit(
branch: String,
workingDir: Path = Paths.get("."),
): String? {
val result = runner.run(listOf("git", "rev-parse", "--verify", "refs/remotes/origin/$branch"), workingDir)
return if (result.isSuccess) result.stdout.trim() else null
}
fun headCommit(workingDir: Path = Paths.get(".")): String =
runner
.runOrThrow(listOf("git", "rev-parse", "HEAD"), workingDir)
.stdout
.trim()
/** Creates a worktree at [path] with [commit] checked out as a detached HEAD; [path] must not exist yet. */
fun worktreeAdd(
path: Path,
commit: String,
workingDir: Path = Paths.get("."),
) {
runner.runOrThrow(listOf("git", "worktree", "add", "--detach", path.toString(), commit), workingDir)
}
/** Removes registrations of worktrees whose directories no longer exist. */
fun worktreePrune(workingDir: Path = Paths.get(".")) {
runner.runOrThrow(listOf("git", "worktree", "prune"), workingDir)
}
/** Checks out [commit] as a detached HEAD, discarding local modifications to tracked files. */
fun checkoutDetached(
commit: String,
workingDir: Path = Paths.get("."),
) {
runner.runOrThrow(listOf("git", "checkout", "--force", "--detach", commit), workingDir)
}
private fun refExists(
ref: String,
workingDir: Path,
): Boolean = runner.run(listOf("git", "show-ref", "--quiet", "--verify", ref), workingDir).isSuccess
/**
* Runs [block] with askpass credentials from config for HTTPS origins.
* SSH origins and missing credentials run without auth setup;
* terminal prompts are always disabled so a fetch can never hang on stdin.
*/
private fun <T> authenticated(
workingDir: Path,
block: (environment: Map<String, String>) -> T,
): T {
val noPrompt = mapOf("GIT_TERMINAL_PROMPT" to "0")
if (getOriginUrl(workingDir)?.startsWith("http") != true) {
return block(noPrompt)
}
val git = configLoader.load(getTopLevel(workingDir)).git
if (git.account.isBlank() || git.token.isBlank()) {
return block(noPrompt)
}
return GitAskPass.withAskPass(git.account, git.token, block)
}
}