feat(remove): add -R remove via zeta

- Add runRemove() to tofu.install (zeta subprocess module)
  - Same pipeProcess/tee/rolling-buffer pattern as runLocalProvide
  - Uses -Remove instead of -LocalProvide
  - Appends --force flag when force=true
- Create tofu.commands.remove with removeCommand()
  - Checks tofu-installed via state.d (warns if not, still proceeds)
  - Confirm prompt (Remove <name>? [y/N]) unless --noconfirm
  - Detects 'still required by' in zeta error → suggests --force
  - Cleans up install record on success
- Wire remove case in main.d dispatch
- 6 unittests with fake zeta scripts
This commit is contained in:
2026-08-08 18:29:54 -04:00
parent 066700b540
commit 7164cd7c04
5 changed files with 650 additions and 4 deletions
+56
View File
@@ -1083,3 +1083,59 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
- `dub test` (with broken siblings excluded) — 19 modules pass unittests, including ui.d's 4 test blocks.
- `dub build` (with broken siblings excluded) — blocked by `main.d` importing `tofu.commands.info` (skipped). ui.d itself compiles clean.
- Evidence logged to `.omo/evidence/task-25-tofu-core.log`.
---
## Task 23 — `tofu.commands.remove` (`-R` remove via ZETA)
### Architecture
- Module `tofu.commands.remove` — file `src/tofu/commands/remove.d`.
- `runRemove` added to `tofu.install` (zeta subprocess module) alongside `runLocalProvide`.
- Depends on: `tofu.config`, `tofu.log`, `tofu.cli`, `tofu.install`, `tofu.state`.
### `runRemove(pkgName, cfg, force=false)` — zeta subprocess runner
- Shares the exact same pattern as `runLocalProvide`: `pipeProcess`, `Redirect.stdout | Redirect.stderrToStdout`, child env (`ZETA_LOCAL_PACKAGES`, `ZETA_REPO`), real-time tee + rolling 20-line buffer.
- Uses `-Remove` instead of `-LocalProvide`. Appends `--force` when `force=true`.
- Throws `InstallException` on non-zero exit with last 20 lines of output.
### `removeCommand(pkgName, flags, cfg)` — command logic
1. **Check tofu-installed**: `isInstalledByTofu` → if not, `logWarn("package '%s' was not installed by tofu — removing via Zeta anyway")` — still proceeds.
2. **Confirm prompt**: `"Remove <name>? [y/N] "` unless `flags.noconfirm`. Uses `write`+`stdout.flush()`+`readln()` — all wrapped in `@trusted` since `stdout` is `@system` in DMD 2.112. EOF/closed stdin → "aborted by user" + return 0.
3. **Invoke** `runRemove(pkgName, cfg, flags.force)`.
4. **Reverse-dep block**: Catch `InstallException` → if error contains `"still required by"` AND `!flags.force` → `logError("cannot remove %s (use --force to override)", pkgName)` + return 1. ZETA output is already streamed in real-time via the tee pattern.
5. **Other failures**: `logError` with full exception message + return 5.
6. **Success**: `removeInstallRecord(pkgName, cfg)` + `logOk("removed %s", pkgName)` + return 0.
### `@safe` / `@trusted` architecture
- `removeCommand` is `@safe`.
- `stdout` accesses (`write`, `flush`) wrapped in `@trusted` lambdas — same pattern as `log.d`.
- `readln()` wrapped in `@trusted` — `std.stdio` globals use `__gshared` which fails `@safe` inference.
- `stdin.open()` in test 6 wrapped in `@trusted`.
### Unittests — 6 fake-zeta-script tests
- Same temp-dir + fake-script pattern as `install.d`. Each test creates a bash script, points `cfg.zetaPath` at it.
- Test (1): tofu-installed, zeta exits 0 → record removed, zeta invoked with package name ✓
- Test (2): NOT tofu-installed → warning printed, still removes via zeta ✓
- Test (3): zeta exits 1 with "still required by libbar" → error + suggestion, state record kept ✓
- Test (4): --force flag → zeta receives --force in args ✓
- Test (5): --noconfirm → no prompt, direct execution, state record removed ✓
- Test (6): confirmation denied → stdin.open("n\n") fed, zeta NOT invoked, state record kept ✓
### Exit codes
- 0 — success
- 1 — reverse-dep block (without --force)
- 5 — install failure (other InstallException)
### `stdin` manipulation in test 6
- `File.open()` reopens the global `stdin` — closes existing handle and opens new path. Test 6 is placed LAST in the file to avoid polluting stdin for subsequent tests.
- D's `File` has `@disable this(this)` — cannot copy. Cannot save/restore `stdin` easily. Accepting "broken" stdin after test 6 is fine since it's the last test.
### `main.d` wiring
- Added `import tofu.commands.remove : removeCommand;`.
- Replaced stub `case Command.remove_:` with `return removeCommand(pa.arg, pa, cfg);`.
- Parallel agents also wired install and info in main.d — minimal conflict, only the remove case lines were touched.
### Build verified
- `dub build` passes — produces `./tofu` binary.
- `dub test` passes — all 20 modules, including remove.d's 6 unittests + install.d's runRemove.
- Evidence logged to `.omo/evidence/task-23-tofu-core.log`.