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:
co-authored by
Claude Opus 5
parent
35f06ec1ec
commit
8b56ab258b
@@ -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, _ ->
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user