Skip to content

Commit 7dcbbae

Browse files
committed
cli/load(fix[hooks]): Run on_project_start only for new session builds
why: on_project_start had been triggered before dispatch, so it also ran for paths that reused an existing session. That made --here rebuilds and interactive append flows execute a hook documented as new-session-only. what: - Move on_project_start execution into the attached and detached new-session load paths - Keep --here rebuilds inside tmux and append flows from invoking the hook - Preserve the outside-tmux --here fallback behavior, which still creates a new session - Add dispatch tests for attached, detached, append, and here routing - Add an on_project_exit guard assertion and fix the loader doctest ellipsis - Update the related load, comparison, and configuration docs to match current behavior
1 parent 395b1e1 commit 7dcbbae

7 files changed

Lines changed: 286 additions & 17 deletions

File tree

docs/cli/load.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ $ tmuxp load --here .
265265
When used, tmuxp builds the workspace panes inside the current window rather than spawning a new session.
266266

267267
```{note}
268-
`--here` sends shell commands (such as `cd` and `export` for environment variables) directly to the active pane via `send-keys`. The pane must be running a POSIX-compatible shell (bash, zsh, etc.). If the active pane is running a non-shell program (e.g., `vim`, `python`), those commands will be interpreted as input to that program.
268+
When `--here` needs to provision a directory, environment, or shell, tmuxp uses tmux primitives (`set-environment` and `respawn-pane`) instead of typing `cd` / `export` into the pane. If provisioning is needed, tmux will replace the active pane process before the workspace commands run, so long-running child processes in that pane can be terminated.
269269
```
270270

271271
## Skipping shell_command_before

docs/comparison.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,20 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com
7474

7575
| Hook | tmuxp | tmuxinator | teamocil |
7676
|---|---|---|---|
77-
| Every start invocation | `on_project_start` | `on_project_start` | (none) |
78-
| First start only | `before_script` | `on_project_first_start` | (none) |
77+
| Every start invocation | (none) | `on_project_start` | (none) |
78+
| New session creation only | `on_project_start` | `on_project_first_start` | (none) |
79+
| Before first script | `before_script` | (none) | (none) |
7980
| On reattach | `on_project_restart` + Plugin: `reattach()` | `on_project_restart` | (none) |
80-
| On exit/detach | `on_project_exit` (tmux `client-detached` hook) | `on_project_exit` | (none) |
81+
| On last client detach | `on_project_exit` (guarded `client-detached` hook) | `on_project_exit` | (none) |
8182
| On stop/kill | `on_project_stop` (via `tmuxp stop`) | `on_project_stop` | (none) |
8283
| Before workspace build | Plugin: `before_workspace_builder()` | (none) | (none) |
8384
| On window create | Plugin: `on_window_create()` | (none) | (none) |
8485
| After window done | Plugin: `after_window_finished()` | (none) | (none) |
8586
| Deprecated pre | (none) | `pre` (deprecated → `on_project_start`+`on_project_restart`; runs before session create) | (none) |
8687
| Deprecated post | (none) | `post` (deprecated → `on_project_stop`+`on_project_exit`; runs after attach on every invocation) | (none) |
8788

89+
tmuxp's lifecycle hook names are intentionally close to tmuxinator's, but `on_project_start` is limited to new-session creation and `on_project_exit` is guarded so teardown only runs when the last client detaches.
90+
8891
### Window-Level
8992

9093
| Key | tmuxp | tmuxinator | teamocil |

docs/configuration/top-level.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ windows:
6161
|------|-------------|
6262
| `on_project_start` | Before session build (new session creation only) |
6363
| `on_project_restart` | When reattaching to an existing session (confirmed attach only) |
64-
| `on_project_exit` | On client detach (tmux `client-detached` hook) |
64+
| `on_project_exit` | When the last client detaches (tmux `client-detached` hook) |
6565
| `on_project_stop` | Before `tmuxp stop` kills the session |
6666

6767
Each hook accepts a string (single command) or a list of strings (multiple commands run sequentially).
@@ -74,12 +74,12 @@ on_project_start:
7474

7575
```{note}
7676
These hooks are inspired by tmuxinator's lifecycle hooks but have tmuxp-specific semantics.
77-
`on_project_start` only fires on new session creation (not on reattach).
77+
`on_project_start` only fires on new session creation (not on reattach, append, or `--here`).
7878
`on_project_restart` only fires when you confirm reattaching to an existing session.
7979
```
8080

8181
```{note}
82-
`on_project_exit` uses tmux's `client-detached` hook, which fires on **any** client detach — including terminal close, SSH disconnect, or manual `tmux detach`. Note: unlike tmuxinator (which fires `on_project_exit` once when the wrapper script exits), tmuxp's hook fires on every detach event for the lifetime of the session.
82+
`on_project_exit` uses tmux's `client-detached` hook, but tmuxp guards it with `#{session_attached} == 0` so the command only runs when the **last** client detaches. This avoids repeated teardown in multi-client sessions. Unlike tmuxinator's wrapper-process hook, tmuxp keeps the hook on the session itself for the session lifetime.
8383
```
8484

8585
## Pane titles

src/tmuxp/cli/load.py

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None:
278278
def _load_attached(
279279
builder: WorkspaceBuilder,
280280
detached: bool,
281+
pre_build_hook: t.Callable[[], None] | None = None,
281282
pre_attach_hook: t.Callable[[], None] | None = None,
282283
) -> None:
283284
"""
@@ -287,10 +288,15 @@ def _load_attached(
287288
----------
288289
builder: :class:`workspace.builder.WorkspaceBuilder`
289290
detached : bool
291+
pre_build_hook : callable, optional
292+
called immediately before ``builder.build()`` for new-session load paths.
290293
pre_attach_hook : callable, optional
291294
called after build, before attach/switch_client; use to stop the spinner
292295
so its cleanup sequences don't appear inside the tmux pane.
293296
"""
297+
if pre_build_hook is not None:
298+
pre_build_hook()
299+
294300
builder.build()
295301
assert builder.session is not None
296302

@@ -311,6 +317,7 @@ def _load_attached(
311317
def _load_detached(
312318
builder: WorkspaceBuilder,
313319
colors: Colors | None = None,
320+
pre_build_hook: t.Callable[[], None] | None = None,
314321
pre_output_hook: t.Callable[[], None] | None = None,
315322
) -> None:
316323
"""
@@ -321,9 +328,14 @@ def _load_detached(
321328
builder: :class:`workspace.builder.WorkspaceBuilder`
322329
colors : Colors | None
323330
Optional Colors instance for styled output.
331+
pre_build_hook : Callable | None
332+
Called immediately before ``builder.build()`` for new-session load paths.
324333
pre_output_hook : Callable | None
325334
Called after build but before printing, e.g. to stop a spinner.
326335
"""
336+
if pre_build_hook is not None:
337+
pre_build_hook()
338+
327339
builder.build()
328340

329341
assert builder.session is not None
@@ -400,6 +412,7 @@ def _dispatch_build(
400412
answer_yes: bool,
401413
cli_colors: Colors,
402414
here: bool = False,
415+
pre_build_hook: t.Callable[[], None] | None = None,
403416
pre_attach_hook: t.Callable[[], None] | None = None,
404417
on_error_hook: t.Callable[[], None] | None = None,
405418
pre_prompt_hook: t.Callable[[], None] | None = None,
@@ -424,6 +437,8 @@ def _dispatch_build(
424437
Colors instance for styled output.
425438
here : bool
426439
Use current window for first workspace window.
440+
pre_build_hook : callable, optional
441+
Called before the build only for code paths that create a new session.
427442
pre_attach_hook : callable, optional
428443
Called before attach/switch_client (e.g. stop spinner).
429444
on_error_hook : callable, optional
@@ -465,7 +480,12 @@ def _dispatch_build(
465480
"""
466481
try:
467482
if detached:
468-
_load_detached(builder, cli_colors, pre_output_hook=pre_attach_hook)
483+
_load_detached(
484+
builder,
485+
cli_colors,
486+
pre_build_hook=pre_build_hook,
487+
pre_output_hook=pre_attach_hook,
488+
)
469489
return _setup_plugins(builder)
470490

471491
if here:
@@ -479,21 +499,36 @@ def _dispatch_build(
479499
cli_colors.warning("[Warning]")
480500
+ " --here requires running inside tmux; loading normally",
481501
)
482-
_load_attached(builder, detached, pre_attach_hook=pre_attach_hook)
502+
_load_attached(
503+
builder,
504+
detached,
505+
pre_build_hook=pre_build_hook,
506+
pre_attach_hook=pre_attach_hook,
507+
)
483508

484509
return _setup_plugins(builder)
485510

486511
if append:
487512
if "TMUX" in os.environ: # tmuxp ran from inside tmux
488513
_load_append_windows_to_current_session(builder)
489514
else:
490-
_load_attached(builder, detached, pre_attach_hook=pre_attach_hook)
515+
_load_attached(
516+
builder,
517+
detached,
518+
pre_build_hook=pre_build_hook,
519+
pre_attach_hook=pre_attach_hook,
520+
)
491521

492522
return _setup_plugins(builder)
493523

494524
# append and answer_yes have no meaning if specified together
495525
if answer_yes:
496-
_load_attached(builder, detached, pre_attach_hook=pre_attach_hook)
526+
_load_attached(
527+
builder,
528+
detached,
529+
pre_build_hook=pre_build_hook,
530+
pre_attach_hook=pre_attach_hook,
531+
)
497532
return _setup_plugins(builder)
498533

499534
if "TMUX" in os.environ: # tmuxp ran from inside tmux
@@ -507,13 +542,27 @@ def _dispatch_build(
507542
choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode)
508543

509544
if choice == "y":
510-
_load_attached(builder, detached, pre_attach_hook=pre_attach_hook)
545+
_load_attached(
546+
builder,
547+
detached,
548+
pre_build_hook=pre_build_hook,
549+
pre_attach_hook=pre_attach_hook,
550+
)
511551
elif choice == "a":
512552
_load_append_windows_to_current_session(builder)
513553
else:
514-
_load_detached(builder, cli_colors)
554+
_load_detached(
555+
builder,
556+
cli_colors,
557+
pre_build_hook=pre_build_hook,
558+
)
515559
else:
516-
_load_attached(builder, detached, pre_attach_hook=pre_attach_hook)
560+
_load_attached(
561+
builder,
562+
detached,
563+
pre_build_hook=pre_build_hook,
564+
pre_attach_hook=pre_attach_hook,
565+
)
517566

518567
except exc.TmuxpException as e:
519568
if on_error_hook is not None:
@@ -811,8 +860,9 @@ def _cleanup_debug() -> None:
811860
_cleanup_debug()
812861
return None
813862

814-
# Run on_project_start hook — fires before new session build
815-
if "on_project_start" in expanded_workspace:
863+
def _run_on_project_start() -> None:
864+
if "on_project_start" not in expanded_workspace:
865+
return
816866
_hook_cwd = expanded_workspace.get("start_directory")
817867
util.run_hook_commands(
818868
expanded_workspace["on_project_start"],
@@ -828,6 +878,7 @@ def _cleanup_debug() -> None:
828878
answer_yes,
829879
cli_colors,
830880
here=here,
881+
pre_build_hook=_run_on_project_start,
831882
)
832883
if result is not None:
833884
summary = ""
@@ -905,6 +956,7 @@ def _emit_success() -> None:
905956
answer_yes,
906957
cli_colors,
907958
here=here,
959+
pre_build_hook=_run_on_project_start,
908960
pre_attach_hook=_emit_success,
909961
on_error_hook=spinner.stop,
910962
pre_prompt_hook=spinner.stop,

src/tmuxp/workspace/loader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def _validate_template_values(context: dict[str, str]) -> None:
4141
--------
4242
>>> _validate_template_values({"key": "simple"})
4343
44-
>>> _validate_template_values({"key": "foo: bar"})
44+
>>> _validate_template_values({"key": "foo: bar"}) # doctest: +ELLIPSIS
4545
Traceback (most recent call last):
4646
...
4747
ValueError: --set value for 'key' contains YAML-unsafe characters ...

0 commit comments

Comments
 (0)