Move the pre-rename state directory at the first start

The configuration is found under either name, the state is not: build
history, control token, auto-build slots and worktrees live at one fixed
path. An installation that updates without moving `.git/gittally` would
not fail — it would come up with an empty history and a fresh control
token, quietly. So the first start moves it instead of the release notes
asking for it.

Only when the old directory exists and the new one does not. Where both
exist nothing is touched and a warning names the leftover: which of the
two is the live state is not something to guess. A failed move is an
error in the log, never an abort — a CI must not hang on it.

The worktrees are dropped rather than moved, since they point at their
old path in both directions; `GitWorktreeWorkspaces` prunes the stale
admin entry and recreates each on its branch's next build. A generated
systemd unit moves with the directory and leaves its symlink dangling,
which is warned about — the running service is unaffected, the next
start is not.

Runs from `CliRunner`, before any command resolves a path under the
directory, and so before the second context of `server` exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-30 20:38:44 +02:00
co-authored by Claude Opus 5
parent 35f06ec1ec
commit 8b56ab258b
5 changed files with 179 additions and 20 deletions
@@ -0,0 +1,83 @@
package de.hoennig.werkator
import org.slf4j.LoggerFactory
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
/**
* Moves the state directory of an installation that predates the rename to Werkator,
* once, at the first start after the update.
*
* The configuration is found under either name ([de.hoennig.werkator.config.ConfigFiles]),
* but the state is not: build history, the control token, the auto-build slots and the
* build worktrees live at one fixed path. A missing state directory is as quiet as a
* missing configuration — the instance would come up with an empty history and a fresh
* control token, and nothing would fail. So this is done rather than documented.
*/
object StateDirMigration {
const val DIR = ".git/werkator"
private const val LEGACY_DIR = ".git/gittally"
private const val WORKTREES = "worktrees"
private val log = LoggerFactory.getLogger(StateDirMigration::class.java)
/**
* Renames `.git/gittally` to `.git/werkator` in [workingDir], if the first exists and
* the second does not. Never throws: a failed move must not stop a CI, it must say
* what to do by hand.
*/
fun migrateIfNeeded(workingDir: Path) {
val legacy = workingDir.resolve(LEGACY_DIR)
val current = workingDir.resolve(DIR)
if (!Files.isDirectory(legacy)) return
if (Files.exists(current)) {
// both exist: which of the two is the live state is not ours to guess
log.warn("{} exists next to {} — the leftover is ignored, remove it once you are sure", LEGACY_DIR, DIR)
return
}
try {
Files.move(legacy, current)
} catch (e: IOException) {
log.error("could not move {} to {}: {} — move it by hand", LEGACY_DIR, DIR, e.message)
return
}
log.info("moved {} to {}: build history, control token and configuration kept", LEGACY_DIR, DIR)
dropWorktrees(current)
warnAboutUnits(current)
}
/**
* The moved worktrees point at their old path in both directions, so they are dropped
* rather than repaired: the next build of a branch creates its worktree again, and
* `GitWorktreeWorkspaces` prunes the stale admin entry before it does.
*/
private fun dropWorktrees(stateDir: Path) {
val worktrees = stateDir.resolve(WORKTREES)
if (!Files.isDirectory(worktrees)) return
if (worktrees.toFile().deleteRecursively()) {
log.info("dropped the moved build worktrees; each is recreated by its branch's next build")
} else {
log.warn("could not drop the moved build worktrees in {} — delete them by hand", worktrees)
}
}
/**
* The generated systemd unit lives in the state directory and is symlinked from
* `~/.config/systemd/user`, so the move leaves that link dangling — the service keeps
* running and fails to start the next time.
*/
private fun warnAboutUnits(stateDir: Path) {
val units =
Files
.list(stateDir)
.use { paths -> paths.map { it.fileName.toString() }.filter { it.endsWith(".service") }.toList() }
if (units.isEmpty()) return
log.warn(
"the systemd unit {} moved with the state directory and its symlink now dangles — " +
"re-run `werkator init --systemd` and re-link it",
units.joinToString(", "),
)
}
}
@@ -10,6 +10,7 @@ import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import picocli.CommandLine
import picocli.CommandLine.IFactory
import java.nio.file.Paths
import kotlin.system.exitProcess
@SpringBootApplication
@@ -26,6 +27,8 @@ class CliRunner(
private var exitCode = 0
override fun run(vararg args: String) {
// before any command resolves a path under it, and once per process
StateDirMigration.migrateIfNeeded(Paths.get("."))
exitCode =
CommandLine(rootCommand, factory)
.setExecutionExceptionHandler { exception, commandLine, _ ->
+10 -3
View File
@@ -25,9 +25,16 @@
rather than merged. The fallback is there because a configuration that is not found
is not an error — it leaves every setting at its default, so an installation that
updated without renaming would have come up looking healthy while having forgotten
its credentials and its builds. Rename at your convenience; the state directory
<code>.git/werkator/</code> has no such fallback and does have to be moved, or the
instance starts without its build history.</li>
its credentials and its builds. Rename at your convenience.</li>
<li>The state directory has no such fallback, so the first start after the update moves
it: <code>.git/gittally/</code> becomes <code>.git/werkator/</code>, with the build
history, the control token and the scheduled-build state in it. It moves only when
the old directory exists and the new one does not — where both exist nothing is
touched and a warning names the leftover. The build worktrees are dropped rather
than moved, since they point at their old path in both directions, and each is
recreated by its branch's next build. A generated systemd unit moves with the
directory and leaves its symlink dangling, which is warned about: re-run
<code>werkator init --systemd</code>.</li>
<li>The default Gitea check context is <code>werkator</code>. Where the old name is
pinned in a branch protection rule, the rule has to be updated with it, or a pull
request waits forever for a check nobody posts any more. Statuses already written
@@ -0,0 +1,60 @@
package de.hoennig.werkator
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.shouldBe
import java.nio.file.Files
import java.nio.file.Path
class StateDirMigrationTest : FunSpec() {
private fun repoWithLegacyState(): Path {
val dir = Files.createTempDirectory("werkator-state")
Files.createDirectories(dir.resolve(".git/gittally"))
dir.resolve(".git/gittally/build-results.json").toFile().writeText("[]")
return dir
}
init {
test("moves the pre-rename state directory to its current path") {
val dir = repoWithLegacyState()
StateDirMigration.migrateIfNeeded(dir)
Files.exists(dir.resolve(".git/gittally")).shouldBeFalse()
dir.resolve(".git/werkator/build-results.json").toFile().readText() shouldBe "[]"
}
test("drops the moved worktrees, because they point at their old path") {
val dir = repoWithLegacyState()
Files.createDirectories(dir.resolve(".git/gittally/worktrees/main"))
StateDirMigration.migrateIfNeeded(dir)
Files.exists(dir.resolve(".git/werkator/worktrees")).shouldBeFalse()
// the rest of the state survives the drop
Files.exists(dir.resolve(".git/werkator/build-results.json")).shouldBeTrue()
}
test("leaves both alone when the current directory already exists") {
val dir = repoWithLegacyState()
Files.createDirectories(dir.resolve(".git/werkator"))
dir.resolve(".git/werkator/build-results.json").toFile().writeText("[\"live\"]")
StateDirMigration.migrateIfNeeded(dir)
// which of the two is the live state is not guessed
dir.resolve(".git/werkator/build-results.json").toFile().readText() shouldBe "[\"live\"]"
Files.exists(dir.resolve(".git/gittally")).shouldBeTrue()
}
test("does nothing where there is no pre-rename directory") {
val dir = Files.createTempDirectory("werkator-state")
Files.createDirectories(dir.resolve(".git"))
StateDirMigration.migrateIfNeeded(dir)
Files.exists(dir.resolve(".git/werkator")).shouldBeFalse()
}
}
}