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
@@ -25,9 +25,9 @@ It leaves every setting at its default, so an installation that updated without
|
||||
|
||||
The fallback is temporary and goes away once the watched repositories have been renamed.
|
||||
|
||||
## What Has To Be Moved By Hand
|
||||
## What Else the Rename Touches
|
||||
|
||||
### 1. The state directory
|
||||
### 1. The state directory — done automatically
|
||||
|
||||
`.git/gittally/` → `.git/werkator/`, with everything in it:
|
||||
|
||||
@@ -38,11 +38,19 @@ The fallback is temporary and goes away once the watched repositories have been
|
||||
- `worktrees/<branchKey>/` — the per-branch build worktrees
|
||||
- the generated systemd unit and its `EnvironmentFile`
|
||||
|
||||
There is no fallback for this path.
|
||||
Without the move the instance starts with an empty history, a fresh control token, and no memory of today's scheduled builds — again without a single failure.
|
||||
There is no name fallback for this path, so the first start after the update moves it (`StateDirMigration`).
|
||||
Without the move the instance would start with an empty history, a fresh control token, and no memory of today's scheduled builds — again without a single failure, which is why it is done rather than documented.
|
||||
|
||||
The worktrees hold absolute paths in both directions (`.git/worktrees/<name>/gitdir` and the worktree's own `.git` file).
|
||||
Either run `git worktree repair` after the move, or simply delete `.git/werkator/worktrees/` — a worktree is rebuilt on the next build of that branch.
|
||||
The move happens only when the old directory exists and the new one does not.
|
||||
Where both exist, nothing is moved and a warning names the leftover: which of the two is the live state is not something to guess.
|
||||
A move that fails is logged as an error and does not stop the start.
|
||||
|
||||
Two things follow from the move, both handled or reported:
|
||||
|
||||
- The worktrees hold absolute paths in both directions (`.git/worktrees/<name>/gitdir` and the worktree's own `.git` file), so they are dropped instead of repaired.
|
||||
Each is recreated by its branch's next build, which prunes the stale admin entry first.
|
||||
- The generated systemd unit moves with the directory, which leaves its symlink in `~/.config/systemd/user` dangling — the running service is unaffected, the next start is not.
|
||||
A warning names the unit; re-run `werkator init --systemd` and re-link it, see step 3.
|
||||
|
||||
### 2. The artifact root
|
||||
|
||||
@@ -105,13 +113,12 @@ Move the state directory with the artifact root, and remove the old container so
|
||||
Per installation, and only while `/api/builds/current` is `[]` — a running build is interrupted by the restart and re-enqueued, but there is no reason to force that.
|
||||
|
||||
1. `systemctl --user stop werkator-<repo>.service` (old name).
|
||||
2. Move the state directory, the artifact root, and the bundle.
|
||||
3. Repair or delete the worktrees.
|
||||
4. Rename the configuration files at the same time, or leave them to the fallback.
|
||||
5. Deploy the new version.
|
||||
6. `werkator init --systemd`, disable the old units, enable the new ones.
|
||||
7. Start the service.
|
||||
8. Update the Gitea branch protection rule if it names the check.
|
||||
2. Move the artifact root and the bundle; the state directory moves itself at the first start.
|
||||
3. Rename the configuration files at the same time, or leave them to the fallback.
|
||||
4. Deploy the new version and start it once, so the state directory moves.
|
||||
5. `werkator init --systemd`, disable the old units, enable the new ones.
|
||||
6. Start the service.
|
||||
7. Update the Gitea branch protection rule if it names the check.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -124,13 +131,12 @@ Per installation, and only while `/api/builds/current` is `[]` — a running bui
|
||||
Keep the previous bundle and a timestamped copy of the machine configuration.
|
||||
Note the asymmetry: the new version reads both names, the old one only reads the old name.
|
||||
So a rollback works as long as the configuration files still carry — or carry again — their pre-rename names.
|
||||
The state directory has to move back with it.
|
||||
The state directory has to be moved back by hand; nothing moves it in that direction.
|
||||
|
||||
## Open Points
|
||||
|
||||
- **Should the state directory get the same fallback as the configuration?**
|
||||
It would make an update a single step, at the price of a second lookup path in every place that writes state.
|
||||
Currently intended as a deliberate manual move, because unlike a configuration the state is written, not only read, and a fallback that writes would have to decide which of two directories wins.
|
||||
- **Settled:** the state directory is moved at the first start instead of getting a name fallback.
|
||||
A fallback would have to decide, on every write, which of two directories wins; a one-time move decides once and leaves one path afterwards.
|
||||
- **This repository's own file names** are a separate step: 121 paths still contain `gittally`, package directories included.
|
||||
The rename script for it is written and waits.
|
||||
- **When the fallback goes away**, `ConfigFiles` loses its legacy entries and a leftover `.gittally.yml` should be rejected by name rather than ignored — the same reasoning as for the legacy `branches` section in [plan step 18](plan/18-remove-branches-section.md).
|
||||
|
||||
@@ -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