feat(upgrade): add -Syu upgrade for tofu-installed recipe packages
- src/tofu/commands/upgrade.d: full 7-step upgrade pipeline
- Fetches index, lists installed state, compares versions
- Shows upgrade plan with confirmation prompt
- REUSES installCommand per outdated package (force=true, noconfirm=true)
- Continue-on-failure for multi-package upgrades
- Binary-only packages excluded (pool=binary → skip)
- 6 unittests: no-installed, all-uptodate, outdated-upgraded,
removed-from-index, multi-with-failure, confirmation-denied
- src/main.d: wire upgrade stub → upgradeCommand(pa, cfg)
- Evidence: dub test (23/23 pass) + dub build clean
This commit is contained in:
@@ -1276,3 +1276,71 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
|
||||
- `dub test` passes — 21 modules, all 8 install.d unittests pass.
|
||||
- `dub build` passes with `warningsAsErrors`.
|
||||
- Evidence logged to `.omo/evidence/task-21-tofu-core.log`.
|
||||
|
||||
---
|
||||
|
||||
## Task 22 — `tofu.commands.upgrade` (`-Syu` full-system upgrade)
|
||||
|
||||
### Architecture
|
||||
- Module `tofu.commands.upgrade` — file `src/tofu/commands/upgrade.d`.
|
||||
- Single public function: `upgradeCommand(ParsedArgs flags, Config cfg, PackageIndex[] delegate(Config) @safe indexFetcher = null)` — returns int exit code 0 (success/no-op) or 1 (upgrade failure).
|
||||
- Depends on: `tofu.config`, `tofu.cli`, `tofu.types`, `tofu.index`, `tofu.state`, `tofu.vercmp`, `tofu.log`, `tofu.commands.install`, `tofu.errors`.
|
||||
|
||||
### Algorithm — 7-step pipeline
|
||||
1. **Fetch index**: via `indexFetcher(cfg)` seam or real `fetchIndex(cfg)`.
|
||||
2. **List installed**: `listInstalled(cfg)` from `tofu.state`. Empty → `logInfo("nothing to do")` + return 0.
|
||||
3. **Compare versions**: For each installed pkg: find in index by name. NOT found → `logWarn` + skip. Found with `pool == Pool.binary` → `logDetail` + skip (binary-only packages are not tofu-upgradable). Compare `installed.ver` vs `index.ver` via `tofu.vercmp.compare`: `>= 0` → up to date (`logDetail`); `< 0` → outdated, add to upgrade list.
|
||||
4. **No outdated check**: Empty upgrade list → `logInfo("nothing to do")` + return 0.
|
||||
5. **Show plan + confirm**: Format `"will upgrade N package(s): <name>: <old> → <new>, ..."`. Prompt `"Proceed? [y/N] "` unless `flags.noconfirm`. No → `logInfo("aborted by user")` + return 0.
|
||||
6. **Execute upgrades** (continue-on-failure):
|
||||
- Create flags copy with `force=true` (ensure rebuild over stale output) + `noconfirm=true` (already confirmed at upgrade level).
|
||||
- Create installCommand-compatible index closure: `delegate PackageIndex[]() @safe { return index; }` (closure captures the already-fetched index to avoid re-fetch).
|
||||
- Call `installCommand(pkgName, pkgFlags, cfg, installIdxFetcher, null)` for each outdated package.
|
||||
- Non-zero exit → `logError("upgrade failed for <name>")`, increment failure counter, CONTINUE.
|
||||
7. **Summary**: `logOk("N package(s) upgraded, M up to date, ...")`. If failures > 0 → `logWarn("K upgrade(s) failed")` + return 1.
|
||||
|
||||
### Delegate type mismatch between upgrade and install
|
||||
- **upgradeCommand** indexFetcher: `PackageIndex[] delegate(Config) @safe` (takes Config param — matches search/info pattern).
|
||||
- **installCommand** indexFetcher: `PackageIndex[] delegate() @safe` (closure, no params — captures state).
|
||||
- **Fix**: Fetch index once in upgradeCommand, then create a zero-arg closure `delegate PackageIndex[]() @safe { return index; }` to pass to installCommand. The closure captures the already-fetched index array.
|
||||
|
||||
### Force + noconfirm override on per-package flags
|
||||
- `flags.force = true` — ensures stale build output is overwritten (Zeta -ReProvide handles overwrite). This is the upgrade semantic: the old package's build output may still exist.
|
||||
- `flags.noconfirm = true` — the overall upgrade plan was already confirmed; individual installCommand calls should not re-prompt. This avoids double-confirmation UX.
|
||||
- D struct copy: `auto pkgFlags = flags;` creates a full value copy of `ParsedArgs`.
|
||||
|
||||
### Binary-only package exclusion
|
||||
- Packages with `pool == Pool.binary` are skipped with `logDetail`. Binary packages are managed by ZETA directly, not tofu. The upgrade command only handles recipe-available packages (pool ∈ {recipes, both}).
|
||||
|
||||
### `installCommand` re-entrancy safety
|
||||
- `installCommand` sets module-level `_installCfg = cfg` and clears it via `scope(exit)`. Since tofu is single-threaded (lock-based), calling `installCommand` multiple times from upgrade is safe. Each call manages its own `_installCfg` lifecycle.
|
||||
|
||||
### `write` name conflict (std.file vs std.stdio)
|
||||
- With both `import std.file;` and `import std.stdio;` in scope, bare `write(...)` is ambiguous. Fix: use `std.stdio.write(string)` for stdout writes and `std.file.write(path, content)` for file writes.
|
||||
- Same issue as encountered in `install.d` (task 21 learnings).
|
||||
|
||||
### Test strategy (6 unittests)
|
||||
- Reused test infrastructure patterns from `install.d`: `makeTempDir`, `removeDir`, `sWrite`, `makeFakeMakepkg`, `makeFakeZeta`.
|
||||
- New helper: `makeConditionalZeta(dir, failName, failCode)` — fake zeta that fails for a specific package name (needed for test 5 multi-package upgrade-with-failure).
|
||||
- `makeRecipeDir(cacheDir, name, ver)` — creates recipe cache directory + .recipe file in one call.
|
||||
|
||||
### Test cases (6/6 pass)
|
||||
1. No installed → `logInfo("nothing to do")` + exit 0.
|
||||
2. Installed all up to date → `logDetail` for each + `logInfo("nothing to do")` + exit 0.
|
||||
3. One outdated (1.0→2.0) → upgrade via installCommand path, zeta invoked, state updated to 2.0, exit 0.
|
||||
4. Package removed from index (ghost) + realpkg outdated → ghost gets `logWarn` skip, realpkg upgraded, exit 0, ghost state preserved.
|
||||
5. goodpkg upgrades, badpkg zeta fails → goodpkg upgraded (state 2.0), badpkg stays at 1.0, exit 1 with `logWarn("1 upgrade(s) failed")`.
|
||||
6. Confirmation denied (stdin="n\n") → `logInfo("aborted by user")`, exit 0, nothing upgraded, zeta NOT invoked.
|
||||
|
||||
### `stdin` redirection caveat
|
||||
- Test 6 (confirmation denied) must be LAST in the file because `stdin` reassignment affects all subsequent test blocks. Same restriction as `install.d` tests 7 and 8.
|
||||
|
||||
### `main.d` integration
|
||||
- Import: `import tofu.commands.upgrade : upgradeCommand;` (selective).
|
||||
- Dispatch: `case Command.upgrade: return upgradeCommand(pa, cfg);`.
|
||||
- Replaced stub `logError("command 'upgrade' not implemented yet")` + `return 1`.
|
||||
|
||||
### Build verified
|
||||
- `dub test` passes — 23 modules, including upgrade.d's 6 unittests.
|
||||
- `dub build` passes with `warningsAsErrors`.
|
||||
- Evidence logged to `.omo/evidence/task-22-tofu-core.log`.
|
||||
|
||||
Reference in New Issue
Block a user