Cleanup hook on worktree removal — stop project daemons before iw rm #382

Open
opened 2026-06-12 16:23:24 +00:00 by mprihoda · 0 comments
mprihoda commented 2026-06-12 16:23:24 +00:00 (Migrated from github.com)

Problem

./iw rm removes the worktree directory but leaves project-spawned processes running. Two distinct pain points have accumulated:

  1. Build-tool daemons — Mill, Bloop (via scala-cli), sbt's BSP server. Observed: 14 orphaned Mill/Bloop processes consuming ~2775% CPU (24-core) and ~50% RAM after months of accumulation across deleted worktrees (originally documented in #225).
  2. Application-level processes — docker-compose stacks, dev servers, long-running test runners, anything a project's start workflow brought up (originally documented in #147).

Today the only way out is to re-create the worktree, run the project's shutdown manually, then ./iw rm again — defeating the point.

Solution: cleanup hook discovered the standard way

Add a CleanupAction trait + reflection-based discovery for *.hook-rm.scala files, matching the action-hook pattern landed in #331 (SessionAction / FixAction / RecoveryAction).

// core/model/CleanupAction.scala
case class CleanupContext(
  worktreePath: os.Path,
  issueId: String,
  config: ProjectConfiguration,
  force: Boolean       // from --force flag
)

trait CleanupAction:
  /** Run cleanup. Return Nil on success, non-empty list of warnings to
    * surface to the user (but proceed with rm). Throw to abort rm. */
  def cleanup(ctx: CleanupContext): List[String]

Discovery & invocation

  • iw rm calls env.hooks.collectValues[CleanupAction] (the same pattern the existing hooks use) before any git worktree remove.
  • Multiple hooks run in declared order; warnings aggregate; first thrown error aborts.
  • No installed hook → today's behavior unchanged.
  • --force continues to mean "ignore dirty tree, dirty index, etc." and is passed through in CleanupContext.force so projects can decide whether to escalate (e.g. SIGKILL instead of SIGTERM).

Built-in fallback for common daemons

Ship a single default BuildToolCleanup hook in core that handles the two specific footguns from #225 without requiring every project to write one:

  • If out/mill-daemon/ exists under the worktree, run mill --no-server shutdown (or send TERM to the PIDs recorded there).
  • If a Bloop server's CWD is inside the worktree (parse ~/.local/share/scalacli/bloop/state.json or its equivalent), bloop exit.
  • If a docker-compose.yml exists at the worktree root, run docker compose down only when a project hook hasn't already handled it.

The fallback is opt-out via cleanup.builtin = false in .iw/config.conf, opt-in to extras by writing a project hook. This keeps the "I just want it to stop hogging my CPU" case fixed with no project config and lets advanced projects layer behaviour.

Acceptance criteria

  • CleanupAction trait in core/model/
  • iw rm discovers CleanupAction values via the same reflection pass doctor, start, etc. use
  • Hook errors abort rm (preserve the worktree); warnings surface but rm proceeds
  • BuildToolCleanup default hook covers Mill, Bloop, docker-compose (best-effort, never blocks rm)
  • cleanup.builtin = false disables the default hook
  • Harness tests cover: no hooks installed, single hook success, single hook warnings, single hook abort, multiple hooks, built-in only, built-in disabled
  • Smoke test in test/rm.bats proves the wiring (single hook prints expected line before worktree removal)

Supersedes

  • #147Project-specific cleanup hook for worktree removal (the project-defined surface)
  • #225Add graceful cleanup of build tool daemons when removing worktrees (the build-daemon driver)
  • #331Add action hook points to start, doctor, and phase-merge — reuses the discovery pattern.
## Problem `./iw rm` removes the worktree directory but leaves project-spawned processes running. Two distinct pain points have accumulated: 1. **Build-tool daemons** — Mill, Bloop (via scala-cli), sbt's BSP server. Observed: 14 orphaned Mill/Bloop processes consuming ~2775% CPU (24-core) and ~50% RAM after months of accumulation across deleted worktrees (originally documented in #225). 2. **Application-level processes** — docker-compose stacks, dev servers, long-running test runners, anything a project's `start` workflow brought up (originally documented in #147). Today the only way out is to re-create the worktree, run the project's shutdown manually, then `./iw rm` again — defeating the point. ## Solution: cleanup hook discovered the standard way Add a `CleanupAction` trait + reflection-based discovery for `*.hook-rm.scala` files, matching the action-hook pattern landed in #331 (`SessionAction` / `FixAction` / `RecoveryAction`). ```scala // core/model/CleanupAction.scala case class CleanupContext( worktreePath: os.Path, issueId: String, config: ProjectConfiguration, force: Boolean // from --force flag ) trait CleanupAction: /** Run cleanup. Return Nil on success, non-empty list of warnings to * surface to the user (but proceed with rm). Throw to abort rm. */ def cleanup(ctx: CleanupContext): List[String] ``` ### Discovery & invocation - `iw rm` calls `env.hooks.collectValues[CleanupAction]` (the same pattern the existing hooks use) before any `git worktree remove`. - Multiple hooks run in declared order; warnings aggregate; first thrown error aborts. - No installed hook → today's behavior unchanged. - `--force` continues to mean "ignore dirty tree, dirty index, etc." and is passed through in `CleanupContext.force` so projects can decide whether to escalate (e.g. SIGKILL instead of SIGTERM). ### Built-in fallback for common daemons Ship a single default `BuildToolCleanup` hook in core that handles the two specific footguns from #225 *without* requiring every project to write one: - If `out/mill-daemon/` exists under the worktree, run `mill --no-server shutdown` (or send TERM to the PIDs recorded there). - If a Bloop server's CWD is inside the worktree (parse `~/.local/share/scalacli/bloop/state.json` or its equivalent), `bloop exit`. - If a `docker-compose.yml` exists at the worktree root, run `docker compose down` *only* when a project hook hasn't already handled it. The fallback is opt-out via `cleanup.builtin = false` in `.iw/config.conf`, opt-in to extras by writing a project hook. This keeps the "I just want it to stop hogging my CPU" case fixed with no project config and lets advanced projects layer behaviour. ## Acceptance criteria - [ ] `CleanupAction` trait in `core/model/` - [ ] `iw rm` discovers `CleanupAction` values via the same reflection pass `doctor`, `start`, etc. use - [ ] Hook errors abort `rm` (preserve the worktree); warnings surface but `rm` proceeds - [ ] `BuildToolCleanup` default hook covers Mill, Bloop, docker-compose (best-effort, never blocks `rm`) - [ ] `cleanup.builtin = false` disables the default hook - [ ] Harness tests cover: no hooks installed, single hook success, single hook warnings, single hook abort, multiple hooks, built-in only, built-in disabled - [ ] Smoke test in `test/rm.bats` proves the wiring (single hook prints expected line before worktree removal) ## Supersedes - #147 — *Project-specific cleanup hook for worktree removal* (the project-defined surface) - #225 — *Add graceful cleanup of build tool daemons when removing worktrees* (the build-daemon driver) ## Related - #331 — *Add action hook points to start, doctor, and phase-merge* — reuses the discovery pattern.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
iterative-works/iw-cli#382
No description provided.