From 72fa331d22af73ed7cfc7f3f6a2279e3d07c3fd2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 06:14:14 -0500 Subject: [PATCH] load(feat[append-multi]): Coalesce multi-file --append into one session why: ``tmuxp load f1 f2 f3 --append`` previously failed on the second file because each file was loaded against its own config's ``session_name``, and the second file's ``new_session`` would collide with the session the first file just created. Multi-file ``--append`` should land every workspace in a single shared target session. The fix has two parts because the old behavior was wrong on two layers: 1. Pre-resolve the target session name once, before the load loop. ``--new-session-name`` wins; otherwise fall back to the first workspace file's ``session_name`` config key. Apply the resolved name to every iteration's ``new_session_name`` so all loads target the same session. 2. Make ``_dispatch_build`` honor ``append`` even when the target session already exists in detached mode. Previously the ``if detached:`` branch unconditionally called ``builder.build()`` (no append arg), which re-tried session creation and raised ``TmuxSessionExists``. New hoisted check: when ``append=True`` and the named session already exists, build windows directly onto the existing session via ``builder.build(existing, append=True)``, honoring ``detached`` for whether to attach afterwards. what: - src/tmuxp/cli/load.py: pre-resolve append_target_session_name in command_load when args.append and len(workspace_files) > 1; force every loop iteration to use it as new_session_name. - src/tmuxp/cli/load.py: add session_name kwarg to _dispatch_build; hoist an append-to-existing-session branch above the detached/ attached split so detached + append + session-exists is honored. - tests/cli/test_load.py: two functional tests covering (a) ``load f1 f2 --append`` coalescing into f1's session_name, and (b) ``--new-session-name`` overriding the first file's session_name. Re-ports PR #839 (originally a single 2022 WIP commit). Drops the WIP's broken in-loop scoping of original_session_name and its type mismatch between ConfigReader._from_file (returns dict) and new_session_name (str). --- src/tmuxp/cli/load.py | 53 ++++++++++++++++++++ tests/cli/test_load.py | 109 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 375cdb1b22..712e103b29 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -328,6 +328,7 @@ def _dispatch_build( pre_attach_hook: t.Callable[[], None] | None = None, on_error_hook: t.Callable[[], None] | None = None, pre_prompt_hook: t.Callable[[], None] | None = None, + session_name: str | None = None, ) -> Session | None: """Dispatch the build to the correct load path and handle errors. @@ -367,6 +368,26 @@ def _dispatch_build( True """ try: + # When append is requested and the target session already exists, + # build windows onto it directly. This applies whether running + # detached or attached, and is what makes ``tmuxp load f1 f2 --append`` + # land all files in the same session instead of failing on the + # second file's create-session call. + if append and session_name is not None and builder.session_exists(session_name): + existing = builder.server.sessions.get(session_name=session_name) + builder.build(existing, append=True) + assert builder.session is not None + if pre_attach_hook is not None: + pre_attach_hook() + if not detached: + if "TMUX" in os.environ: + tmux_env = os.environ.pop("TMUX") + builder.session.switch_client() + os.environ["TMUX"] = tmux_env + else: + builder.session.attach() + return _setup_plugins(builder) + if detached: _load_detached(builder, cli_colors, pre_output_hook=pre_attach_hook) return _setup_plugins(builder) @@ -618,6 +639,7 @@ def load_workspace( append, answer_yes, cli_colors, + session_name=session_name, ) if result is not None: summary = "" @@ -696,6 +718,7 @@ def _emit_success() -> None: pre_attach_hook=_emit_success, on_error_hook=spinner.stop, pre_prompt_hook=spinner.stop, + session_name=session_name, ) if result is not None: _emit_success() @@ -877,6 +900,33 @@ def command_load( original_detached_option = args.detached original_new_session_name = args.new_session_name + # When --append spans multiple files, every load must target the same + # session — otherwise each file would be appended to whatever its own + # config names, defeating the point. Resolve the target once: explicit + # --new-session-name wins; otherwise fall back to the first file's + # `session_name` config key. + append_target_session_name: str | None = None + if args.append and last_idx > 0: + if original_new_session_name: + append_target_session_name = original_new_session_name + else: + try: + first_workspace_file = find_workspace_file( + args.workspace_files[0], + workspace_dir=get_workspace_dir(), + ) + first_config = config_reader.ConfigReader._from_file( + pathlib.Path(first_workspace_file), + ) + resolved = first_config.get("session_name") + if isinstance(resolved, str): + append_target_session_name = resolved + except Exception: + logger.debug( + "could not pre-resolve session_name for multi-file --append", + exc_info=True, + ) + for idx, workspace_file in enumerate(args.workspace_files): workspace_file = find_workspace_file( workspace_file, @@ -890,6 +940,9 @@ def command_load( detached = True new_session_name = None + if append_target_session_name is not None: + new_session_name = append_target_session_name + load_workspace( workspace_file, socket_name=args.socket_name, diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index ec045dcf3c..ca646d196e 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -356,6 +356,115 @@ def test_regression_00132_session_name_with_dots( cli.cli(["load", *cli_args]) +@pytest.mark.usefixtures("tmuxp_configdir_default") +def test_load_multi_file_append_coalesces_into_first_sessions_name( + tmp_path: pathlib.Path, + tmuxp_configdir: pathlib.Path, + server: Server, + session: Session, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``tmuxp load f1 f2 --append`` lands every file in f1's session_name.""" + assert server.socket_name is not None + monkeypatch.chdir(tmp_path) + + (tmuxp_configdir / "first_cfg.yaml").write_text( + "session_name: shared_target\n" + "windows:\n" + " - window_name: from_first\n" + " panes:\n" + " -\n", + encoding="utf-8", + ) + (tmuxp_configdir / "second_cfg.yaml").write_text( + "session_name: should_not_appear\n" + "windows:\n" + " - window_name: from_second\n" + " panes:\n" + " -\n", + encoding="utf-8", + ) + + with contextlib.suppress(SystemExit): + cli.cli( + [ + "load", + "first_cfg", + "second_cfg", + "--append", + "-d", + "-L", + server.socket_name, + "-y", + ], + ) + + assert server.has_session("shared_target") + assert not server.has_session("should_not_appear") + target = server.sessions.get(session_name="shared_target") + assert target is not None + window_names = {w.name for w in target.windows} + assert "from_first" in window_names + assert "from_second" in window_names + + +@pytest.mark.usefixtures("tmuxp_configdir_default") +def test_load_multi_file_append_with_new_session_name_overrides_first_cfg( + tmp_path: pathlib.Path, + tmuxp_configdir: pathlib.Path, + server: Server, + session: Session, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``--append --new-session-name=foo`` wins over the first file's name.""" + assert server.socket_name is not None + monkeypatch.chdir(tmp_path) + + (tmuxp_configdir / "first_cfg.yaml").write_text( + "session_name: from_first_cfg\n" + "windows:\n" + " - window_name: from_first\n" + " panes:\n" + " -\n", + encoding="utf-8", + ) + (tmuxp_configdir / "second_cfg.yaml").write_text( + "session_name: from_second_cfg\n" + "windows:\n" + " - window_name: from_second\n" + " panes:\n" + " -\n", + encoding="utf-8", + ) + + with contextlib.suppress(SystemExit): + cli.cli( + [ + "load", + "first_cfg", + "second_cfg", + "--append", + "-s", + "explicit_target", + "-d", + "-L", + server.socket_name, + "-y", + ], + ) + + assert server.has_session("explicit_target") + assert not server.has_session("from_first_cfg") + assert not server.has_session("from_second_cfg") + target = server.sessions.get(session_name="explicit_target") + assert target is not None + window_names = {w.name for w in target.windows} + assert "from_first" in window_names + assert "from_second" in window_names + + class ZshAutotitleTestFixture(t.NamedTuple): """Test fixture for zsh auto title warning tests."""