diff --git a/.claude/commands/check/parity.md b/.claude/commands/check/parity.md new file mode 100644 index 0000000000..82dca9f61c --- /dev/null +++ b/.claude/commands/check/parity.md @@ -0,0 +1,80 @@ +# /check:parity — Feature Parity Analysis + +Deep-dive analysis of tmuxp vs tmuxinator and teamocil. Updates comparison docs and parity notes. + +This command produces three artifacts: +- Feature parity notes (`notes/parity-*.md`) +- Import behavior notes (`notes/import-*.md`) +- Auto-detection heuristics (in `docs/comparison.md`) + +## Workflow + +1. **Read source code** of all three projects (see `.claude/commands/implement.md` for shared reference codebase paths). Focus on `src/tmuxp/workspace/`, `tmuxinator/lib/tmuxinator/{project,window,pane}.rb` + `hooks/` + `assets/template.erb`, and `teamocil/lib/teamocil/tmux/{session,window,pane}.rb`. + +2. **Read existing docs** for baseline: + - `docs/about.md` — tmuxp's own feature description + - `docs/comparison.md` — feature comparison table (create if missing) + - `notes/parity-tmuxinator.md` — tmuxinator parity analysis (create if missing) + - `notes/parity-teamocil.md` — teamocil parity analysis (create if missing) + +3. **Update `docs/comparison.md`** with tabular feature comparison: + - Overview table (language, min tmux, config format, architecture) + - Configuration keys table (every key across all three, with ✓/✗) + - CLI commands table (side-by-side) + - Architecture comparison (ORM vs script generation vs command objects) + - Include version numbers for each project + +4. **Update `notes/parity-tmuxinator.md`** with: + - Features tmuxinator has that tmuxp lacks (with source locations) + - Import behavior analysis (what the current importer handles vs misses) + - WorkspaceBuilder requirements for 100% feature support + - Code quality issues in current importer + +5. **Update `notes/parity-teamocil.md`** with: + - Features teamocil has that tmuxp lacks (with source locations) + - v0.x vs v1.4.2 format differences (current importer targets v0.x only) + - Import behavior analysis + - WorkspaceBuilder requirements for full parity + +6. **Commit each file separately** + +## Key areas to verify + +- Check `importers.py` line-by-line against actual tmuxinator/teamocil config keys +- Verify `load_workspace()` actually reads config keys it claims to support +- Cross-reference CHANGELOGs for version-specific features +- Check test fixtures match real-world configs + +--- + +# Import Behavior + +Study tmuxp, teamocil, and tmuxinator source code. Find any syntax they support that tmuxp's native syntax doesn't. + +Create/update: +- `notes/import-teamocil.md` +- `notes/import-tmuxinator.md` + +## Syntax Level Differences / Limitations + +For each config key and syntax pattern discovered, classify as: + +### Differences (Translatable) + +Syntax that differs but can be automatically converted during import. Document the mapping. + +### Limitations (tmuxp needs to add support) + +Syntax/features that cannot be imported because tmuxp lacks the underlying capability. For each, note: +1. What the feature does in the source tool +2. Why it can't be imported +3. What tmuxp would need to add + +--- + +# WorkspaceBuilder + +Analyze what WorkspaceBuilder needs to: + +1. **Auto-detect config format** — Determine heuristics to identify tmuxinator vs teamocil vs tmuxp configs transparently +2. **100% feature support** — List every feature/behavior needed for complete compatibility, including behavioral idiosyncrasies diff --git a/.claude/commands/check/shortcomings.md b/.claude/commands/check/shortcomings.md new file mode 100644 index 0000000000..48a3669da0 --- /dev/null +++ b/.claude/commands/check/shortcomings.md @@ -0,0 +1,51 @@ +# /check:shortcomings — API Limitations Analysis + +Second-step command that reads parity analysis and outputs API blockers to `notes/plan.md`. + +## Input Files (from /check:parity) + +- `notes/parity-tmuxinator.md` +- `notes/parity-teamocil.md` +- `notes/import-tmuxinator.md` +- `notes/import-teamocil.md` + +## Workflow + +1. **Read parity analysis files** to understand feature gaps + +2. **Explore libtmux** at `~/work/python/libtmux/`: + - What APIs are missing? (e.g., no `pane.set_title()`) + - What's hardcoded? (e.g., `shutil.which("tmux")`) + +3. **Explore tmuxp** at `~/work/python/tmuxp/`: + - What config keys are dead data? + - What keys are missing from loader/builder? + - What CLI flags are missing? + +4. **Update `notes/plan.md`** with: + - libtmux limitations (what Server/Pane/Window/Session can't do) + - tmuxp limitations (what WorkspaceBuilder/loader/cli can't do) + - Dead config keys (imported but ignored) + - Required API additions for each gap + - Non-breaking implementation notes + +5. **Commit** `notes/plan.md` + +## Output Structure + +notes/plan.md should follow this format: + +### libtmux Limitations +Per-limitation: +- **Blocker**: What API is missing/hardcoded +- **Blocks**: What parity feature this prevents +- **Required**: What API addition is needed + +### tmuxp Limitations +Per-limitation: +- **Blocker**: What's missing/broken +- **Blocks**: What parity feature this prevents +- **Required**: What change is needed + +### Implementation Notes +Non-breaking approach for each limitation. diff --git a/.claude/commands/implement.md b/.claude/commands/implement.md new file mode 100644 index 0000000000..bd7481caa0 --- /dev/null +++ b/.claude/commands/implement.md @@ -0,0 +1,155 @@ +# /implement — Plan and Implement from notes/plan.md + +Orchestrates the full implementation workflow: plan → implement → test → verify → commit → document. + +## Reference Codebases + +- **tmuxinator**: `~/study/ruby/tmuxinator/` +- **teamocil**: `~/study/ruby/teamocil/` +- **tmux**: `~/study/c/tmux/` +- **libtmux**: `~/work/python/libtmux/` +- **tmuxp**: `~/work/python/tmuxp/` + +## Workflow + +### Phase 1: Planning Mode + +1. **Read the plan**: Load `notes/plan.md` to understand what needs to be implemented +2. **Select a task**: Pick the highest priority incomplete item from the plan +3. **Research**: + - Read relevant tmuxinator/teamocil Ruby source for behavior reference + - Read libtmux Python source for available APIs + - Read tmuxp source for integration points + - **Study existing tests** for similar functionality (see Testing Pattern below) +4. **Create implementation plan**: Design the specific changes needed +5. **Exit planning mode** with the finalized approach + +### Phase 2: Implementation + +1. **Make changes**: Edit the necessary files +2. **Follow conventions**: Match existing code style, use type hints, add docstrings + +### Phase 3: Write Tests + +**CRITICAL**: Before running verification, write tests for new functionality. + +1. **Find similar tests**: Search `tests/` for existing tests of similar features +2. **Follow the project test pattern** (see Testing Pattern below) +3. **Add test cases**: Cover normal cases, edge cases, and error conditions + +### Phase 4: Verification + +Run the full QA suite: + +```bash +uv run ruff check . --fix --show-fixes +uv run ruff format . +uv run mypy +uv run py.test --reruns 0 -vvv +``` + +All checks must pass before proceeding. + +### Phase 5: Commit Implementation + +**Source and tests must be in separate commits.** + +1. **Commit source code first**: Implementation changes only (e.g., `fix(cli): Read socket_name/path and config from workspace config`) +2. **Commit tests second**: Test files only (e.g., `tests(cli): Add config key precedence tests for load_workspace`) + +Follow the project's commit conventions (e.g., `feat:`, `fix:`, `refactor:` for source; `tests:` or `tests():` for tests). + +### Phase 6: Update Documentation + +1. **Update `notes/completed.md`**: Add entry for what was implemented + - Date + - What was done + - Files changed + - Any notes or follow-ups + +2. **Update `notes/plan.md`**: Mark the item as complete or remove it + +3. **Commit notes separately**: Use message like `notes: Mark as complete` + +--- + +## Testing Pattern + +This project uses a consistent test pattern. **Always follow this pattern for new tests.** + +### 1. NamedTuple Fixture Class + +```python +import typing as t + +class MyFeatureTestFixture(t.NamedTuple): + """Test fixture for my feature tests.""" + + # pytest (internal): Test fixture name + test_id: str + + # test params + input_value: str + expected_output: str + expected_error: str | None = None +``` + +### 2. Fixture List + +```python +TEST_MY_FEATURE_FIXTURES: list[MyFeatureTestFixture] = [ + MyFeatureTestFixture( + test_id="normal-case", + input_value="foo", + expected_output="bar", + ), + MyFeatureTestFixture( + test_id="edge-case-empty", + input_value="", + expected_output="", + ), + MyFeatureTestFixture( + test_id="error-case", + input_value="bad", + expected_output="", + expected_error="Invalid input", + ), +] +``` + +### 3. Parametrized Test Function + +```python +@pytest.mark.parametrize( + "test", + TEST_MY_FEATURE_FIXTURES, + ids=[test.test_id for test in TEST_MY_FEATURE_FIXTURES], +) +def test_my_feature(test: MyFeatureTestFixture) -> None: + """Test my feature with various inputs.""" + result = my_function(test.input_value) + assert result == test.expected_output + + if test.expected_error: + # check error handling + pass +``` + +See `CLAUDE.md` "Testing Guidelines" for the project-wide test conventions (function tests only, real fixtures, `tmp_path`, `monkeypatch`). + +--- + +## Output + +After completion, report: +- What was implemented +- Files changed (including test files) +- Test results summary +- What remains in the plan + +## Notes + +- If tests fail, fix the issues before committing +- If libtmux changes are needed, note them but don't modify libtmux in this workflow +- One logical change per run — don't implement multiple unrelated items +- **Always write tests** — No implementation is complete without tests diff --git a/CHANGES b/CHANGES index edece8c7ba..c805440159 100644 --- a/CHANGES +++ b/CHANGES @@ -39,6 +39,96 @@ $ pipx install \ _Notes on the upcoming release will go here._ +### New features — tmuxinator/teamocil parity (#1014) + +These additions close the remaining feature gaps with tmuxinator and +teamocil, so configs from either tool now have a tmuxp-native equivalent. + +**New config keys** + +- **`synchronize`** (window-level) — sync keystrokes across the panes of a + window. Accepts `before` / `true` (turn `synchronize-panes` on before + pane commands run) or `after` (turn it on once each pane has finished + setup). Mirrors tmuxinator. +- **`enable_pane_titles`**, **`pane_title_position`** (default `top`), + **`pane_title_format`** (default `"#{pane_index}: #{pane_title}"`) + (session-level) plus per-pane **`title`**. Renders pane titles in the + border bar. tmuxinator's named-pane shorthand (`{name: command}`) is + imported as both a title and the pane command. +- **`shell_command_after`** (window-level) — list of commands sent to + every pane after each pane's main `shell_command` has run. Mirrors + teamocil's `filters.after`. +- **`on_project_start`**, **`on_project_first_start`**, + **`on_project_restart`**, **`on_project_exit`**, **`on_project_stop`** + (session-level) — lifecycle hooks. Each accepts a string or list. Hooks + reuse tmuxp's existing script executor: no `shell=True`, so pipes and + redirects need a real script file. `start` fires on every load, + `first_start` only on a brand-new session, `restart` only when + attaching to an existing one, `exit` after attach, and `stop` runs from + `tmuxp stop`. + +**New CLI flags on `tmuxp load`** + +- **`--here`** — load the workspace into the current tmux session + instead of creating a new one. Mutually exclusive with `--append`; + refuses to run outside a tmux session. +- **`--no-shell-command-before`** — skip every `shell_command_before` + (session, window, and pane). Slightly broader than tmuxinator's + `--no-pre-window`, which only suppresses `pre_window`. +- **`--dry-run`** — execute the build on an isolated tmux socket so your + main tmux server is untouched. tmuxp surfaces libtmux's + `tmux command dispatched` log at INFO so you can see the exact tmux + call sequence; the temporary server is killed when the run finishes. +- **`-D KEY=VALUE` / `--var KEY=VALUE`** (repeatable) — substitute + `${var}` and `${var:-default}` placeholders in the workspace YAML + before parsing, matching tmuxinator's ERB-before-YAML order. Bare + `$name` is intentionally not handled here so `loader.expandshell` can + keep handling environment-variable expansion on specific config keys + after parsing. Stdlib-only — no Jinja2 dependency. + +**New CLI commands** + +- **`tmuxp stop `** — fire `on_project_stop` and kill the matching + session. Idempotent: a no-op if the session isn't running. Mirrors + tmuxinator's `stop` command. +- **`tmuxp new `** — create a starter workspace YAML in your + tmuxp config directory and open it in `$EDITOR`. +- **`tmuxp copy `** — copy an existing workspace and open the + copy in `$EDITOR`. Prompts before overwriting. +- **`tmuxp delete ...`** — delete one or more workspaces, with a + per-name confirmation unless `-y` is supplied. +- **`tmuxp implode`** — delete every workspace in every tmuxp config + directory (legacy `~/.tmuxp/` and XDG `~/.config/tmuxp/`). Single + confirmation; `-y` skips it. + +### Behavior changes — tmuxinator/teamocil import (#1014) + +- **tmuxinator**: `pre` now correctly maps to `before_script` (was: per-pane + `shell_command_before`). Importer warns when `pre` contains shell + constructs like pipes/redirects since `before_script` runs without + `shell=True`. Combo `pre` + `pre_window` previously dropped `pre` + entirely; both keys now map independently. +- **tmuxinator**: `cli_args`/`tmux_options` now use `shlex` parsing. + `-L` (socket name) and `-S` (socket path) are extracted (were silently + dropped). Paths containing `-f` no longer corrupt the config value. + Unknown flags and missing-value flags warn. +- **tmuxinator**: new key translations — `rvm`, `pre_tab`, + `startup_window`, `startup_pane`, `on_project_first_start`, + `socket_path`. `attach: false` warns and directs to `tmuxp load -d`. + `startup_window`/`startup_pane` resolve by name OR integer index to + set `focus: true` on the matching window/pane. +- **teamocil**: v1.x format now supported. Importer auto-dispatches to + v0.x or v1.x based on `session:` wrapper, `splits`/`filters`/`cmd` + markers. v1.x handles bare-string panes, `commands`, per-window/pane + `focus`, window `options`. v1.x pane dicts with no recognizable keys + produce empty panes and warn. +- **teamocil**: v0.x configs now emit `environment: {TEAMOCIL: "1"}` by + default (matches `with_env_var: true` from teamocil 0.4-stable). + Suppress with `with_env_var: false` (also accepts the YAML-quoted + string `"false"`). +- **teamocil**: v0.x pane `width`/`height`/`target` keys now drop with a + WARNING (were silently dropped or passed through as dead data). + ### Documentation - Visual improvements to API docs from [gp-sphinx](https://gp-sphinx.git-pull.com)-based Sphinx packages (#1035) diff --git a/docs/comparison.md b/docs/comparison.md new file mode 100644 index 0000000000..027a694d22 --- /dev/null +++ b/docs/comparison.md @@ -0,0 +1,181 @@ +# Feature Comparison: tmuxp vs tmuxinator vs teamocil + +*Last updated: 2026-03-07* + +## Overview + +| | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| **Version** | 1.64.0 | 3.3.7 | 1.4.2 | +| **Language** | Python | Ruby | Ruby | +| **Min tmux** | 3.2 | 1.5+ (1.5–3.6a tested) | (not specified) | +| **Config formats** | YAML, JSON | YAML (with ERB) | YAML | +| **Architecture** | ORM (libtmux) | Script generation (ERB templates) | Command objects → shell exec | +| **License** | MIT | MIT | MIT | +| **Session building** | API calls via libtmux | Generates bash script, then execs it | Generates tmux command list, renames current session, then `system()` | +| **Plugin system** | Yes (Python classes) | No | No | +| **Shell completion** | Yes | Yes (zsh/bash/fish) | No | + +## Architecture Comparison + +| Tool | Build model | Practical consequence | +|---|---|---| +| tmuxp | libtmux API calls (Python ORM over `tmux(1)`) | Mid-build error recovery, plugin hooks, scripting via Python API | +| tmuxinator | ERB-rendered bash script then `exec`'d | Debuggable script output (`tmuxinator debug`), ERB templating, no mid-build recovery | +| teamocil | `Command` objects joined with `; ` and run via `Kernel.system()` | Simple and predictable, no hooks/templating/recovery | + +## Configuration Keys + +### Session-Level + +| Key | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Session name | `session_name` | `name` / `project_name` | `name` (auto-generated if omitted) | +| Root directory | `start_directory` | `root` / `project_root` | (none, per-window only) | +| Windows list | `windows` | `windows` / `tabs` | `windows` | +| Socket name | (CLI `-L`) | `socket_name` | (none) | +| Socket path | (CLI `-S`) | `socket_path` | (none) | +| Attach on create | (CLI `-d` to detach) | `attach` (default: true) | (always attaches) | +| Tmux config file | (CLI `-f`) | `tmux_options` / `cli_args` | (none) | +| Tmux command | (none) | `tmux_command` (e.g. `wemux`) | (none) | +| Session options | `options` | (none) | (none) | +| Global options | `global_options` | (none) | (none) | +| Environment vars | `environment` | (none) | (none) | +| Pre-build script | `before_script` | `on_project_first_start` / `pre` (deprecated; see Hooks) | (none) | +| Shell cmd before (all panes) | `shell_command_before` | `pre_window` / `pre_tab` / `rbenv` / `rvm` (all deprecated) | (none) | +| Startup window | (none; use `focus: true` on window) | `startup_window` (name or index) | (none; use `focus: true` on window) | +| Startup pane | (none; use `focus: true` on pane) | `startup_pane` | (none; use `focus: true` on pane) | +| Plugins | `plugins` | (none) | (none) | +| ERB/variable interpolation | (none) | Yes (`key=value` args) | (none) | +| YAML anchors | Yes | Yes (via `YAML.safe_load` `aliases: true`) | Yes | +| Pane titles enable | (none) | `enable_pane_titles` | (none) | +| Pane title position | (none) | `pane_title_position` | (none) | +| Pane title format | (none) | `pane_title_format` | (none) | + +### Session Hooks + +| Hook | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Every start invocation | (none) | `on_project_start` | (none) | +| First start only | `before_script` | `on_project_first_start` | (none) | +| On reattach | Plugin: `reattach()` | `on_project_restart` | (none) | +| On exit/detach | (none) | `on_project_exit` | (none) | +| On stop/kill | (none) | `on_project_stop` | (none) | +| Before workspace build | Plugin: `before_workspace_builder()` | (none) | (none) | +| On window create | Plugin: `on_window_create()` | (none) | (none) | +| After window done | Plugin: `after_window_finished()` | (none) | (none) | +| Deprecated pre | (none) | `pre` (deprecated → `on_project_start`+`on_project_restart`; runs before session create) | (none) | +| Deprecated post | (none) | `post` (deprecated → `on_project_stop`+`on_project_exit`; runs after attach on every invocation) | (none) | + +### Window-Level + +| Key | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Window name | `window_name` | hash key | `name` | +| Window index | `window_index` | (auto, sequential) | (auto, sequential) | +| Root directory | `start_directory` | `root` (relative to project root) | `root` | +| Layout | `layout` | `layout` | `layout` | +| Panes list | `panes` | `panes` | `panes` | +| Window options | `options` | (none) | `options` | +| Post-create options | `options_after` | (none) | (none) | +| Shell cmd before | `shell_command_before` | `pre` | (none) | +| Shell for window | `window_shell` | (none) | (none) | +| Environment vars | `environment` | (none) | (none) | +| Suppress history | `suppress_history` | (none) | (none) | +| Focus | `focus` | (none; use `startup_window`) | `focus` | +| Synchronize panes | (none) | `synchronize` (`true`/`before`/`after`; `true`/`before` deprecated → use `after`) | (none) | +| Filters (before) | (none) | (none) | `filters.before` (v0.x) | +| Filters (after) | (none) | (none) | `filters.after` (v0.x) | + +### Pane-Level + +| Key | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Commands | `shell_command` | (value: string/list) | `commands` | +| Root directory | `start_directory` | (none, inherits) | (none, inherits) | +| Shell | `shell` | (none) | (none) | +| Environment vars | `environment` | (none) | (none) | +| Press enter | `enter` | (always) | (always) | +| Sleep before | `sleep_before` | (none) | (none) | +| Sleep after | `sleep_after` | (none) | (none) | +| Suppress history | `suppress_history` | (none) | (none) | +| Focus | `focus` | (none; use `startup_pane`) | `focus` | +| Shell cmd before | `shell_command_before` | (none; inherits from window/session) | (none) | +| Pane title | (none) | hash key (named pane → `select-pane -T`) | (none) | +| Width | (none) | (none) | `width` (v0.x, horizontal split %) | +| Height | (none) | (none) | `height` (v0.x, vertical split %) | +| Split target | (none) | (none) | `target` (v0.x) | + +### Shorthand Syntax + +| Pattern | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| String pane | `- vim` | `- vim` | `- vim` | +| List of commands | `- [cmd1, cmd2]` | `- [cmd1, cmd2]` | `commands: [cmd1, cmd2]` | +| Empty/blank pane | `- blank` / `- pane` / `- null` | `- ` (nil) | (omit commands) | +| Named pane | (none) | `- name: cmd` | (none) | +| Window as string | (none) | `window_name: cmd` | (none) | +| Window as list | (none) | `window_name: [cmd1, cmd2]` | (none) | + +## CLI Commands + +| Function | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Load/start session | `tmuxp load ` | `tmuxinator start ` | `teamocil ` | +| Load detached | `tmuxp load -d ` | `attach: false` / `tmuxinator start --no-attach` | (none) | +| Load with name override | `tmuxp load -s ` | `tmuxinator start -n ` | (none) | +| Append to session | `tmuxp load --append` | `tmuxinator start --append` | (none) | +| List configs | `tmuxp ls` | `tmuxinator list` | `teamocil --list` | +| Edit config | `tmuxp edit ` | `tmuxinator edit ` | `teamocil --edit ` | +| Show/debug config | (none) | `tmuxinator debug ` | `teamocil --show` / `--debug` | +| Create new config | (none) | `tmuxinator new ` | (none) | +| Copy config | (none) | `tmuxinator copy ` | (none) | +| Delete config | (none) | `tmuxinator delete ` | (none) | +| Delete all configs | (none) | `tmuxinator implode` | (none) | +| Stop/kill session | (none) | `tmuxinator stop ` | (none) | +| Stop all sessions | (none) | `tmuxinator stop-all` | (none) | +| Freeze/export session | `tmuxp freeze ` | (none) | (none) | +| Convert format | `tmuxp convert ` | (none) | (none) | +| Import config | `tmuxp import ` | (none) | (none) | +| Search workspaces | `tmuxp search ` | (none) | (none) | +| Python shell | `tmuxp shell` | (none) | (none) | +| Debug/system info | `tmuxp debug-info` | `tmuxinator doctor` | (none) | +| Use here (current window) | (none) | (none) | `teamocil --here` | +| Skip pre_window | (none) | `--no-pre-window` | (none) | +| Pass variables | (none) | `key=value` args | (none) | +| Suppress version warning | (none) | `--suppress-tmux-version-warning` | (none) | +| Custom config path | `tmuxp load /path/to/file` | `-p /path/to/file` | `--layout /path/to/file` | +| Load multiple configs | `tmuxp load f1 f2 ...` (all but last detached) | (none) | (none) | +| Local config | `tmuxp load .` | `tmuxinator local` | (none) | + +## Config File Discovery + +| Feature | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Global directory | `~/.tmuxp/` (legacy), `~/.config/tmuxp/` (XDG) | `~/.tmuxinator/`, `~/.config/tmuxinator/` (XDG), `$TMUXINATOR_CONFIG` | `~/.teamocil/` | +| Local config | `.tmuxp.yaml`, `.tmuxp.yml`, `.tmuxp.json` (traverses up to `~`) | `.tmuxinator.yml`, `.tmuxinator.yaml` (current dir only) | (none) | +| Env override | `$TMUXP_CONFIGDIR` | `$TMUXINATOR_CONFIG` | (none) | +| XDG support | Yes (`$XDG_CONFIG_HOME/tmuxp/`) | Yes (`$XDG_CONFIG_HOME/tmuxinator/`) | No | +| Extension search | `.yaml`, `.yml`, `.json` | `.yml`, `.yaml` | `.yml` | +| Recursive search | No | Yes (`Dir.glob("**/*.{yml,yaml}")`) | No | +| Upward traversal | Yes (cwd → `~`) | No | No | + +## Config Format Auto-Detection Heuristics + +If tmuxp were to auto-detect and transparently load tmuxinator/teamocil configs, these heuristics would distinguish the formats: + +| Indicator | tmuxp | tmuxinator | teamocil v0.x | teamocil v1.x | +|---|---|---|---|---| +| `session_name` key | Yes | No | No | No | +| `name` or `project_name` key | No | Yes | Yes (inside `session:`) | Yes | +| `session:` wrapper | No | No | Yes | No | +| `root` / `project_root` key | No | Yes | Yes | No | +| `start_directory` key | Yes | No | No | No | +| `windows` contains hash-key syntax | No | Yes (`- editor: ...`) | No | No | +| `windows` contains `window_name` key | Yes | No | No | No | +| `windows` contains `name` key | No | No | Yes | Yes | +| `splits` key in windows | No | No | Yes | No | +| `panes` with `cmd` key | No | No | Yes | No | +| `panes` with `commands` key | No | No | No | Yes | +| `panes` with `shell_command` key | Yes | No | No | No | +| `tabs` key | No | Yes (deprecated) | No | No | diff --git a/notes/import-teamocil.md b/notes/import-teamocil.md new file mode 100644 index 0000000000..67e5324a59 --- /dev/null +++ b/notes/import-teamocil.md @@ -0,0 +1,238 @@ +# Teamocil Import Behavior + +*Last updated: 2026-03-07* +*Importer: `src/tmuxp/workspace/importers.py:import_teamocil`* + +## Format Detection Problem + +Teamocil has two distinct config formats: + +- **v0.x** (pre-1.0): `session:` wrapper, `splits`, `filters`, `cmd` +- **v1.x** (1.0–1.4.2): Flat `windows`, `panes`, `commands`, `focus`, `options` + +The current importer **targets v0.x only**. It handles v0.x-specific constructs (`session:` wrapper, `splits`, `filters.before`, `filters.after`, `cmd`) but does not handle v1.x-specific constructs (`commands`, string pane shorthand, `focus`, window `options`). + +Since teamocil 1.4.2 uses the v1.x format, the importer is outdated for current teamocil configs. + +## Syntax Differences (Translatable) + +### 1. Session Wrapper (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `session:` + `name:` + `windows:` | `session_name:` + `windows:` | + +**Importer status**: ✓ Handled (lines 127-128). Unwraps the `session:` key. + +### 2. Session Name + +| teamocil | tmuxp | +|---|---| +| `name: my-layout` | `session_name: my-layout` | + +**Importer status**: ✓ Handled (line 130) + +### 3. Session Root (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `session.root: ~/project` | `start_directory: ~/project` | + +**Importer status**: ✓ Handled (lines 132-133). Note: v1.x teamocil has no session-level root. + +### 4. Window Name + +| teamocil | tmuxp | +|---|---| +| `name: editor` | `window_name: editor` | + +**Importer status**: ✓ Handled (line 138) + +### 5. Window Root + +| teamocil | tmuxp | +|---|---| +| `root: ~/project` | `start_directory: ~/project` | + +**Importer status**: ✓ Handled (lines 151-152) + +### 6. Window Layout + +| teamocil | tmuxp | +|---|---| +| `layout: main-vertical` | `layout: main-vertical` | + +**Importer status**: ✓ Handled (lines 166-167). Same key name, direct pass-through. + +### 7. Splits → Panes (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `splits:` | `panes:` | + +**Importer status**: ✓ Handled (lines 154-155). Renames key. + +### 8. Pane `cmd` → `shell_command` (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `cmd: vim` | `shell_command: vim` | +| `cmd: [cd /path, vim]` | `shell_command: [cd /path, vim]` | + +**Importer status**: ✓ Handled (lines 159-160). Renames key. + +### 9. Filters Before → Shell Command Before (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `filters: { before: [cmd1, cmd2] }` | `shell_command_before: [cmd1, cmd2]` | + +**Importer status**: ⚠ Handled but with redundant loop (lines 144-146). The `for _b in` loop iterates uselessly — the assignment inside is the same each iteration. Should be a direct assignment. + +### 10. Pane `commands` → `shell_command` (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `commands: [git pull, vim]` | `shell_command: [git pull, vim]` | + +**Importer status**: ✗ Not handled. The v1.x `commands` key is not mapped. Only `cmd` (v0.x) is recognized. + +### 11. String Pane Shorthand (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `- git status` (string in panes list) | `- shell_command: [git status]` | + +**Importer status**: ✗ Not handled. The importer expects each pane to be a dict (tries `p["cmd"]`). String panes will cause a `TypeError`. + +### 12. Window Focus (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `focus: true` (on window) | `focus: true` | + +**Importer status**: ✗ Not handled. The key is not imported. + +### 13. Pane Focus (v0.x and v1.x) + +| teamocil | tmuxp | +|---|---| +| `focus: true` (on pane) | `focus: true` | + +**Importer status**: ✓ Accidentally handled in v0.x. The importer modifies pane dicts in-place (only renaming `cmd` → `shell_command` and dropping `width`), so `focus` survives as an unhandled key that passes through. Test fixture `test3.py` and `layouts.py` confirm this. For v1.x format, pane `focus` would also survive if the pane is a dict (but not if it's a string shorthand). + +### 14. Window Options (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `options: { main-pane-width: '100' }` | `options: { main-pane-width: '100' }` | + +**Importer status**: ✗ Not handled. Same key name in tmuxp, but not imported from teamocil configs. + +## Limitations (tmuxp Needs to Add Support) + +### 1. `--here` Flag (Reuse Current Window) + +**What it does in teamocil**: First window is renamed and reused instead of creating a new one. Root directory applied via `cd` command. + +**Why it can't be imported**: This is a runtime CLI flag, not a config key. + +**What tmuxp would need to add**: `--here` flag on `tmuxp load` that tells WorkspaceBuilder to rename the current window for the first window instead of creating new. + +### 2. Filters After / `shell_command_after` (v0.x) + +**What it does in teamocil**: `filters.after` commands run after pane commands. + +**Why it can't be imported**: The importer maps this to `shell_command_after`, but tmuxp has no support for this key in the WorkspaceBuilder. The key is silently ignored. + +**What tmuxp would need to add**: `shell_command_after` key on windows/panes. Builder would send these commands after all pane `shell_command` entries. + +### 3. Pane Width (v0.x) + +**What it does in teamocil v0.x**: `width` on splits to set pane width. + +**Why it can't be imported**: tmuxp drops this with a TODO comment. tmuxp relies on tmux layouts for pane geometry. + +**What tmuxp would need to add**: Per-pane `width`/`height` keys. Builder would use `resize-pane -x ` or `resize-pane -y ` after split. Alternatively, support custom layout strings. + +### 4. Window Clear (v0.x) + +**What it does in teamocil v0.x**: `clear: true` on windows. + +**Why it can't be imported**: The importer preserves the `clear` key but tmuxp doesn't act on it. + +**What tmuxp would need to add**: `clear` key on windows. Builder would send `clear` (or `send-keys C-l`) after pane creation. + +## Import-Only Fixes (No Builder Changes) + +### 5. `with_env_var` (v0.x only) + +**Verified**: `with_env_var` exists in teamocil's v0.x (`0.4-stable` branch) at `lib/teamocil/layout/window.rb`. When `true` (the default), it exports `TEAMOCIL=1` environment variable in each pane's command chain. Removed in v1.x rewrite. + +tmuxp's `environment` key would be the natural mapping: `environment: { TEAMOCIL: "1" }`. However, since this was a default behavior in v0.x (auto-exported unless disabled), the importer should either: +- Always add `environment: { TEAMOCIL: "1" }` unless `with_env_var: false` +- Or simply drop it, since it's an implementation detail of teamocil + +### 6. `cmd_separator` (v0.x only) + +**Verified**: `cmd_separator` exists in teamocil's v0.x at `lib/teamocil/layout/window.rb`. It's a per-window string (default `"; "`) used to join multiple pane commands before sending via `send-keys`. Removed in v1.x (hardcoded to `"; "`). + +tmuxp sends commands individually (one `send_keys` per command), so this is irrelevant — the importer can safely ignore it. + +## Code Issues in Current Importer + +### Bug: Redundant Filter Loop + +```python +# Lines 143-149 (current) +if "filters" in w: + if "before" in w["filters"]: + for _b in w["filters"]["before"]: + window_dict["shell_command_before"] = w["filters"]["before"] + if "after" in w["filters"]: + for _b in w["filters"]["after"]: + window_dict["shell_command_after"] = w["filters"]["after"] +``` + +The `for _b in` loops are pointless — they iterate over the list but set the same value each time. Should be: + +```python +if "filters" in w: + if "before" in w["filters"]: + window_dict["shell_command_before"] = w["filters"]["before"] + if "after" in w["filters"]: + window_dict["shell_command_after"] = w["filters"]["after"] +``` + +### Bug: v1.x String Panes Cause TypeError + +```python +# Lines 157-163 (current) +if "panes" in w: + for p in w["panes"]: + if "cmd" in p: # TypeError if p is a string + p["shell_command"] = p.pop("cmd") +``` + +If `p` is a string (v1.x shorthand), `"cmd" in p` will check for substring match in the string, not a dict key. This will either silently pass (if the command doesn't contain "cmd") or incorrectly match. + +### Verified TODOs: `with_env_var` and `cmd_separator` + +Listed in the importer's docstring TODOs (`importers.py:121-123`). Both verified as v0.x features (present in `0.4-stable` branch, removed in v1.x rewrite). `with_env_var` auto-exports `TEAMOCIL=1`; `cmd_separator` controls command joining. Since the importer targets v0.x, these are valid TODOs — but `cmd_separator` is irrelevant since tmuxp sends commands individually. + +### Missing v0.x Features: `height` + +Not mentioned in the importer TODOs but present in v0.x: +- `height` (pane): Percentage for vertical split (`split-window -p `). Like `width`, silently dropped. + +### Accidentally Preserved: `target` and `focus` + +Through in-place dict mutation, these v0.x pane keys survive the import without explicit handling: +- `target` (pane): Preserved in output (see `layouts.py:106-108`), but tmuxp's WorkspaceBuilder ignores it. +- `focus` (pane): Preserved in output (see `layouts.py:109`, `test3.py:39`), and tmuxp's WorkspaceBuilder **does** use `focus` — so pane focus actually works correctly through the v0.x importer by accident. + +### Silent Drops + +- `clear` is preserved but unused by tmuxp +- `width` is dropped with no user warning +- `shell_command_after` is set but unused by tmuxp diff --git a/notes/import-tmuxinator.md b/notes/import-tmuxinator.md new file mode 100644 index 0000000000..38502b8e1f --- /dev/null +++ b/notes/import-tmuxinator.md @@ -0,0 +1,229 @@ +# Tmuxinator Import Behavior + +*Last updated: 2026-03-07* +*Importer: `src/tmuxp/workspace/importers.py:import_tmuxinator`* + +## Syntax Differences (Translatable) + +These are config keys/patterns that differ syntactically but can be automatically converted during import. + +### 1. Session Name + +| tmuxinator | tmuxp | +|---|---| +| `name: myproject` | `session_name: myproject` | +| `project_name: myproject` | `session_name: myproject` | + +**Importer status**: ✓ Handled (lines 24-29) + +### 2. Root Directory + +| tmuxinator | tmuxp | +|---|---| +| `root: ~/project` | `start_directory: ~/project` | +| `project_root: ~/project` | `start_directory: ~/project` | + +**Importer status**: ✓ Handled (lines 31-34) + +### 3. Windows List Key + +| tmuxinator | tmuxp | +|---|---| +| `tabs:` | `windows:` | +| `windows:` | `windows:` | + +**Importer status**: ✓ Handled (lines 56-57) + +### 4. Window Name Syntax + +| tmuxinator | tmuxp | +|---|---| +| `- editor:` (hash key) | `- window_name: editor` | + +**Importer status**: ✓ Handled (lines 79-81) + +### 5. Window Root + +| tmuxinator | tmuxp | +|---|---| +| `root: ./src` (under window hash) | `start_directory: ./src` | + +**Importer status**: ✓ Handled (lines 96-97) + +### 6. Window Pre-Commands + +| tmuxinator | tmuxp | +|---|---| +| `pre: "source .env"` (under window hash) | `shell_command_before: ["source .env"]` | + +**Importer status**: ✓ Handled (lines 92-93) + +### 7. Socket Name + +| tmuxinator | tmuxp | +|---|---| +| `socket_name: myapp` | `socket_name: myapp` | + +**Importer status**: ✓ Handled (lines 51-52). Note: tmuxp doesn't use `socket_name` as a config key in `WorkspaceBuilder` — it's a CLI flag. The importer preserves it but it may not be used. + +### 8. CLI Args / Tmux Options → Config File + +| tmuxinator | tmuxp | +|---|---| +| `cli_args: "-f ~/.tmux.special.conf"` | `config: ~/.tmux.special.conf` | +| `tmux_options: "-f ~/.tmux.special.conf"` | `config: ~/.tmux.special.conf` | + +**Importer status**: ⚠ Partially handled (lines 36-49). Only extracts `-f` flag value via `str.replace("-f", "").strip()`, which is fragile — it would also match strings containing `-f` as a substring (e.g. a path like `/opt/foobar`). Other flags like `-L` (socket name) and `-S` (socket path) that may appear in `cli_args`/`tmux_options` are silently included in the `config` value, which is incorrect — `config` should only be a file path. + +In tmuxinator, `cli_args` is deprecated in favor of `tmux_options` (`project.rb:17-19`). The actual tmux command is built as `"#{tmux_command}#{tmux_options}#{socket}"` (`project.rb:196`), where `socket` handles `-L`/`-S` separately from `socket_name`/`socket_path` config keys. + +### 9. Rbenv + +| tmuxinator | tmuxp | +|---|---| +| `rbenv: 2.7.0` | `shell_command_before: ["rbenv shell 2.7.0"]` | + +**Importer status**: ✓ Handled (lines 72-77) + +### 10. Pre / Pre-Window Commands + +| tmuxinator | tmuxp (correct) | tmuxp (current importer) | +|---|---|---| +| `pre: "cmd"` (session-level, alone) | `before_script: "cmd"` | `shell_command_before: ["cmd"]` (wrong scope) | +| `pre_window: "cmd"` | `shell_command_before: ["cmd"]` | ✓ Correct (when alone) | +| `pre: "cmd"` + `pre_window: "cmd2"` | `before_script: "cmd"` + `shell_command_before: ["cmd2"]` | `shell_command: "cmd"` (invalid key, lost) + `shell_command_before: ["cmd2"]` | + +**Importer status**: ⚠ Bug (lines 59-70). Two issues: +1. When both `pre` and `pre_window` exist, the importer sets `shell_command` (not a valid tmuxp session-level key) for `pre`. The `pre` commands are silently lost. +2. When only `pre` exists, the importer maps it to `shell_command_before` — but `pre` runs once before session creation (like `before_script`), not per-pane. This changes the semantics from "run once" to "run in every pane." + +In tmuxinator, `pre` is a deprecated session-level command run once before creating windows (in `template.erb:19`, inside the new-session conditional). Its deprecation message says it's replaced by `on_project_start` + `on_project_restart`. `pre_window` is a per-pane command run before each pane's commands (in `template.erb:71-73`). These are different scopes. + +**Correct mapping**: +- `pre` → `before_script` (runs once before windows are created) +- `pre_window` → `shell_command_before` (runs per pane) + +### 11. Window as String/List + +| tmuxinator | tmuxp | +|---|---| +| `- editor: vim` | `- window_name: editor` + `panes: [vim]` | +| `- editor: [vim, "git status"]` | `- window_name: editor` + `panes: [vim, "git status"]` | + +**Importer status**: ✓ Handled (lines 83-90) + +### 12. `startup_window` → `focus` + +| tmuxinator | tmuxp | +|---|---| +| `startup_window: editor` | Set `focus: true` on the matching window | + +**Importer status**: ✗ Not handled. Could be translated by finding the matching window and adding `focus: true`. + +### 13. `startup_pane` → `focus` + +| tmuxinator | tmuxp | +|---|---| +| `startup_pane: 1` | Set `focus: true` on the matching pane | + +**Importer status**: ✗ Not handled. Could be translated by finding the pane at the given index and adding `focus: true`. + +### 14. `pre_tab` → `shell_command_before` + +| tmuxinator | tmuxp | +|---|---| +| `pre_tab: "source .env"` | `shell_command_before: ["source .env"]` | + +**Importer status**: ✗ Not handled. `pre_tab` is a deprecated predecessor to `pre_window` (not an alias — it was renamed). + +### 15. `rvm` → `shell_command_before` + +| tmuxinator | tmuxp | +|---|---| +| `rvm: ruby-2.7@mygemset` | `shell_command_before: ["rvm use ruby-2.7@mygemset"]` | + +**Importer status**: ✗ Not handled. Only `rbenv` is mapped; `rvm` is ignored. + +### 16. `socket_path` + +| tmuxinator | tmuxp | +|---|---| +| `socket_path: /tmp/my.sock` | (CLI `-S /tmp/my.sock`) | + +**Importer status**: ✗ Not handled. `socket_path` is a tmuxinator config key (takes precedence over `socket_name`) but the importer ignores it. tmuxp takes socket path via CLI `-S` flag only. + +### 17. `attach: false` → CLI Flag + +| tmuxinator | tmuxp | +|---|---| +| `attach: false` | `tmuxp load -d` (detached mode) | + +**Importer status**: ✗ Not handled. Could add a comment or warning suggesting `-d` flag. + +### 18. YAML Aliases/Anchors + +| tmuxinator | tmuxp | +|---|---| +| `defaults: &defaults` + `<<: *defaults` | Same (YAML 1.1 feature) | + +**Importer status**: ✓ Handled transparently. YAML aliases are resolved by the YAML parser before the importer sees the dict. No special handling needed. However, tmuxp's test fixtures have **no coverage** of this pattern — real tmuxinator configs commonly use anchors to DRY up repeated settings (see `tmuxinator/spec/fixtures/sample_alias.yml`). + +### 19. Numeric/Emoji Window Names + +| tmuxinator | tmuxp | +|---|---| +| `- 222:` or `- true:` or `- 🍩:` | `window_name: "222"` or `window_name: "True"` or `window_name: "🍩"` | + +**Importer status**: ⚠ Potentially handled but **untested**. YAML parsers coerce bare `222` to int and `true` to bool. tmuxinator handles this via Ruby's `.to_s` method. The importer iterates `window_dict.items()` (line 80) which will produce `(222, ...)` or `(True, ...)` — the `window_name` will be an int/bool, not a string. tmuxp's builder may or may not handle non-string window names correctly. Needs test coverage. + +## Limitations (tmuxp Needs to Add Support) + +These are features that cannot be imported because tmuxp lacks the underlying capability. + +### 1. Lifecycle Hooks + +**What it does in tmuxinator**: Five project hooks (`on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop`) allow running arbitrary commands at different lifecycle stages. + +**Why it can't be imported**: tmuxp only has `before_script` (partial equivalent to `on_project_first_start`). The exit/stop/restart hooks require tmux `set-hook` integration or signal trapping that tmuxp doesn't support. + +**What tmuxp would need to add**: Session-level `on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop` config keys, plus builder logic to execute them at appropriate points. For exit/stop hooks, tmuxp would need a `stop` command and tmux `set-hook` for `client-detached`. + +### 2. Pane Synchronization + +**What it does in tmuxinator**: `synchronize: true/before/after` on windows enables `synchronize-panes` option, with control over whether sync happens before or after pane commands. + +**Why it can't be imported**: tmuxp has no `synchronize` config key. While users can set `synchronize-panes` via `options`, the before/after timing distinction requires builder support. + +**What tmuxp would need to add**: `synchronize` key on windows with `before`/`after`/`true`/`false` values. Builder should call `set-window-option synchronize-panes on` at the appropriate point. + +### 3. Pane Titles + +**What it does in tmuxinator**: Named pane syntax (`pane_name: command`) sets pane titles via `select-pane -T`. Session-level `enable_pane_titles`, `pane_title_position`, `pane_title_format` control display. + +**Why it can't be imported**: tmuxp has no pane title support. + +**What tmuxp would need to add**: Per-pane `title` key, session-level title configuration. Builder calls `select-pane -T ` after pane creation. + +### 4. ERB Templating + +**What it does in tmuxinator**: Config files are processed through ERB before YAML parsing. Supports `<%= @settings["key"] %>` interpolation and full Ruby expressions. Variables passed via `key=value` CLI args. + +**Why it can't be imported**: ERB is a Ruby templating system. The importer receives already-parsed YAML (ERB would have already been processed in Ruby). When importing a raw tmuxinator config file with ERB syntax, YAML parsing will fail. + +**What tmuxp would need to add**: Either a Jinja2 templating pass, Python string formatting, or environment variable expansion in config values. This is a significant architectural feature. + +### 5. Wemux Support + +**What it does in tmuxinator**: `tmux_command: wemux` uses an alternate template and wemux-specific commands. + +**Why it can't be imported**: tmuxp and libtmux are tightly bound to the `tmux` binary. + +**What tmuxp would need to add**: Configurable tmux binary path in libtmux's `Server` class. + +### 6. `--no-pre-window` Flag + +**What it does in tmuxinator**: Skips all `pre_window` commands when starting a session. Useful for debugging. + +**Why it can't be imported**: This is a runtime behavior, not a config key. + +**What tmuxp would need to add**: `--no-shell-command-before` CLI flag on `tmuxp load`. diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md new file mode 100644 index 0000000000..26d0f2b29e --- /dev/null +++ b/notes/parity-teamocil.md @@ -0,0 +1,193 @@ +# Teamocil Parity Analysis + +*Last updated: 2026-03-07* +*Teamocil version analyzed: 1.4.2* +*tmuxp version: 1.64.0* + +## Version History Context + +Teamocil has had two distinct config formats: + +- **v0.x** (pre-1.0): Wrapped in `session:` key, used `splits` for panes, `filters` for before/after commands, `cmd` for pane commands +- **v1.x** (1.0–1.4.2): Simplified format — top-level `windows`, `panes` with `commands` key, `focus` support, window `options` + +The current tmuxp importer (`importers.py:import_teamocil`) **targets the v0.x format**. It handles the `session:` wrapper, `splits`, `filters`, and `cmd` keys — all of which are v0.x-only constructs. It does **not** handle the v1.x format natively, though v1.x configs may partially work since the `windows`/`panes` structure is similar. + +Note: teamocil v1.x does not create new sessions — it **renames** the current session (`rename-session`) and adds windows to it. This is fundamentally different from tmuxp/tmuxinator which create fresh sessions. + +**v1.0 rewrite context** (from teamocil README): Teamocil 1.0 was a complete rewrite that explicitly dropped several v0.x features: no hook system (pre/post execution scripts), no pane-specific environment variables (`with_env_var`), no inline scripting or complex DSL, and no `cmd_separator` customization. The focus narrowed to core declarative window/pane creation. + +## Confirmed parity (no action needed) + +| Feature | tmuxp equivalent / note | +|---|---| +| Window `focus: true` | Same key, same semantics; teamocil applies via `session.rb:24-25` `select-window` after build, tmuxp does the same | +| Pane `focus: true` | Same key, same semantics | +| Window `options` | Same key, maps to `set-window-option` | +| Layout timing (per-pane vs end) | teamocil reapplies `select-layout` after each pane (`pane.rb:9`); tmuxp applies once at end (`builder.py:511`). Same result for named layouts; tmuxp is correct for custom layout strings | +| Root path expansion | teamocil uses `File.expand_path`; tmuxp uses `expandshell()` in `loader.py` | + +## Features teamocil has that tmuxp lacks + +### 1. Session Rename (Not Create) + +**Source**: `lib/teamocil/tmux/session.rb:18-20` + +teamocil does not create a new session. It **renames** the current session via `rename-session` and adds windows to it. If no `name` is provided, it auto-generates one: `"teamocil-session-#{rand(1_000_000)}"`. + +**Gap**: tmuxp always creates a new session (unless appending with `--append`). There is no way to rename and populate the current session. + +### 2. `--here` Option (Reuse Current Window) + +**Source**: `lib/teamocil/tmux/window.rb`, `lib/teamocil/utils/option_parser.rb` + +```bash +teamocil --here my-layout +``` + +When `--here` is specified: +- First window: **renames** current window (`rename-window`) instead of creating a new one +- First window: sends `cd "<root>"` + `Enter` via `send-keys` to change directory (since no `-c` flag is available on an existing window) +- First window: decrements the window count when calculating base indices for subsequent windows +- Subsequent windows: created normally with `new-window` + +**Gap**: tmuxp always creates new windows. There is no way to populate the current window with a layout. + +**WorkspaceBuilder requirement**: Add `--here` CLI flag. For first window, use `rename-window` + `send-keys cd` instead of `new_window()`. Must also adjust window index calculation. This would require special handling in `WorkspaceBuilder.first_window_pass()`. + +### 3. `--show` Option (Show Raw Config) + +**Source**: `lib/teamocil/layout.rb` + +```bash +teamocil --show my-layout +``` + +Outputs the raw YAML content of the layout file. + +**Gap**: tmuxp has no equivalent. Users can `cat` the file manually. + +### 4. `--debug` Option (Show Commands Without Executing) + +**Source**: `lib/teamocil/layout.rb` + +```bash +teamocil --debug my-layout +``` + +Outputs the tmux commands that would be executed, one per line, without running them. + +**Gap**: tmuxp has no dry-run mode. Since tmuxp uses libtmux API calls rather than generating command strings, implementing this would require a logging/recording mode in the builder. + +Note: teamocil also has `--list` (lists available layouts in `~/.teamocil/`) and `--edit` (opens layout file in `$EDITOR`). Both are available in tmuxp (`tmuxp ls`, `tmuxp edit`). + +### 5. Multiple Commands Joined by Semicolon + +**Source**: `lib/teamocil/tmux/pane.rb` + +Teamocil joins multiple pane commands with `; ` and sends them as a single `send-keys` invocation: + +```ruby +# Pane with commands: ["cd /path", "vim"] +# → send-keys "cd /path; vim" +``` + +**Gap**: tmuxp sends each command separately via individual `pane.send_keys()` calls. This is actually more reliable (each command gets its own Enter press), so this is a **behavioral difference** rather than a gap. + +## v0.x vs v1.x Format Differences + +| Feature | v0.x | v1.x (current) | +|---|---|---| +| Top-level wrapper | `session:` key | None (top-level `windows`) | +| Session name | `session.name` | `name` | +| Session root | `session.root` | (none, per-window only) | +| Panes key | `splits` | `panes` | +| Pane commands | `cmd` (string or list) | `commands` (list) | +| Before commands | `filters.before` (list) | (none) | +| After commands | `filters.after` (list) | (none) | +| Pane width | `width` (number, horizontal split %) | (none) | +| Pane height | `height` (number, vertical split %) | (none) | +| Pane target | `target` (pane to split from) | (none) | +| Window clear | `clear` (boolean) | (none) | +| TEAMOCIL env var | `with_env_var` (default true, exports `TEAMOCIL=1`) | (none) | +| Command separator | `cmd_separator` (default `"; "`) | Hardcoded `"; "` | +| ERB templating | Yes (layouts processed as ERB) | No | +| Pane focus | (none) | `focus` (boolean) | +| Window focus | (none) | `focus` (boolean) | +| Window options | (none) | `options` (hash) | +| Pane string shorthand | (none) | `- command_string` | + +## Import Behavior Analysis + +### Current Importer: `importers.py:import_teamocil` + +For the full v0.x key-by-key mapping, see `notes/import-teamocil.md`. + +**What it misses:** + +| Feature | Issue | +|---|---| +| v1.x `commands` key | Not handled — only `cmd` (v0.x) is mapped | +| v1.x pane string shorthand | Not handled — expects dict with `cmd` key | +| v1.x `focus` (window) | Not imported | +| v1.x `focus` (pane) | Not imported | +| v1.x `options` (window) | Not imported | +| Session-level `name` (without `session:` wrapper) | Handled (uses `.get("name")`) | +| v0.x `focus` (pane) | ✓ Accidentally preserved (in-place dict mutation keeps unhandled keys) | +| v0.x `target` (pane) | ✓ Accidentally preserved (same reason) | +| `with_env_var` (v0.x) | Not handled — silently dropped by importer | +| `cmd_separator` (v0.x) | Not handled — silently dropped by importer | + +### Code Quality Issues in Importer + +1. **Lines 144-149**: The `filters.before` and `filters.after` handling has redundant `for _b in` loops that serve no purpose. The inner assignment just reassigns the same value each iteration: + ```python + for _b in w["filters"]["before"]: + window_dict["shell_command_before"] = w["filters"]["before"] + ``` + This iterates N times but sets the same value each time. It should be a direct assignment. + +2. **Lines 140-141**: `clear` is preserved in the config but tmuxp has no handling for it. It will be silently ignored during workspace building. + +3. **Lines 147-149**: `shell_command_after` is set from `filters.after` but is not a tmuxp-supported key. It will be silently ignored during workspace building. + +4. **Lines 161-163**: `width` is silently dropped with a TODO comment. No warning to the user. + +5. **v1.x incompatibility**: The importer assumes v0.x format. A v1.x config with `commands` instead of `cmd`, or string panes, will not import correctly: + - String pane `"git status"` → error (tries to access `p["cmd"]` on a string) + - `commands: [...]` → not mapped to `shell_command` + +6. **No format detection**: The importer doesn't attempt to detect whether the input is v0.x or v1.x format. + +## WorkspaceBuilder Requirements for Full Parity + +### Already Supported (No Changes Needed) + +- Window `focus` — ✓ +- Pane `focus` — ✓ +- Window `options` — ✓ +- Window `layout` — ✓ +- Window `root`/`start_directory` — ✓ +- Pane commands — ✓ + +### Gaps Requiring New Features + +1. **Session rename mode** — teamocil renames the current session rather than creating a new one. tmuxp always creates a fresh session. + +2. **`--here` flag** — Reuse current window for first window of layout. Requires `WorkspaceBuilder` to rename instead of create, and send `cd` for root directory. + +3. **`--debug` / dry-run mode** — Log commands without executing. Architectural challenge since tmuxp uses libtmux API, not command strings. + +4. **`shell_command_after`** — Commands run after pane commands. The importer preserves this from teamocil's `filters.after` but tmuxp has no support for it in the builder. + +### Import-Only Fixes (No Builder Changes) + +5. **v1.x format support** — The importer should handle: + - `commands` key (v1.x) in addition to `cmd` (v0.x) + - String pane shorthand + - `focus` on windows and panes + - `options` on windows + +6. **Redundant loop cleanup** — Fix the `filters` handling code. + +7. **Drop unsupported keys with warnings** — Instead of silently preserving `clear` or dropping `width`, warn the user. diff --git a/notes/parity-tmuxinator.md b/notes/parity-tmuxinator.md new file mode 100644 index 0000000000..542d6d7747 --- /dev/null +++ b/notes/parity-tmuxinator.md @@ -0,0 +1,260 @@ +# Tmuxinator Parity Analysis + +*Last updated: 2026-03-07* +*Tmuxinator version analyzed: 3.3.7 (supports tmux 1.5–3.6a)* +*tmuxp version: 1.64.0* + +## Confirmed parity (no action needed) + +| Feature | tmuxp equivalent | +|---|---| +| `tmuxinator start --name <s>` | `tmuxp load -s <s>` | + +## Features tmuxinator has that tmuxp lacks + +### 1. Project Hooks (Lifecycle Events) + +**Source**: `lib/tmuxinator/hooks/project.rb`, `assets/template.erb` + +tmuxinator has 5 lifecycle hooks: + +| Hook | Description | tmuxp equivalent | +|---|---|---| +| `on_project_start` | Runs on every `start` invocation | No equivalent | +| `on_project_first_start` | Runs only when session doesn't exist yet | `before_script` (partial — runs before windows, but kills session on failure) | +| `on_project_restart` | Runs when attaching to existing session | Plugin `reattach()` (requires writing a plugin) | +| `on_project_exit` | Runs when detaching from session | No equivalent | +| `on_project_stop` | Runs on `tmuxinator stop` | No equivalent (tmuxp has no `stop` command) | + +**Gap**: tmuxp's `before_script` is a partial equivalent of `on_project_first_start` — it runs before windows are created and kills the session on failure. tmuxp has no equivalent for `on_project_start` (runs every time, including reattach), no hooks for detach/exit/stop events, and no distinction between first start vs. restart. + +**Execution order from `template.erb`**: `cd root` → `on_project_start` → (if new session: `pre` → `on_project_first_start` → `new-session` → create windows → build panes → select startup → attach) OR (if existing: `on_project_restart`) → `post` → `on_project_exit`. Note that `post` and `on_project_exit` run on every invocation (outside the new/existing conditional). + +**WorkspaceBuilder requirement**: Add config keys for `on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop`. The exit/stop hooks require shell integration (trap signals, set-hook in tmux). + +### 2. Stop/Kill Session Command + +**Source**: `lib/tmuxinator/cli.rb` (`stop`, `stop_all`), `assets/template-stop.erb` + +tmuxinator provides: + +```bash +tmuxinator stop <project> # Kill specific session + run on_project_stop hook +tmuxinator stop-all # Kill all tmuxinator-managed sessions +``` + +**Gap**: tmuxp has no `stop` or `kill` command. Users must use `tmux kill-session` directly, which skips any cleanup hooks. + +### 4. Startup Window / Startup Pane Selection + +**Source**: `lib/tmuxinator/project.rb` (`startup_window`, `startup_pane`) + +```yaml +startup_window: editor # Select this window after build +startup_pane: 1 # Select this pane within the startup window +``` + +**Gap**: tmuxp supports `focus: true` on windows and panes (boolean), which is equivalent but syntactically different. The `startup_window` key allows referencing by window name or numeric index (rendered as `"#{name}:#{value}"`, defaults to `base_index` if omitted). The `startup_pane` is relative to the startup window (rendered as `"#{startup_window}.#{value}"`, defaults to `pane_base_index`). **Partial parity** — tmuxp can achieve this but uses a different mechanism (`focus` key on individual windows/panes rather than a centralized key). + +### 5. Pane Synchronization + +**Source**: `lib/tmuxinator/window.rb` (`synchronize`) + +```yaml +windows: + - editor: + synchronize: true # or "before" or "after" + panes: + - vim + - vim +``` + +- `synchronize: true` / `synchronize: before` — enable pane sync before running pane commands (**deprecated** in tmuxinator `project.rb:21-29`) +- `synchronize: after` — enable pane sync after running pane commands (recommended) + +Note: tmuxinator deprecates `synchronize: true` and `synchronize: before` in favor of `synchronize: after`. The deprecation message says `before` was the original behavior but `after` is the recommended pattern. Import should still honor the original semantics of each value. + +**Gap**: tmuxp has no `synchronize` config key. Users would need to set `synchronize-panes on` via `options` manually, but this doesn't support the before/after distinction. + +**WorkspaceBuilder requirement**: Add `synchronize` key to window config with `before`/`after`/`true`/`false` values. + +### 6. Pane Titles + +**Source**: `lib/tmuxinator/project.rb`, `lib/tmuxinator/pane.rb` + +```yaml +enable_pane_titles: true +pane_title_position: top # default: "top" +pane_title_format: "#{pane_index}: #{pane_title}" # this is the default format +windows: + - editor: + panes: + - my-editor: vim # "my-editor" becomes the pane title +``` + +**Gap**: tmuxp has no pane title support. Named panes in tmuxinator (hash syntax: `pane_name: command`) set both a title and commands. + +**WorkspaceBuilder requirement**: Add session-level `enable_pane_titles`, `pane_title_position`, `pane_title_format` keys. Add per-pane `title` key. Issue `select-pane -T <title>` after pane creation. + +### 7. ERB Templating / Variable Interpolation + +**Source**: `lib/tmuxinator/project.rb` (`parse_settings`, `render_template`) + +```bash +tmuxinator start myproject env=production port=3000 +``` + +```yaml +# config.yml +root: ~/apps/<%= @settings["app"] %> +windows: + - server: + panes: + - rails server -p <%= @settings["port"] || 3000 %> +``` + +**Gap**: tmuxp has no config templating. Environment variable expansion (`$VAR`) is supported in `start_directory` paths, but not arbitrary variable interpolation in config values. + +**WorkspaceBuilder requirement**: This is an architectural difference. tmuxp could support Jinja2 templating or Python string formatting, but this is a significant feature addition. + +### 8. Wemux Support + +**Source**: `lib/tmuxinator/wemux_support.rb`, `assets/wemux_template.erb` + +```yaml +tmux_command: wemux +``` + +**Gap**: tmuxp has no wemux support. libtmux is tightly bound to the `tmux` command. + +**WorkspaceBuilder requirement**: Allow configurable tmux command binary. Requires libtmux changes. + +### 9. Debug / Dry-Run Output + +**Source**: `lib/tmuxinator/cli.rb` (`debug`) + +```bash +tmuxinator debug myproject +``` + +Outputs the generated shell script without executing it. + +**Gap**: tmuxp has no dry-run mode. Since tmuxp uses API calls rather than script generation, a dry-run would need to log the libtmux calls that *would* be made. + +### 10. Config Management Commands + +**Source**: `lib/tmuxinator/cli.rb` + +| Command | Description | +|---|---| +| `tmuxinator new <name>` | Create new config from template | +| `tmuxinator copy <src> <dst>` | Copy existing config | +| `tmuxinator delete <name>` | Delete config (with confirmation) | +| `tmuxinator implode` | Delete ALL configs | +| `tmuxinator stop <project>` | Stop session + run hooks | +| `tmuxinator stop-all` | Stop all managed sessions | +| `tmuxinator doctor` | Check system setup (tmux installed, version) | +| `tmuxinator completions` | Shell completion helper | + +**Gap**: tmuxp has `edit` but not `new`, `copy`, `delete`, `implode`, `stop`, or `doctor` commands. + +Additional CLI flags on `start`: +- `--append` — append windows to existing session (tmuxp has `--append`) +- `--no-pre-window` — skip pre_window commands (tmuxp lacks this) +- `--project-config` / `-p` — use specific config file (tmuxp uses positional arg) +- `--suppress-tmux-version-warning` — skip tmux version check + +Additional flags on `list`: +- `--active` / `-a` — filter by active sessions (tmuxp lacks this) +- `--newline` / `-n` — one entry per line + +### 11. `--no-pre-window` Flag + +**Source**: `lib/tmuxinator/cli.rb` + +```bash +tmuxinator start myproject --no-pre-window +``` + +Skips `pre_window` commands. Useful for debugging. + +Note: tmuxinator's `pre_window` method has a fallback chain (`project.rb:175-186`): `pre_window` → `pre_tab` → `rbenv` → `rvm` (highest priority first, using Ruby's `||` operator). The `--no-pre-window` flag disables all of these, not just `pre_window`. + +**Gap**: tmuxp has no equivalent flag to skip `shell_command_before`. + +### 12. Create Config from Running Session + +**Source**: `lib/tmuxinator/cli.rb` (`new <name> <session>`) + +```bash +tmuxinator new myproject existing-session-name +``` + +Creates a config file pre-populated from a running tmux session. Note: tmuxinator captures only the window/pane structure and names, not running commands. + +**Gap**: tmuxp has `tmuxp freeze` which exports to YAML/JSON with more detail (captures pane working directories and current commands). Different approach, functionally equivalent. + +## Import Behavior Analysis + +### Current Importer: `importers.py:import_tmuxinator` + +For the full key-by-key mapping, see `notes/import-tmuxinator.md`. + +**What it misses or handles incorrectly:** + +| tmuxinator key | Issue | +|---|---| +| `attach` | Not imported. tmuxp uses CLI flags instead. | +| `startup_window` | Not imported. tmuxp uses `focus: true` on windows. | +| `startup_pane` | Not imported. tmuxp uses `focus: true` on panes. | +| `tmux_command` | Not imported. tmuxp has no equivalent. | +| `socket_path` | Not imported. tmuxp takes this via CLI. | +| `pre_tab` | Not imported (deprecated predecessor to `pre_window`). | +| `rvm` | Not imported (only `rbenv` is handled). | +| `post` | Not imported. tmuxp has no equivalent. | +| `synchronize` | Not imported. tmuxp has no equivalent. | +| `enable_pane_titles` | Not imported. tmuxp has no equivalent. | +| `pane_title_position` | Not imported. tmuxp has no equivalent. | +| `pane_title_format` | Not imported. tmuxp has no equivalent. | +| `on_project_start` | Not imported. tmuxp has no equivalent. | +| `on_project_first_start` | Not imported. Could map to `before_script`. | +| `on_project_restart` | Not imported. tmuxp has no equivalent. | +| `on_project_exit` | Not imported. tmuxp has no equivalent. | +| `on_project_stop` | Not imported. tmuxp has no equivalent. | +| Named panes (hash syntax) | Not imported. Pane names/titles are lost. | +| ERB templating | Not handled. YAML parsing will fail on ERB syntax. | +| `pre` mapping | Bug: maps to `shell_command_before` (per-pane) instead of `before_script` (once); combo with `pre_window` uses invalid `shell_command` key | + +### Code Quality Issues in Importer + +1. **Lines 59-70 (`pre` handling)**: Two bugs: + - When both `pre` and `pre_window` exist (line 60), the importer sets `tmuxp_workspace["shell_command"]` — but `shell_command` is not a valid session-level tmuxp key. The `pre` commands are silently lost. + - When only `pre` exists (line 68), it maps to `shell_command_before` — but tmuxinator's `pre` runs *once* before session creation (`template.erb:19`), not per-pane. The correct mapping is `before_script`. + +2. **Lines 36-49**: The `cli_args`/`tmux_options` handler only extracts `-f` (config file). It ignores `-L` (socket name) and `-S` (socket path) which could also appear in these fields. + +3. **Line 79-101**: The window iteration uses `for k, v in window_dict.items()` which assumes windows are always dicts with a single key (the window name). This is correct for tmuxinator's format but fragile — if a window dict has multiple keys, only the last one is processed. + +4. **Missing `pre_tab`**: The `pre_tab` deprecated predecessor to `pre_window` is not handled. + +5. **Missing `rvm`**: Only `rbenv` is imported; `rvm` (another deprecated but still functional key) is ignored. In tmuxinator, `rvm` maps to `rvm use #{value}` (`project.rb:181`). + +6. **No validation or warnings**: The importer silently drops unsupported keys with no feedback to the user. + +## WorkspaceBuilder Requirements for 100% Feature Support + +### Must-Have for Parity + +1. **Pane synchronization** (`synchronize` window key) — `set-window-option synchronize-panes on/off` +2. **Pane titles** — `select-pane -T <title>`, `set-option pane-border-status top`, `set-option pane-border-format <fmt>` +3. **Startup window/pane selection** — Already achievable via `focus: true`, but could add `startup_window`/`startup_pane` as aliases +4. **Stop command** — `tmuxp stop <session>` to kill session + +### Nice-to-Have + +5. **Lifecycle hooks** — `on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop` +6. **Config templating** — Jinja2 or Python format string support for config values +7. **Debug/dry-run** — Log tmux commands without executing +8. **Config management** — `tmuxp new`, `tmuxp copy`, `tmuxp delete` commands +9. **`--no-shell-command-before`** flag — Skip `shell_command_before` for debugging +10. **Custom tmux binary** — `tmux_command` key for wemux/byobu support (requires libtmux changes) diff --git a/notes/plan.md b/notes/plan.md new file mode 100644 index 0000000000..4434b13931 --- /dev/null +++ b/notes/plan.md @@ -0,0 +1,224 @@ +# Parity Implementation Plan + +*Last updated: 2026-03-15* + +## libtmux Limitations + +### L1. No `Pane.set_title()` Method — **RESOLVED in libtmux v0.55.0** + +**Status**: `Pane.set_title(title)` added at `pane.py:834-859`. Unblocks T2. + +### L2. Hardcoded tmux Binary Path — **RESOLVED in libtmux v0.55.0** + +**Status**: `Server(tmux_bin=...)` added at `server.py:142`. Unblocks tmuxinator `tmux_command`. + +### L3. No Dry-Run / Command Preview Mode — **RESOLVED in libtmux v0.55.0** + +**Status**: Pre-execution `logger.debug` added at `common.py:263-268`. Unblocks T9. + +**Note**: Since tmuxp uses libtmux API calls (not command strings), a true dry-run would require a recording layer in `WorkspaceBuilder` that logs each API call. This is architecturally different from tmuxinator/teamocil's approach and may not be worth full parity. + +### L4. Available APIs (No Blockers) + +These libtmux APIs already exist and do NOT need changes: + +| API | Location | Supports | +|---|---|---| +| `Session.rename_session(name)` | `session.py:422` | teamocil session rename mode | +| `Window.rename_window(name)` | `window.py:462` | teamocil `--here` flag | +| `Pane.resize(height, width)` | `pane.py:217` | teamocil v0.x pane `width` | +| `Pane.send_keys(cmd, enter)` | `pane.py:423` | All command sending | +| `Pane.select()` | `pane.py:586` | Pane focus | +| `Window.set_option(key, val)` | `options.py:593` (OptionsMixin) | `synchronize-panes`, window options | +| `Session.set_hook(hook, cmd)` | `hooks.py:118` (HooksMixin) | Lifecycle hooks (`client-detached`, etc.) | +| `Session.set_option(key, val)` | `options.py:593` (OptionsMixin) | `pane-border-status`, `pane-border-format` | +| `HooksMixin` on Session/Window/Pane | `session.py:55`, `window.py:56`, `pane.py:51` | All entities inherit hooks | +| `HooksMixin.set_hooks()` (bulk) | `hooks.py:437` | Efficient multi-hook setup (dict/list input) | +| `Session.set_environment(key, val)` | `common.py:63` (EnvironmentMixin) | Session-level env vars (teamocil `with_env_var`) | +| `Pane.clear()` | `pane.py:869` | Sends `reset` to clear pane (teamocil `clear`) | +| `Pane.reset()` | `pane.py:874` | `send-keys -R \; clear-history` (full reset) | +| `Pane.split(target=...)` | `pane.py:634` | Split targeting (teamocil v0.x `target`) | + +## tmuxp Limitations + +> **Status (2026-05-09):** Every gap below has shipped on the +> `tmuxinator-parity` branch. tmuxp now has feature parity with +> tmuxinator and teamocil. Sections below remain as historical design +> references; see `CHANGES` for the user-visible feature summary. + +### T1. No `synchronize` Config Key + +- **Blocker**: `WorkspaceBuilder` (`builder.py`) does not check for a `synchronize` key on window configs. Silently ignored if present. +- **Blocks**: Pane synchronization (tmuxinator `synchronize: true/before/after`). tmuxinator deprecates `true`/`before` in favor of `after` (`project.rb:21-29`), but all three values still function — import should honor original semantics. +- **Required**: For `before`/`true`, call `window.set_option("synchronize-panes", "on")` in `build()` ~line 541 (after `on_window_create` plugin hook, before `iter_create_panes()` loop). For `after`, same call in `config_after_window()` ~line 822. For `false`/omitted, no action. +- **Note**: In tmux 3.2+ (tmuxp's minimum), `synchronize-panes` is a dual-scope option (window\|pane, `options-table.c:1423`); window-level set propagates to later splits. +- **Non-breaking**: New optional config key. + +### Simple gaps (table form) + +| ID | Gap | Required change | Notes | +|---|---|---|---| +| T3 | No `shell_command_after` config key | In `config_after_window()` ~line 822 (or after `iter_create_panes()`), read `window_config.get("shell_command_after", [])` and `pane.send_keys()` to each pane | teamocil importer already produces this on the **window** dict (`importers.py:149`); only builder read is missing | +| T7 | No `--no-shell-command-before` CLI flag | Add flag to `cli/load.py`; clear `shell_command_before` from all levels before `trickle()` (`loader.py:245-256`) | Mirrors tmuxinator `--no-pre-window` | +| T10 | Missing config management commands (`new`, `copy`, `delete`) | Add CLI commands in `cli/`; straightforward file operations | Mirrors tmuxinator `new`, `copy`, `delete`, `implode` | + +### T2. No Pane Title Config Key + +- **Blocker**: `WorkspaceBuilder` has no handling for pane `title` key or session-level `enable_pane_titles` / `pane_title_position` / `pane_title_format`. +- **Blocks**: Pane titles (tmuxinator named pane syntax). +- **Required**: + 1. Session-level: set `pane-border-status` and `pane-border-format` options via `session.set_option()` in `build()` alongside other session options (lines 529-539). + 2. Pane-level: call `pane.cmd("select-pane", "-T", title)` after commands are sent in `iter_create_panes()`, before focus handling (around line 816). Requires L1 (libtmux `set_title()`), or can use `pane.cmd()` directly. +- **Config keys**: `enable_pane_titles: true`, `pane_title_position: top`, `pane_title_format: "..."` (session-level). `title: "my-title"` (pane-level). +- **Non-breaking**: New optional config keys. + +### T4. No Session Rename Mode / `--here` CLI Flag + +- **Blocker**: `tmuxp load` (`cli/load.py`) has no `--here` flag. `WorkspaceBuilder.iter_create_windows()` always creates new windows via `session.new_window()` (line 649). Additionally, teamocil always renames the current session (`session.rb:18-20`), regardless of `--here`; the `--here` flag only affects **window** behavior (reuse current window for first window instead of creating new). tmuxp's `--append` flag partially covers session rename mode, but does not rename the session. +- **Blocks**: teamocil `--here` (reuse current window for first window) and teamocil session rename (always active, not conditional on `--here`). +- **Required**: + 1. Add `--here` flag to `cli/load.py` (around line 516, near `--append`). + 2. Pass `here=True` through to `WorkspaceBuilder.build()`. + 3. In `iter_create_windows()`, when `here=True` and first window: use `window.rename_window(name)` instead of `session.new_window()`, and send `cd <root>` via `pane.send_keys()` for directory change. + 4. Adjust `first_window_pass()` logic (line 864). + 5. For session rename: when `--here` is used, also call `session.rename_session(name)` (line 262 area in `build()`). +- **Depends on**: libtmux `Window.rename_window()` and `Session.rename_session()` (both already exist, L4). +- **Non-breaking**: New optional CLI flag. + +### T5. No `stop` / `kill` CLI Command + +- **Blocker**: tmuxp has no `stop` command. The CLI modules (`cli/__init__.py`) only register: `load`, `freeze`, `ls`, `search`, `shell`, `convert`, `import`, `edit`, `debug-info`. +- **Blocks**: tmuxinator `stop` / `stop-all` — kill session with cleanup hooks. +- **Required**: Add `tmuxp stop <session>` command. Implementation: find session by name via `server.sessions`, call `session.kill()`. For hook support, run `on_project_stop` hook before kill. +- **Non-breaking**: New CLI command. + +### T6. No Lifecycle Hook Config Keys + +- **Blocker**: tmuxp's plugin system (`plugin.py:216-292`) has 5 hooks: `before_workspace_builder`, `on_window_create`, `after_window_finished`, `before_script`, `reattach`. These are Python plugin hooks, not config-driven shell command hooks. There are no config keys for `on_project_start`, `on_project_exit`, etc. +- **Blocks**: tmuxinator lifecycle hooks (`on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop`). +- **Required**: Add config-level hook keys. Mapping: + - `on_project_start` → run shell command at start of `build()`, before `before_script` + - `on_project_first_start` → already partially covered by `before_script` + - `on_project_restart` → run when reattaching (currently only plugin `reattach()` hook) + - `on_project_exit` → use tmux `set-hook client-detached` via `session.set_hook()` (libtmux L4) + - `on_project_stop` → run in new `tmuxp stop` command (T5) +- **Depends on**: T5 for `on_project_stop`. +- **Non-breaking**: New optional config keys. + +### T8. No Config Templating + +- **Blocker**: tmuxp has no user-defined variable interpolation. Environment variable expansion (`$VAR` via `os.path.expandvars()`) already works in most config values — `session_name`, `window_name`, `start_directory`, `before_script`, `environment`, `options`, `global_options` (see `loader.py:108-160`). But there is no way to pass custom `key=value` variables at load time. +- **Blocks**: tmuxinator ERB templating (`<%= @settings["key"] %>`). +- **Required**: Add a Jinja2 or Python `string.Template` pass before YAML parsing. Allow `key=value` CLI args to set template variables. This is a significant architectural addition. +- **Non-breaking**: Opt-in feature, existing configs are unaffected. + +### T9. No `--debug` / Dry-Run CLI Flag + +- **Blocker**: `tmuxp load` has no dry-run mode. Since tmuxp uses libtmux API calls rather than generating command strings, there's no natural command list to preview. +- **Blocks**: tmuxinator `debug` and teamocil `--debug` / `--show`. +- **Required**: Either (a) add a recording proxy layer around libtmux calls that logs what would be done, or (b) add verbose logging that shows each tmux command before execution (depends on L3). +- **Non-breaking**: New optional CLI flag. + +## Dead Config Keys + +Keys produced by importers but silently ignored by the builder: + +| Key | Producer | Importer Line | Builder Handling | Issue | +|---|---|---|---|---| +| `shell_command` (session-level) | tmuxinator importer | `importers.py:71` | Not a valid session key | **Bug** (I1 Bug B): `pre` commands lost when both `pre` and `pre_window` exist | +| `config` | tmuxinator importer | `importers.py:48,55` | Never read | Dead data — extracted `-f` path goes nowhere | +| `socket_name` | tmuxinator importer | `importers.py:63` | Never read | Dead data — CLI uses `-L` flag | +| `clear` | teamocil importer | `importers.py:158` | Never read | Dead data — builder doesn't read it, but libtmux has `Pane.clear()` (L4) | +| `height` (pane) | teamocil importer | passthrough (not popped) | Never read | Dead data — `width` is popped but `height` passes through silently | +| `target` (pane) | teamocil importer | passthrough (not popped) | Never read | Dead data — accidentally preserved via dict mutation, but libtmux has `Pane.split(target=...)` (L4) | +| `shell_command_after` | teamocil importer | `importers.py:166` | Never read | Dead data — tmuxp has no after-command support | + +## Importer Bugs (No Builder Changes Needed) + +For full bug analysis with file:line refs, see `notes/import-tmuxinator.md` and `notes/import-teamocil.md`. + +| ID | Importer | Bug | Fix scope | +|---|---|---|---| +| I1 | tmuxinator | `pre` (alone) maps to `shell_command_before` (per-pane) instead of `before_script` (once); combo `pre` + `pre_window` writes `pre` to invalid `shell_command` key and `isinstance` checks the wrong var (lines 70-81) | **Resolved**: maps `pre` → `before_script`; warns on shell metacharacters (Popen runs without `shell=True`). Long-term shell fix waits on T6. | +| I2 | tmuxinator | `cli_args`/`tmux_options` use `str.replace("-f", "")` (lines 50-60); breaks on `-L`/`-S` flags or paths containing `-f` | **Resolved**: shlex-based parsing extracts `-f`/`-L`/`-S`; warns on unknown flags. | +| I3 | teamocil | Redundant `for _b in w["filters"]["before"]` loops set same value N times (lines 160-166) | **Resolved**: direct assignment. | +| I4 | teamocil | v1.x format not detected: string panes cause `"cmd" in str` substring check; `commands` key dropped; pane `width` silently dropped, `height` passes through | **Resolved**: dispatch to `_import_teamocil_v0x` / `_import_teamocil_v1x`; v0.x detected by `session:` wrapper OR window `splits`/`filters`/pane `cmd`. v1.x handles string panes, `commands`, per-window/pane `focus`, window `options`. | +| I5 | tmuxinator | Missing translations: `rvm`, `pre_tab`, `startup_window`, `startup_pane`, `on_project_first_start`, `post`, `socket_path`, `attach: false` | **Resolved**: rbenv → rvm → pre_tab → pre_window OR-fallback chain implemented; `startup_window`/`startup_pane` resolved by name or int → `focus: true`; `on_project_first_start` → `before_script`; `socket_path` pass-through; `attach: false` warns. `post` deferred (needs T6). | +| I6 | teamocil | Missing v1.x mappings (`commands`, window/pane `focus`, window `options`, string pane shorthand) and v0.x `with_env_var`/`height` | **Resolved** by I4 (v1.x mappings) + I7 (`with_env_var`); v0.x `height`/`target` now popped with WARNING. | +| I7 | teamocil | TODOs at `importers.py:132-134` (`with_env_var`, `clear`, `cmd_separator`) — first two are real v0.x features, third is irrelevant | **Resolved**: `with_env_var` → session-level `environment: {TEAMOCIL: "1"}` for v0.x configs; `cmd_separator` warns; `clear` preserved with WARNING (builder support deferred). | + +## Test Coverage Gaps + +Current importer test fixtures cover ~40% of real-world config patterns. Key gaps by severity: + +### Tier 1: Will Crash or Silently Lose Data + +- **v1.x teamocil string panes**: `panes: ["git status"]` → `TypeError` (importer tries `"cmd" in p` on string) +- **v1.x teamocil `commands` key**: `commands: [...]` → silently dropped (only `cmd` recognized) +- **tmuxinator `rvm`**: Completely ignored by importer (only `rbenv` handled) +- **tmuxinator `pre` scope bug**: Tests pass because fixtures don't verify execution semantics + +### Tier 2: Missing Coverage + +- **YAML aliases/anchors**: Real tmuxinator configs use `&defaults` / `*defaults` — no test coverage +- **Numeric/emoji window names**: `222:`, `true:`, `🍩:` — YAML type coercion edge cases untested +- **Pane title syntax**: `pane_name: command` dict form — no fixtures +- **`startup_window`/`startup_pane`**: Not tested +- **`pre_tab`** (deprecated): Not tested +- **Window-level `root` with relative paths**: Not tested +- **`tmux_options` with non-`-f` flags**: Not tested (importer bug I2) + +### Required New Fixtures + +When implementing Phase 1 import fixes, each item needs corresponding test fixtures. See `tests/fixtures/import_tmuxinator/` and `tests/fixtures/import_teamocil/` for existing patterns. + +**tmuxinator fixtures needed**: YAML aliases, emoji names, numeric names, `rvm`, `pre_tab`, `startup_window`/`startup_pane`, pane titles, `socket_path`, multi-flag `tmux_options` + +**teamocil fixtures needed**: v1.x format (`commands`, string panes, window `focus`/`options`), pane `height`, `with_env_var`, mixed v0.x/v1.x detection + +## Implementation Priority + +### Phase 1: Import Fixes (No Builder/libtmux Changes) + +These fix existing bugs and add missing translations without touching the builder: + +1. **I3**: Fix redundant filter loops (teamocil) +2. **I4**: Add v1.x teamocil format support +3. **I6**: Import teamocil v1.x keys (`commands`, `focus`, `options`, string panes) +4. **I5**: Import missing tmuxinator keys (`rvm`, `pre_tab`, `startup_window`, `startup_pane`) +5. **I1**: Fix `pre`/`pre_window` mapping (tmuxinator) +6. **I2**: Fix `cli_args` parsing (tmuxinator) +7. **I7**: Triage importer TODOs (implement `with_env_var`, remove `cmd_separator`) + +### Phase 2: Builder Additions (tmuxp Only) + +These add new config key handling to the builder. Each also needs a corresponding importer update: + +1. **T1**: `synchronize` config key — straightforward `set_option()` call + - Then update tmuxinator importer to import `synchronize` key (pass-through, same name) +2. **T3**: `shell_command_after` config key — straightforward `send_keys()` loop + - teamocil importer already produces this key (I3 fixes the loop); builder just needs to read it +3. **T2**: Pane title config keys — **now unblocked** (L1 resolved in libtmux v0.55.0) + - Use `pane.set_title()` in builder. Session-level options via `session.set_option()`. + - Update tmuxinator importer for named pane syntax +4. **T4**: `--here` CLI flag — moderate complexity, uses existing libtmux APIs + +### ~~Phase 3: libtmux Additions~~ — **COMPLETE** (libtmux v0.55.0, issue #635 closed) + +All libtmux API additions shipped in v0.55.0 (2026-03-07). tmuxp pins `libtmux~=0.55.0`. + +- ~~**L1**: `Pane.set_title()`~~ → `pane.py:834-859` +- ~~**L2**: `Server(tmux_bin=...)`~~ → `server.py:142` +- ~~**L3**: Pre-execution `logger.debug`~~ → `common.py:263-268` + +### Phase 4: New CLI Commands + +1. **T5**: `tmuxp stop` command +2. **T10**: `tmuxp new`, `tmuxp copy`, `tmuxp delete` commands + +### Phase 5: CLI Flags & Larger Features + +1. **T7**: `--no-shell-command-before` flag — simple +2. **T9**: `--debug` / dry-run mode — **now unblocked** (L3 resolved in libtmux v0.55.0) +3. **T6**: Lifecycle hook config keys — complex, needs design +4. **T8**: Config templating — significant architectural addition diff --git a/src/tmuxp/_internal/template.py b/src/tmuxp/_internal/template.py new file mode 100644 index 0000000000..55c36cda3c --- /dev/null +++ b/src/tmuxp/_internal/template.py @@ -0,0 +1,140 @@ +"""Minimal template engine for tmuxp workspace files. + +Two substitution forms only — keep the surface small and predictable: + +- ``${name}`` — required variable; raises in strict mode if missing +- ``${name:-default}`` — optional, falls back to the literal default text + +Bare ``$name`` is intentionally NOT supported: the post-parse expansion in +:func:`tmuxp.workspace.loader.expandshell` already handles ``$ENV_VAR`` +forms on specific config keys. Keeping this engine tied to ``${...}`` lets +the two layers cohabit without ambiguity. + +Templating runs on the raw YAML text before +:class:`tmuxp._internal.config_reader.ConfigReader` parses it, +mirroring tmuxinator's ERB-before-YAML behaviour. + +This module deliberately has no runtime dependencies beyond :mod:`re`. +""" + +from __future__ import annotations + +import re +import typing as t + +__all__ = [ + "UnresolvedVariableError", + "parse_cli_vars", + "render", +] + +_VAR_PATTERN = re.compile( + r""" + \$\{ # opening ${ + (?P<name>[A-Za-z_][A-Za-z0-9_]*) # variable name + (?: # optional default group + :- # default operator + (?P<default>[^}]*) # default value (no nested braces) + )? + \} # closing } + """, + re.VERBOSE, +) + + +class UnresolvedVariableError(KeyError): + """A template referenced a variable with no value and no default. + + Subclass of :class:`KeyError` so callers using ``except KeyError`` keep + working, but the more specific name lets tmuxp's CLI distinguish this + from an actual dict-key miss. + """ + + +def render( + template: str, + variables: t.Mapping[str, str], + *, + strict: bool = True, +) -> str: + """Substitute ``${name}`` and ``${name:-default}`` placeholders. + + Parameters + ---------- + template : str + The raw text containing placeholders. + variables : Mapping[str, str] + Substitution dictionary (typically from CLI ``KEY=VALUE`` args). + strict : bool, optional + When ``True`` (the default), an unresolved placeholder without a + default raises :class:`UnresolvedVariableError`. When ``False``, + unresolved placeholders are left in the output untouched — useful + when a later pipeline stage (e.g. ``loader.expandshell``) will + resolve them. + + Returns + ------- + str + The rendered template. + + >>> render("hello ${name}", {"name": "world"}) + 'hello world' + >>> render("port=${port:-3000}", {}) + 'port=3000' + >>> render("port=${port:-3000}", {"port": "4000"}) + 'port=4000' + >>> render("untouched ${HOME}", {}, strict=False) + 'untouched ${HOME}' + """ + + def replace(match: re.Match[str]) -> str: + name = match["name"] + if name in variables: + return variables[name] + default = match["default"] + if default is not None: + return default + if strict: + raise UnresolvedVariableError(name) + return match[0] + + return _VAR_PATTERN.sub(replace, template) + + +def parse_cli_vars(args: t.Sequence[str]) -> dict[str, str]: + """Parse ``KEY=VALUE`` positional CLI args into a dict. + + Supports values containing ``=``: only the first ``=`` is treated as + the separator. Empty input returns an empty dict. + + Parameters + ---------- + args : Sequence[str] + Tokens from the CLI (e.g. ``["app=blog", "port=4000"]``). + + Returns + ------- + dict[str, str] + Variable map suitable for passing as ``variables=`` to + :func:`render`. + + Raises + ------ + ValueError + If any token has no ``=`` separator. + + >>> parse_cli_vars(["app=blog", "port=4000"]) + {'app': 'blog', 'port': '4000'} + >>> parse_cli_vars(["url=https://example.com/?a=1"]) + {'url': 'https://example.com/?a=1'} + >>> parse_cli_vars([]) + {} + """ + out: dict[str, str] = {} + for token in args: + if "=" not in token: + msg = f"template var must be KEY=VALUE: {token!r}" + raise ValueError(msg) + key, _, value = token.partition("=") + out[key] = value + return out diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 860a9200cb..c0bb530b78 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -45,6 +45,24 @@ create_load_subparser, ) from .ls import LS_DESCRIPTION, CLILsNamespace, command_ls, create_ls_subparser +from .manage import ( + COPY_DESCRIPTION, + DELETE_DESCRIPTION, + IMPLODE_DESCRIPTION, + NEW_DESCRIPTION, + CLICopyNamespace, + CLIDeleteNamespace, + CLIImplodeNamespace, + CLINewNamespace, + command_copy, + command_delete, + command_implode, + command_new, + create_copy_subparser, + create_delete_subparser, + create_implode_subparser, + create_new_subparser, +) from .search import ( SEARCH_DESCRIPTION, CLISearchNamespace, @@ -57,6 +75,12 @@ command_shell, create_shell_subparser, ) +from .stop import ( + STOP_DESCRIPTION, + CLIStopNamespace, + command_stop, + create_stop_subparser, +) from .utils import tmuxp_echo logger = logging.getLogger(__name__) @@ -156,6 +180,11 @@ "search", "shell", "debug-info", + "new", + "copy", + "delete", + "implode", + "stop", ] CLIImportSubparserName: TypeAlias = t.Literal["teamocil", "tmuxinator"] @@ -262,6 +291,46 @@ def create_parser() -> argparse.ArgumentParser: ) create_freeze_subparser(freeze_parser) + new_parser = subparsers.add_parser( + "new", + help="create a new workspace file in the user config dir", + description=NEW_DESCRIPTION, + formatter_class=formatter_class, + ) + create_new_subparser(new_parser) + + copy_parser = subparsers.add_parser( + "copy", + help="copy an existing workspace file to a new name", + description=COPY_DESCRIPTION, + formatter_class=formatter_class, + ) + create_copy_subparser(copy_parser) + + delete_parser = subparsers.add_parser( + "delete", + help="delete one or more workspace files", + description=DELETE_DESCRIPTION, + formatter_class=formatter_class, + ) + create_delete_subparser(delete_parser) + + implode_parser = subparsers.add_parser( + "implode", + help="delete ALL workspace files", + description=IMPLODE_DESCRIPTION, + formatter_class=formatter_class, + ) + create_implode_subparser(implode_parser) + + stop_parser = subparsers.add_parser( + "stop", + help="stop a tmuxp-managed tmux session (fires on_project_stop)", + description=STOP_DESCRIPTION, + formatter_class=formatter_class, + ) + create_stop_subparser(stop_parser) + return parser @@ -353,6 +422,16 @@ def cli(_args: list[str] | None = None) -> None: args=CLIFreezeNamespace(**vars(args)), parser=parser, ) + elif args.subparser_name == "new": + command_new(args=CLINewNamespace(**vars(args)), parser=parser) + elif args.subparser_name == "copy": + command_copy(args=CLICopyNamespace(**vars(args)), parser=parser) + elif args.subparser_name == "delete": + command_delete(args=CLIDeleteNamespace(**vars(args)), parser=parser) + elif args.subparser_name == "implode": + command_implode(args=CLIImplodeNamespace(**vars(args)), parser=parser) + elif args.subparser_name == "stop": + command_stop(args=CLIStopNamespace(**vars(args)), parser=parser) elif args.subparser_name == "ls": command_ls( args=CLILsNamespace(**vars(args)), diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 375cdb1b22..6040c8ef27 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -105,6 +105,10 @@ class CLILoadNamespace(argparse.Namespace): answer_yes: bool | None detached: bool append: bool | None + no_shell_command_before: bool + here: bool + dry_run: bool + template_vars: list[str] colors: CLIColorsLiteral | None color: CLIColorModeLiteral log_file: str | None @@ -450,6 +454,9 @@ def load_workspace( progress_format: str | None = None, panel_lines: int | None = None, no_progress: bool = False, + no_shell_command_before: bool = False, + template_vars: t.Mapping[str, str] | None = None, + here: bool = False, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -532,6 +539,17 @@ def load_workspace( behalf. An exception raised during this process means it's not easy to predict how broken the session is. """ + # --here precondition checks. Forces append=True so the existing + # append codepath layers windows onto the user's current session. + if here and append: + msg = "--here is mutually exclusive with --append" + raise exc.TmuxpException(msg) + if here and not os.environ.get("TMUX"): + msg = "--here requires being inside an existing tmux session" + raise exc.TmuxpException(msg) + if here: + append = True + # Initialize CLI colors if not provided if cli_colors is None: cli_colors = Colors(ColorMode.AUTO) @@ -552,8 +570,20 @@ def load_workspace( + cli_colors.highlight(str(PrivatePath(workspace_file))), ) - # ConfigReader allows us to open a yaml or json file as a dict - raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} + # Render ${var} / ${var:-default} placeholders against the raw YAML + # text BEFORE parsing (matches tmuxinator's ERB-before-YAML ordering). + # JSON files skip this step. strict=False leaves unresolved placeholders + # in place so loader.expandshell handles $ENV_VAR forms. + workspace_path = pathlib.Path(workspace_file) + if template_vars and workspace_path.suffix.lower() in {".yaml", ".yml"}: + from tmuxp._internal import template as _template + + raw_text = workspace_path.read_text(encoding="utf-8") + rendered = _template.render(raw_text, template_vars, strict=False) + raw_workspace = config_reader.ConfigReader._load("yaml", rendered) or {} + else: + # ConfigReader allows us to open a yaml or json file as a dict + raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} # shapes workspaces relative to config / profile file location expanded_workspace = loader.expand( @@ -566,7 +596,10 @@ def load_workspace( expanded_workspace["session_name"] = new_session_name # propagate workspace inheritance (e.g. session -> window, window -> pane) - expanded_workspace = loader.trickle(expanded_workspace) + expanded_workspace = loader.trickle( + expanded_workspace, + no_shell_command_before=no_shell_command_before, + ) t = Server( # create tmux server object socket_name=socket_name, @@ -758,6 +791,51 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP action="store_true", help="load workspace, appending windows to the current session", ) + parser.add_argument( + "--no-shell-command-before", + dest="no_shell_command_before", + action="store_true", + default=False, + help=( + "skip session/window/pane shell_command_before propagation " + "(broader than tmuxinator's --no-pre-window, which only skips " + "pre_window)" + ), + ) + parser.add_argument( + "--here", + dest="here", + action="store_true", + default=False, + help=( + "load workspace into the current tmux session (rename it + add " + "windows here). Mirrors teamocil --here. Requires being inside " + "tmux. Mutually exclusive with -a/--append." + ), + ) + parser.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + default=False, + help=( + "execute the build on an isolated tmux socket (won't touch your " + "main tmux server) and surface the libtmux command log at INFO. " + "The temp server is killed when done." + ), + ) + parser.add_argument( + "-D", + "--var", + dest="template_vars", + action="append", + default=[], + metavar="KEY=VALUE", + help=( + "template variable for ${var} / ${var:-default} substitution " + "in YAML workspace files; repeatable" + ), + ) colorsgroup = parser.add_mutually_exclusive_group() colorsgroup.add_argument( @@ -877,6 +955,25 @@ def command_load( original_detached_option = args.detached original_new_session_name = args.new_session_name + # --dry-run isolates the build on a temp socket so the user's main tmux + # server is untouched. Force socket_name to a unique value, force + # detached=True (no attaching to a sandbox session), bump tmuxp's logger + # to DEBUG so libtmux's `tmux command dispatched` messages surface, and + # kill the temp server in finally. + dry_run = getattr(args, "dry_run", False) + dry_run_socket: str | None = None + if dry_run: + import secrets as _secrets + + dry_run_socket = f"tmuxp-dryrun-{os.getpid()}-{_secrets.token_hex(2)}" + args.socket_name = dry_run_socket + original_detached_option = True + logging.getLogger("libtmux").setLevel(logging.DEBUG) + logging.getLogger("tmuxp").setLevel(logging.DEBUG) + tmuxp_echo( + cli_colors.info(f"[dry-run] using isolated socket: {dry_run_socket}"), + ) + for idx, workspace_file in enumerate(args.workspace_files): workspace_file = find_workspace_file( workspace_file, @@ -890,18 +987,41 @@ def command_load( detached = True new_session_name = None - load_workspace( - workspace_file, - socket_name=args.socket_name, - socket_path=args.socket_path, - tmux_config_file=args.tmux_config_file, - new_session_name=new_session_name, - colors=args.colors, - detached=detached, - answer_yes=args.answer_yes or False, - append=args.append or False, - cli_colors=cli_colors, - progress_format=args.progress_format, - panel_lines=args.panel_lines, - no_progress=args.no_progress, - ) + from tmuxp._internal import template as _template + + try: + load_workspace( + workspace_file, + socket_name=args.socket_name, + socket_path=args.socket_path, + tmux_config_file=args.tmux_config_file, + new_session_name=new_session_name, + colors=args.colors, + detached=dry_run or detached, + answer_yes=args.answer_yes or False, + append=args.append or False, + cli_colors=cli_colors, + progress_format=args.progress_format, + panel_lines=args.panel_lines, + no_progress=args.no_progress, + no_shell_command_before=args.no_shell_command_before, + template_vars=_template.parse_cli_vars(args.template_vars or []), + here=getattr(args, "here", False), + ) + finally: + if dry_run and dry_run_socket: + # Kill the temp tmux server we spun up. Best-effort: if the + # build raised before any session was created, the server + # may not exist yet. + try: + Server(socket_name=dry_run_socket).kill_server() + tmuxp_echo( + cli_colors.muted( + f"[dry-run] cleaned up isolated socket: {dry_run_socket}", + ), + ) + except Exception: + logger.debug( + "dry-run cleanup skipped", + extra={"tmux_session": dry_run_socket}, + ) diff --git a/src/tmuxp/cli/manage.py b/src/tmuxp/cli/manage.py new file mode 100644 index 0000000000..9b09cfb937 --- /dev/null +++ b/src/tmuxp/cli/manage.py @@ -0,0 +1,285 @@ +"""Workspace-config management commands: ``new``, ``copy``, ``delete``, ``implode``. + +Mirrors tmuxinator's ``new``/``copy``/``delete``/``implode`` subcommands +(cli.rb:78-457). Implemented in a single module to keep the four +commands' shared file-IO patterns close together. +""" + +from __future__ import annotations + +import argparse +import logging +import os +import pathlib +import shutil +import subprocess +import sys +import typing as t + +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir + +from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + + +_NEW_TEMPLATE = """# {name}.yaml — generated by `tmuxp new {name}` +session_name: {name} +windows: +- window_name: editor + panes: + - shell_command: + - +""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _config_path(name: str) -> pathlib.Path: + """Return the canonical workspace file path for ``name`` in the user's dir.""" + return pathlib.Path(get_workspace_dir()) / f"{name}.yaml" + + +def _ensure_dir(path: pathlib.Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def _open_in_editor(path: pathlib.Path) -> None: + editor = os.environ.get("EDITOR", "vim") + subprocess.call([editor, str(path)]) + + +def _confirm(prompt: str, *, default_no: bool = True) -> bool: + """Read y/n from stdin; on EOF (no TTY) return ``not default_no``.""" + suffix = " [y/N] " if default_no else " [Y/n] " + try: + answer = input(prompt + suffix).strip().lower() + except EOFError: + return not default_no + if not answer: + return not default_no + return answer in {"y", "yes"} + + +# --------------------------------------------------------------------------- +# new +# --------------------------------------------------------------------------- + + +NEW_DESCRIPTION = build_description( + "Create a new tmuxp workspace file in the user's workspace directory.", + ((None, ["tmuxp new myproject"]),), +) + + +class CLINewNamespace(argparse.Namespace): + """Typed namespace for ``tmuxp new``.""" + + name: str + color: CLIColorModeLiteral | None + + +def create_new_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` for ``tmuxp new``.""" + parser.add_argument("name", help="config name (without .yaml extension)") + return parser + + +def command_new( + args: CLINewNamespace, parser: argparse.ArgumentParser | None = None +) -> None: + """Entry point for ``tmuxp new``.""" + colors = Colors(get_color_mode(args.color)) + target = _config_path(args.name) + _ensure_dir(target) + if not target.exists(): + target.write_text(_NEW_TEMPLATE.format(name=args.name), encoding="utf-8") + tmuxp_echo(colors.muted("Created ") + colors.info(str(PrivatePath(target)))) + _open_in_editor(target) + + +# --------------------------------------------------------------------------- +# copy +# --------------------------------------------------------------------------- + + +COPY_DESCRIPTION = build_description( + "Copy an existing workspace file to a new name and open it for editing.", + ((None, ["tmuxp copy myproject myproject-staging"]),), +) + + +class CLICopyNamespace(argparse.Namespace): + """Typed namespace for ``tmuxp copy``.""" + + src: str + dst: str + answer_yes: bool + color: CLIColorModeLiteral | None + + +def create_copy_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` for ``tmuxp copy``.""" + parser.add_argument("src", help="source config name or path") + parser.add_argument("dst", help="destination config name (without extension)") + parser.add_argument( + "-y", + "--yes", + dest="answer_yes", + action="store_true", + help="skip overwrite confirmation", + ) + return parser + + +def command_copy( + args: CLICopyNamespace, parser: argparse.ArgumentParser | None = None +) -> None: + """Entry point for ``tmuxp copy``.""" + colors = Colors(get_color_mode(args.color)) + src_path = pathlib.Path(find_workspace_file(args.src)) + dst_path = _config_path(args.dst) + _ensure_dir(dst_path) + if ( + dst_path.exists() + and not args.answer_yes + and not _confirm(colors.warning(f"Overwrite {PrivatePath(dst_path)}?")) + ): + tmuxp_echo(colors.muted("Aborted.")) + return + shutil.copyfile(src_path, dst_path) + tmuxp_echo( + colors.muted("Copied ") + + colors.info(str(PrivatePath(src_path))) + + colors.muted(" -> ") + + colors.info(str(PrivatePath(dst_path))), + ) + _open_in_editor(dst_path) + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +DELETE_DESCRIPTION = build_description( + "Delete one or more workspace config files (with confirmation).", + ((None, ["tmuxp delete myproject", "tmuxp delete proj1 proj2"]),), +) + + +class CLIDeleteNamespace(argparse.Namespace): + """Typed namespace for ``tmuxp delete``.""" + + names: list[str] + answer_yes: bool + color: CLIColorModeLiteral | None + + +def create_delete_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` for ``tmuxp delete``.""" + parser.add_argument("names", nargs="+", help="config name(s) to delete") + parser.add_argument( + "-y", + "--yes", + dest="answer_yes", + action="store_true", + help="skip per-config confirmation", + ) + return parser + + +def command_delete( + args: CLIDeleteNamespace, parser: argparse.ArgumentParser | None = None +) -> None: + """Entry point for ``tmuxp delete``.""" + colors = Colors(get_color_mode(args.color)) + for name in args.names: + try: + path = pathlib.Path(find_workspace_file(name)) + except Exception: + tmuxp_echo(colors.error(f"Not found: {name}")) + continue + if not args.answer_yes and not _confirm( + colors.warning(f"Delete {PrivatePath(path)}?"), + ): + tmuxp_echo(colors.muted(f"Skipped {PrivatePath(path)}")) + continue + path.unlink() + tmuxp_echo(colors.muted("Deleted ") + colors.info(str(PrivatePath(path)))) + + +# --------------------------------------------------------------------------- +# implode +# --------------------------------------------------------------------------- + + +IMPLODE_DESCRIPTION = build_description( + "Delete ALL workspace config files in every tmuxp workspace directory.", + ((None, ["tmuxp implode"]),), +) + + +class CLIImplodeNamespace(argparse.Namespace): + """Typed namespace for ``tmuxp implode``.""" + + answer_yes: bool + color: CLIColorModeLiteral | None + + +def create_implode_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` for ``tmuxp implode``.""" + parser.add_argument( + "-y", + "--yes", + dest="answer_yes", + action="store_true", + help="skip the global confirmation prompt", + ) + return parser + + +def _implode_dirs() -> list[pathlib.Path]: + """Return existing tmuxp config directories worth removing.""" + candidates = [ + pathlib.Path(os.path.expanduser("~/.tmuxp")), + pathlib.Path(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))) + / "tmuxp", + ] + return [p for p in candidates if p.exists() and p.is_dir()] + + +def command_implode( + args: CLIImplodeNamespace, parser: argparse.ArgumentParser | None = None +) -> None: + """Entry point for ``tmuxp implode``.""" + colors = Colors(get_color_mode(args.color)) + dirs = _implode_dirs() + if not dirs: + tmuxp_echo(colors.muted("No tmuxp config directories found.")) + return + if not args.answer_yes and not _confirm( + colors.error(f"Delete ALL configs in: {', '.join(str(d) for d in dirs)}?"), + ): + tmuxp_echo(colors.muted("Aborted.")) + return + for d in dirs: + shutil.rmtree(d) + tmuxp_echo(colors.muted("Removed ") + colors.info(str(PrivatePath(d)))) + + +# Silence unused-import warnings on platforms where `sys` isn't directly needed +# at runtime (kept for future stdin-detection extensions). +_ = sys diff --git a/src/tmuxp/cli/stop.py b/src/tmuxp/cli/stop.py new file mode 100644 index 0000000000..99495bd055 --- /dev/null +++ b/src/tmuxp/cli/stop.py @@ -0,0 +1,111 @@ +"""CLI for ``tmuxp stop`` subcommand. + +Mirrors tmuxinator's ``stop`` command (cli.rb:300-322) — fires the +``on_project_stop`` hook before killing the session, but does NOT fire +``on_project_exit`` (that's tmuxinator's behavior in template-stop.erb). +""" + +from __future__ import annotations + +import argparse +import logging +import os +import pathlib +import typing as t + +from libtmux.server import Server + +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.util import run_lifecycle_hook +from tmuxp.workspace.finders import find_workspace_file + +from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +STOP_DESCRIPTION = build_description( + "Stop a tmuxp-managed session, firing the on_project_stop hook first.", + ((None, ["tmuxp stop myproject", "tmuxp stop -L mysocket myproject"]),), +) + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + + +class CLIStopNamespace(argparse.Namespace): + """Typed namespace for ``tmuxp stop``.""" + + workspace_file: str + socket_name: str | None + socket_path: str | None + color: CLIColorModeLiteral | None + + +def create_stop_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` for ``tmuxp stop``.""" + parser.add_argument( + "workspace_file", + metavar="workspace-file", + help="config name (resolved via tmuxp workspace dir) or path", + ) + parser.add_argument( + "-L", + dest="socket_name", + action="store", + help="passthru to tmux(1) -L", + ) + parser.add_argument( + "-S", + dest="socket_path", + action="store", + help="passthru to tmux(1) -S", + ) + return parser + + +def command_stop( + args: CLIStopNamespace, parser: argparse.ArgumentParser | None = None +) -> None: + """Entry point for ``tmuxp stop``.""" + colors = Colors(get_color_mode(args.color)) + + workspace_file = pathlib.Path(find_workspace_file(args.workspace_file)) + raw = ConfigReader._from_file(workspace_file) or {} + session_name = raw.get("session_name") + if not session_name: + tmuxp_echo( + colors.error( + f"Cannot determine session_name from {workspace_file}", + ), + ) + return + + server = Server( + socket_name=args.socket_name, + socket_path=args.socket_path, + ) + if not server.is_alive() or not server.has_session(session_name): + tmuxp_echo( + colors.muted( + f"No running session named {session_name!r}; nothing to stop.", + ), + ) + return + + # Run on_project_stop BEFORE killing (matches template-stop.erb:1-10). + cwd_str = ( + os.path.dirname(workspace_file) + if "start_directory" not in raw + else raw["start_directory"] + ) + run_lifecycle_hook( + "on_project_stop", + raw.get("on_project_stop"), + cwd=pathlib.Path(cwd_str) if cwd_str else None, + ) + + server.kill_session(session_name) + tmuxp_echo(colors.muted(f"Stopped {session_name!r}.")) diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 152b1f6c06..a03ad4c0d6 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -105,6 +105,52 @@ def run_before_script( return return_code +def run_lifecycle_hook( + hook_name: str, + value: str | list[str] | None, + cwd: pathlib.Path | None = None, + on_line: t.Callable[[str], None] | None = None, +) -> int: + """Execute a tmuxinator-style lifecycle hook value. + + Reuses :func:`run_before_script`'s `shlex.split` + `subprocess.Popen` + semantics — no `shell=True`. Pipes/redirects in hook strings won't + work; users wrap shell logic in a script file. + + Parameters + ---------- + hook_name : str + The hook key (e.g. ``"on_project_start"``); used in DEBUG logs. + value : str | list[str] | None + The config value. ``None`` is a no-op (returns 0). String is + run once. List is run sequentially; aborts on first non-zero + exit code. + cwd : pathlib.Path | None + Working directory passed to each invocation. + on_line : Callable[[str], None] | None + Per-line output callback (same as ``run_before_script``). + + Returns + ------- + int + Final exit code (0 if all items succeeded). Raises the same + exceptions ``run_before_script`` does. + """ + if value is None: + return 0 + items = value if isinstance(value, list) else [value] + last_code = 0 + for item in items: + logger.debug( + "running lifecycle hook", + extra={"tmux_key": hook_name}, + ) + last_code = run_before_script(item, cwd=cwd, on_line=on_line) + if last_code != 0: + return last_code + return last_code + + def oh_my_zsh_auto_title() -> None: """Give warning and offer to fix ``DISABLE_AUTO_TITLE``. diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 728b477963..908450494b 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -16,7 +16,7 @@ from tmuxp import exc from tmuxp.log import TmuxpLoggerAdapter -from tmuxp.util import get_current_pane, run_before_script +from tmuxp.util import get_current_pane, run_before_script, run_lifecycle_hook if t.TYPE_CHECKING: from collections.abc import Iterator @@ -491,6 +491,33 @@ def build(self, session: Session | None = None, append: bool = False) -> None: focus = None + # Lifecycle hooks: on_project_start fires every load (matches + # tmuxinator template.erb:14). first_start fires only on a NEW + # session; restart fires only when appending to an existing one. + # All execute via run_lifecycle_hook which reuses run_before_script + # (no shell=True; pipes need a script file). + hook_cwd = self.session_config.get("start_directory") + run_lifecycle_hook( + "on_project_start", + self.session_config.get("on_project_start"), + cwd=hook_cwd, + on_line=self.on_script_output, + ) + if append: + run_lifecycle_hook( + "on_project_restart", + self.session_config.get("on_project_restart"), + cwd=hook_cwd, + on_line=self.on_script_output, + ) + else: + run_lifecycle_hook( + "on_project_first_start", + self.session_config.get("on_project_first_start"), + cwd=hook_cwd, + on_line=self.on_script_output, + ) + if "before_script" in self.session_config: if self.on_before_script: self.on_before_script() @@ -538,6 +565,21 @@ def build(self, session: Session | None = None, append: bool = False) -> None: for option, value in self.session_config["environment"].items(): self.session.set_environment(option, value) + # Pane title display options: tmuxinator's enable_pane_titles + # toggles `pane-border-status`; pane_title_position picks where the + # status bar lives; pane_title_format defines what tmux renders. + # Per-pane `title` is set later in iter_create_panes via set_title(). + # Both pane-border-* are window-scope options; use global_=True so + # the values cover every window in the session. + if self.session_config.get("enable_pane_titles"): + position = self.session_config.get("pane_title_position", "top") + fmt = self.session_config.get( + "pane_title_format", + "#{pane_index}: #{pane_title}", + ) + self.session.set_option("pane-border-status", position, global_=True) + self.session.set_option("pane-border-format", fmt, global_=True) + for window, window_config in self.iter_create_windows(session, append): assert isinstance(window, Window) @@ -674,6 +716,13 @@ def iter_create_windows( for key, val in window_config["options"].items(): window.set_option(key, val) + # synchronize-panes: before/true fires here, after fires in + # config_after_window. tmuxinator deprecates true/before in favor + # of after (project.rb:21-29) but accepts all three for compat. + sync = window_config.get("synchronize") + if sync is True or sync == "before": + window.set_option("synchronize-panes", "on") + if window_config.get("focus"): window.select() @@ -813,6 +862,12 @@ def get_pane_shell( if sleep_after is not None: time.sleep(sleep_after) + # Per-pane title via libtmux Pane.set_title; works regardless of + # pane-border-status (title is stored on the pane, display is + # independent — see tmux cmd-select-pane.c:215-221). + if "title" in pane_config: + pane.set_title(pane_config["title"]) + if pane_config.get("focus"): assert pane.pane_id is not None window.select_pane(pane.pane_id) @@ -844,6 +899,18 @@ def config_after_window( for key, val in window_config["options_after"].items(): window.set_option(key, val) + # synchronize-panes value "after" fires here (post-pane-build) so + # the option is enabled only after pane commands have been sent. + if window_config.get("synchronize") == "after": + window.set_option("synchronize-panes", "on") + + # Window-level shell_command_after: send each command to every pane + # after the main shell_command for that pane has run. The teamocil + # importer produces this on the window dict from `filters.after`. + for after_cmd in window_config.get("shell_command_after", []) or []: + for pane in window.panes: + pane.send_keys(after_cmd, suppress_history=False) + def find_current_attached_session(self) -> Session: """Return current attached session.""" assert self.server is not None diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 65184d73a4..bd4e22f6e2 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -3,11 +3,92 @@ from __future__ import annotations import logging +import shlex import typing as t logger = logging.getLogger(__name__) +_SHELL_METACHAR_TOKENS = ("|", "&&", "||", ">", "<", "$(", "`", ";") + +_FALSY_YAML_STRINGS = frozenset({"false", "no", "off"}) + + +def _is_falsy_yaml(value: t.Any) -> bool: + """Treat False, None, and YAML-style falsy strings as falsy. + + PyYAML usually decodes ``false`` to Python ``False``, but if a user + quotes the value (``with_env_var: "false"``), it arrives as a + truthy non-empty string. Coerce manually so the importer respects + intent rather than Python's bool semantics. + """ + if value is False or value is None: + return True + if isinstance(value, str): + return value.strip().lower() in _FALSY_YAML_STRINGS + return False + + +def _has_shell_metachars(value: t.Any) -> bool: + """Return True if value contains shell metacharacters that need a real shell. + + tmuxp's `before_script` runs via `subprocess.Popen` after `shlex.split()` — + no shell process. Pipes, redirects, command substitution, and `&&` chains + don't work. This helper flags such values so the caller can warn the user. + + Strings, lists of strings, and dicts containing strings are scanned. Any + other type returns False (nothing to scan). + + >>> _has_shell_metachars("plain command") + False + >>> _has_shell_metachars("echo a | grep b") + True + >>> _has_shell_metachars(["safe", "echo $(date)"]) + True + >>> _has_shell_metachars(None) + False + """ + if isinstance(value, str): + return any(token in value for token in _SHELL_METACHAR_TOKENS) + if isinstance(value, list): + return any(_has_shell_metachars(item) for item in value) + return False + + +def _parse_tmuxinator_tmux_args( + args_str: str, + target: dict[str, t.Any], + session_name: str | None, +) -> None: + """Parse tmuxinator `cli_args`/`tmux_options` into individual tmux flags. + + Splits via `shlex` and walks tokens to extract `-f` (config), `-L` + (socket name), and `-S` (socket path). Unknown flags are warned. + Mutates ``target`` in place. + """ + mapping = {"-f": "config", "-L": "socket_name", "-S": "socket_path"} + tokens = shlex.split(args_str) + i = 0 + while i < len(tokens): + flag = tokens[i] + if flag in mapping: + if i + 1 < len(tokens): + target[mapping[flag]] = tokens[i + 1] + i += 2 + else: + logger.warning( + "tmux flag requires a value but none was provided", + extra={"tmux_key": flag, "tmux_session": session_name}, + ) + i += 1 + else: + logger.warning( + "unrecognized tmux flag in cli_args/tmux_options", + extra={"tmux_key": flag, "tmux_session": session_name}, + ) + i += 1 + + def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: """Return tmuxp workspace from a `tmuxinator`_ yaml workspace. @@ -22,6 +103,11 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ------- dict """ + # Top-level shallow copy so .pop()s below don't mutate the caller's dict. + # Nested window/pane dicts are still mutated in place by the per-window + # loop; if you pass a dict whose nested values are shared elsewhere, + # deepcopy upstream. + workspace_dict = {**workspace_dict} logger.debug( "importing tmuxinator workspace", extra={ @@ -44,48 +130,71 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: elif "root" in workspace_dict: tmuxp_workspace["start_directory"] = workspace_dict.pop("root") - if "cli_args" in workspace_dict: - tmuxp_workspace["config"] = workspace_dict["cli_args"] - - if "-f" in tmuxp_workspace["config"]: - tmuxp_workspace["config"] = ( - tmuxp_workspace["config"].replace("-f", "").strip() - ) - elif "tmux_options" in workspace_dict: - tmuxp_workspace["config"] = workspace_dict["tmux_options"] - - if "-f" in tmuxp_workspace["config"]: - tmuxp_workspace["config"] = ( - tmuxp_workspace["config"].replace("-f", "").strip() - ) + args_str = workspace_dict.get("cli_args") or workspace_dict.get("tmux_options") + if args_str: + _parse_tmuxinator_tmux_args( + args_str, + tmuxp_workspace, + tmuxp_workspace.get("session_name"), + ) if "socket_name" in workspace_dict: tmuxp_workspace["socket_name"] = workspace_dict["socket_name"] + if "socket_path" in workspace_dict: + tmuxp_workspace["socket_path"] = workspace_dict["socket_path"] + + if workspace_dict.get("attach") is False: + logger.warning( + "attach: false has no tmuxp config equivalent; pass `-d` to " + "`tmuxp load` instead", + extra={ + "tmux_key": "attach", + "tmux_session": tmuxp_workspace.get("session_name"), + }, + ) + tmuxp_workspace["windows"] = [] if "tabs" in workspace_dict: workspace_dict["windows"] = workspace_dict.pop("tabs") - if "pre" in workspace_dict and "pre_window" in workspace_dict: - tmuxp_workspace["shell_command"] = workspace_dict["pre"] - - if isinstance(workspace_dict["pre"], str): - tmuxp_workspace["shell_command_before"] = [workspace_dict["pre_window"]] - else: - tmuxp_workspace["shell_command_before"] = workspace_dict["pre_window"] - elif "pre" in workspace_dict: - if isinstance(workspace_dict["pre"], str): - tmuxp_workspace["shell_command_before"] = [workspace_dict["pre"]] - else: - tmuxp_workspace["shell_command_before"] = workspace_dict["pre"] + # `pre` runs once before the session is created (template.erb:18-19). + # `on_project_first_start` is the equivalent in the modern hook system + # (project.rb:165-168) — fall back to it when `pre` is not set. + pre_source = workspace_dict.get("pre") + if pre_source is None and "on_project_first_start" in workspace_dict: + pre_source = workspace_dict["on_project_first_start"] + if pre_source is not None: + if _has_shell_metachars(pre_source): + logger.warning( + "pre contains shell constructs that will not work in " + "before_script (runs without shell=True)", + extra={ + "tmux_key": "pre", + "tmux_session": tmuxp_workspace.get("session_name"), + }, + ) + tmuxp_workspace["before_script"] = pre_source + # tmuxinator computes pre_window from an OR-fallback chain + # (project.rb:175-188): rbenv -> rvm -> pre_tab -> pre_window. First + # non-nil wins. `rbenv` and `rvm` are wrapped as shell commands. + pre_window_chain: list[str] = [] if "rbenv" in workspace_dict: - if "shell_command_before" not in tmuxp_workspace: - tmuxp_workspace["shell_command_before"] = [] - tmuxp_workspace["shell_command_before"].append( - "rbenv shell {}".format(workspace_dict["rbenv"]), - ) + pre_window_chain.append(f"rbenv shell {workspace_dict['rbenv']}") + elif "rvm" in workspace_dict: + pre_window_chain.append(f"rvm use {workspace_dict['rvm']}") + elif "pre_tab" in workspace_dict: + pre_window_chain.append(workspace_dict["pre_tab"]) + elif "pre_window" in workspace_dict: + pre_tab_or_window = workspace_dict["pre_window"] + if isinstance(pre_tab_or_window, list): + pre_window_chain.extend(pre_tab_or_window) + else: + pre_window_chain.append(pre_tab_or_window) + if pre_window_chain: + tmuxp_workspace["shell_command_before"] = pre_window_chain for window_dict in workspace_dict["windows"]: for k, v in window_dict.items(): @@ -103,21 +212,160 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "pre" in v: window_dict["shell_command_before"] = v["pre"] if "panes" in v: - window_dict["panes"] = v["panes"] + # Named-pane shorthand: tmuxinator's {title: command} maps to + # tmuxp {title: ..., shell_command: [...]} so the builder + # can apply the title via Pane.set_title(). + window_dict["panes"] = [ + _normalize_tmuxinator_pane(p) for p in v["panes"] + ] if "root" in v: window_dict["start_directory"] = v["root"] if "layout" in v: window_dict["layout"] = v["layout"] + # pass synchronize through (true/before/after) — same key name. + if "synchronize" in v: + window_dict["synchronize"] = v["synchronize"] tmuxp_workspace["windows"].append(window_dict) + + _apply_tmuxinator_startup_focus(workspace_dict, tmuxp_workspace) return tmuxp_workspace +def _apply_tmuxinator_startup_focus( + workspace_dict: dict[str, t.Any], + tmuxp_workspace: dict[str, t.Any], +) -> None: + """Map tmuxinator `startup_window`/`startup_pane` to tmuxp `focus: true`. + + tmuxinator passes these values directly to tmux as targets + (`project.rb:261-267`); a string is a window name, an integer is an + index. tmuxp uses a per-window/per-pane `focus` flag instead. We + resolve the value (try int first, fall back to name) and set `focus: + true` on the matching window (and pane). Unresolved values warn. + """ + windows = tmuxp_workspace.get("windows", []) + + target_window_idx: int | None = None + if "startup_window" in workspace_dict: + target_window_idx = _resolve_startup_index( + workspace_dict["startup_window"], + windows, + "window_name", + "startup_window", + tmuxp_workspace.get("session_name"), + ) + if target_window_idx is not None: + windows[target_window_idx]["focus"] = True + + if "startup_pane" in workspace_dict and target_window_idx is not None: + panes = windows[target_window_idx].get("panes") or [] + # Pane lists in this importer are positional strings; we can't add + # a `focus` flag without converting. Skip unless panes are dicts. + normalized_panes: list[t.Any] = [] + pane_target = _resolve_startup_index( + workspace_dict["startup_pane"], + panes, + None, + "startup_pane", + tmuxp_workspace.get("session_name"), + ) + for idx, pane in enumerate(panes): + if idx == pane_target: + if isinstance(pane, dict): + normalized_panes.append({**pane, "focus": True}) + else: + normalized_panes.append( + {"shell_command": [pane] if pane else [], "focus": True}, + ) + else: + normalized_panes.append(pane) + windows[target_window_idx]["panes"] = normalized_panes + + +def _normalize_tmuxinator_pane(pane: t.Any) -> t.Any: + """Normalize a tmuxinator pane shorthand to tmuxp pane form. + + tmuxinator panes can be: + + - bare string ``"cmd"`` (passed through unchanged; loader.expand wraps it) + - list ``["cmd1", "cmd2"]`` (passed through unchanged) + - hash ``{name: "cmd"}`` (a single-key dict — name becomes the pane + title). Returns ``{"title": name, "shell_command": [cmd]}``. + + Anything else (e.g., already-tmuxp pane dicts, ``None``) passes + through unchanged. + + >>> _normalize_tmuxinator_pane("vim") + 'vim' + >>> _normalize_tmuxinator_pane({"editor": "vim"}) + {'title': 'editor', 'shell_command': ['vim']} + """ + if isinstance(pane, dict) and len(pane) == 1: + ((name, cmd),) = pane.items() + if isinstance(cmd, str): + return {"title": name, "shell_command": [cmd]} + if isinstance(cmd, list): + return {"title": name, "shell_command": cmd} + return pane + + +def _resolve_startup_index( + value: t.Any, + items: list[t.Any], + name_key: str | None, + field: str, + session_name: str | None, +) -> int | None: + """Resolve a tmuxinator startup_window/startup_pane value to a list index. + + Tries integer first (treated as 0-based index into ``items``), then + falls back to matching by ``name_key`` if provided. Returns None and + warns if no match. + + >>> windows = [{"window_name": "shell"}, {"window_name": "editor"}] + >>> _resolve_startup_index(0, windows, "window_name", "f", None) + 0 + >>> _resolve_startup_index("editor", windows, "window_name", "f", None) + 1 + >>> _resolve_startup_index("missing", windows, "window_name", "f", None) is None + True + """ + try: + idx = int(value) + except (TypeError, ValueError): + idx = None + if idx is not None and 0 <= idx < len(items): + return idx + if name_key is not None: + for i, item in enumerate(items): + if isinstance(item, dict) and item.get(name_key) == value: + return i + logger.warning( + "%s value did not match any window/pane", + field, + extra={ + "tmux_key": field, + "tmux_session": session_name, + }, + ) + return None + + def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: """Return tmuxp workspace from a `teamocil`_ yaml workspace. .. _teamocil: https://github.com/remiprev/teamocil + Detects the teamocil format and dispatches: + + - **v0.x** (pre-1.0): ``session:`` wrapper present. Handles + ``splits``/``cmd``/``filters``/``clear``/``with_env_var``/ + ``cmd_separator``. + - **v1.x** (1.0-1.4.2): top-level ``windows``. Handles ``commands``, + string pane shorthand, per-window/pane ``focus``, window + ``options``. + Parameters ---------- workspace_dict : dict @@ -125,45 +373,101 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: Notes ----- - Todos: - - - change 'root' to a cd or start_directory - - width in pane -> main-pain-width - - with_env_var - - clear - - cmd_separator + Behavior of v0.x-only keys: + + - ``with_env_var`` (default ``true`` in v0.x) maps to a session-level + ``environment: {TEAMOCIL: "1"}`` to mirror teamocil 0.4-stable. + - ``clear`` is preserved on the window dict but the builder does not + yet act on it; a warning is emitted. + - ``cmd_separator`` is irrelevant since tmuxp sends commands + individually; a warning is emitted. + - ``width``/``height``/``target`` (per-pane geometry) are dropped + with a warning; builder support is a separate change. """ - _inner = workspace_dict.get("session", workspace_dict) + # Top-level shallow copy so the dispatch helpers' .pop()s don't mutate + # the caller's dict. Nested window/pane dicts are still mutated in place + # by the v0.x window loop; deepcopy upstream if you share nested values. + workspace_dict = {**workspace_dict} + has_session_wrapper = "session" in workspace_dict + inner = workspace_dict["session"] if has_session_wrapper else workspace_dict + is_v0x = has_session_wrapper or _has_v0x_window_markers(inner) logger.debug( "importing teamocil workspace", - extra={"tmux_session": _inner.get("name", "")}, + extra={ + "tmux_session": inner.get("name", ""), + "tmux_importer_version": "v0.x" if is_v0x else "v1.x", + }, ) + if is_v0x: + return _import_teamocil_v0x(inner) + return _import_teamocil_v1x(workspace_dict) - tmuxp_workspace: dict[str, t.Any] = {} - if "session" in workspace_dict: - workspace_dict = workspace_dict["session"] +def _has_v0x_window_markers(inner: dict[str, t.Any]) -> bool: + """Return True if any window/pane uses a v0.x-only key. - tmuxp_workspace["session_name"] = workspace_dict.get("name", None) + Some v0.x configs in the wild omit the ``session:`` wrapper but still + use ``splits`` (v0.x pane key), ``cmd`` (v0.x command key), or + ``filters`` (v0.x before/after hooks). Detect those so they route to + the v0.x importer instead of being misclassified as v1.x. + """ + for w in inner.get("windows", []) or []: + if not isinstance(w, dict): + continue + if "splits" in w or "filters" in w: + return True + for pane in w.get("panes", []) or []: + if isinstance(pane, dict) and "cmd" in pane: + return True + return False + + +def _import_teamocil_v0x(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + """Convert a teamocil v0.x workspace (``session:`` wrapped) to tmuxp.""" + tmuxp_workspace: dict[str, t.Any] = { + "session_name": workspace_dict.get("name"), + } if "root" in workspace_dict: tmuxp_workspace["start_directory"] = workspace_dict.pop("root") + # v0.x default: TEAMOCIL=1 is exported in every pane (with_env_var=true + # by default per teamocil 0.4-stable). _is_falsy_yaml handles quoted + # YAML strings like with_env_var: "false" that survive to Python as + # truthy strings. We're already in the v0.x helper so no is_v0x check. + if not _is_falsy_yaml(workspace_dict.get("with_env_var", True)): + tmuxp_workspace.setdefault("environment", {})["TEAMOCIL"] = "1" + + if "cmd_separator" in workspace_dict: + logger.warning( + "cmd_separator has no effect in tmuxp; commands are sent individually", + extra={ + "tmux_key": "cmd_separator", + "tmux_session": tmuxp_workspace.get("session_name"), + }, + ) + tmuxp_workspace["windows"] = [] for w in workspace_dict["windows"]: - window_dict = {"window_name": w["name"]} + window_dict: dict[str, t.Any] = {"window_name": w["name"]} if "clear" in w: window_dict["clear"] = w["clear"] + logger.warning( + "clear is preserved on the window but the builder does not " + "yet act on it", + extra={ + "tmux_key": "clear", + "tmux_session": tmuxp_workspace.get("session_name"), + }, + ) if "filters" in w: if "before" in w["filters"]: - for _b in w["filters"]["before"]: - window_dict["shell_command_before"] = w["filters"]["before"] + window_dict["shell_command_before"] = w["filters"]["before"] if "after" in w["filters"]: - for _b in w["filters"]["after"]: - window_dict["shell_command_after"] = w["filters"]["after"] + window_dict["shell_command_after"] = w["filters"]["after"] if "root" in w: window_dict["start_directory"] = w.pop("root") @@ -175,9 +479,22 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: for p in w["panes"]: if "cmd" in p: p["shell_command"] = p.pop("cmd") - if "width" in p: - # TODO support for height/width - p.pop("width") + for geom_key in ("width", "height", "target"): + if geom_key in p: + # No builder support for per-pane geometry yet + # (libtmux Pane.split(target=...) and Pane.resize() + # exist but the builder doesn't wire them up). + p.pop(geom_key) + logger.warning( + "v0.x pane key dropped (no per-pane geometry " + "support in tmuxp)", + extra={ + "tmux_key": geom_key, + "tmux_session": tmuxp_workspace.get( + "session_name", + ), + }, + ) window_dict["panes"] = w["panes"] if "layout" in w: @@ -185,3 +502,59 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: tmuxp_workspace["windows"].append(window_dict) return tmuxp_workspace + + +def _import_teamocil_v1x(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + """Convert a teamocil v1.x workspace (top-level ``windows``) to tmuxp. + + v1.x dropped the ``session:`` wrapper, ``filters``, ``with_env_var``, + and ``cmd_separator``. A bare string pane is shorthand for + ``{commands: [<string>]}`` (window.rb:12-14 in upstream teamocil). + """ + tmuxp_workspace: dict[str, t.Any] = { + "session_name": workspace_dict.get("name"), + } + + if "root" in workspace_dict: + tmuxp_workspace["start_directory"] = workspace_dict["root"] + + tmuxp_workspace["windows"] = [] + for w in workspace_dict.get("windows", []): + window_dict: dict[str, t.Any] = {"window_name": w["name"]} + if "root" in w: + window_dict["start_directory"] = w["root"] + if "layout" in w: + window_dict["layout"] = w["layout"] + if "focus" in w: + window_dict["focus"] = w["focus"] + if "options" in w: + window_dict["options"] = w["options"] + window_dict["panes"] = [_normalize_v1x_pane(p) for p in w.get("panes", [])] + tmuxp_workspace["windows"].append(window_dict) + + return tmuxp_workspace + + +def _normalize_v1x_pane(pane: t.Any) -> dict[str, t.Any]: + """Normalize a teamocil v1.x pane (string or dict) to tmuxp pane dict. + + >>> _normalize_v1x_pane("vim") + {'shell_command': ['vim']} + >>> _normalize_v1x_pane({"commands": ["a", "b"], "focus": True}) + {'shell_command': ['a', 'b'], 'focus': True} + """ + if isinstance(pane, str): + return {"shell_command": [pane]} + out: dict[str, t.Any] = {} + if "commands" in pane: + out["shell_command"] = pane["commands"] + if "focus" in pane: + out["focus"] = pane["focus"] + if not out and isinstance(pane, dict) and pane: + # Dict had keys, but none we recognized — surface to user instead + # of silently producing a no-command pane. + logger.warning( + "v1.x pane dict has no recognizable keys; produced empty pane", + extra={"tmux_key": ",".join(sorted(pane.keys()))}, + ) + return out diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 9efcd05b52..aa1509f854 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -193,7 +193,11 @@ def expand( return workspace_dict -def trickle(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: +def trickle( + workspace_dict: dict[str, t.Any], + *, + no_shell_command_before: bool = False, +) -> dict[str, t.Any]: """Return a dict with "trickled down" / inherited workspace values. This will only work if workspace has been expanded to full form with @@ -207,6 +211,12 @@ def trickle(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ---------- workspace_dict : dict the tmuxp workspace. + no_shell_command_before : bool, optional + When True, skip the shell_command_before propagation entirely. + Mirrors tmuxinator's ``--no-pre-window``, but slightly broader: + tmuxinator's flag only suppresses ``pre_window`` proper, whereas + tmuxp's flag skips ``shell_command_before`` at session, window, + and pane levels. Returns ------- @@ -251,19 +261,22 @@ def trickle(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: for pane_idx, pane_dict in enumerate(window_dict["panes"]): commands_before = [] - # Prepend shell_command_before to commands - if "shell_command_before" in workspace_dict: - commands_before.extend( - workspace_dict["shell_command_before"]["shell_command"], - ) - if "shell_command_before" in window_dict: - commands_before.extend( - window_dict["shell_command_before"]["shell_command"], - ) - if "shell_command_before" in pane_dict: - commands_before.extend( - pane_dict["shell_command_before"]["shell_command"], - ) + # Prepend shell_command_before to commands. Skipped entirely when + # no_shell_command_before is True (mirrors tmuxinator's + # --no-pre-window, but broader — we skip all three levels). + if not no_shell_command_before: + if "shell_command_before" in workspace_dict: + commands_before.extend( + workspace_dict["shell_command_before"]["shell_command"], + ) + if "shell_command_before" in window_dict: + commands_before.extend( + window_dict["shell_command_before"]["shell_command"], + ) + if "shell_command_before" in pane_dict: + commands_before.extend( + pane_dict["shell_command_before"]["shell_command"], + ) if "shell_command" in pane_dict: commands_before.extend(pane_dict["shell_command"]) diff --git a/tests/_internal/test_template.py b/tests/_internal/test_template.py new file mode 100644 index 0000000000..658fc41fd9 --- /dev/null +++ b/tests/_internal/test_template.py @@ -0,0 +1,144 @@ +"""Tests for the in-house template engine.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from tmuxp._internal.template import ( + UnresolvedVariableError, + parse_cli_vars, + render, +) + + +class RenderFixture(t.NamedTuple): + """Fixture for the strict=True render path.""" + + test_id: str + template: str + variables: dict[str, str] + expected: str | None + expected_error: type[Exception] | None = None + + +RENDER_FIXTURES: list[RenderFixture] = [ + RenderFixture("plain_no_subst", "hello world", {}, "hello world"), + RenderFixture("braced", "hi ${name}!", {"name": "ada"}, "hi ada!"), + RenderFixture("default_used", "port=${port:-3000}", {}, "port=3000"), + RenderFixture( + "default_overridden", + "port=${port:-3000}", + {"port": "4000"}, + "port=4000", + ), + RenderFixture( + "missing_strict", + "hi ${gone}", + {}, + None, + UnresolvedVariableError, + ), + RenderFixture("multiple_in_one", "${a}/${b}", {"a": "x", "b": "y"}, "x/y"), + RenderFixture("default_with_special", "p=${p:-a/b/c}", {}, "p=a/b/c"), + RenderFixture("name_underscore", "${my_var}", {"my_var": "v"}, "v"), + RenderFixture("dollar_not_braced", "$bare", {"bare": "v"}, "$bare"), + RenderFixture("env_like_var", "${HOME}", {"HOME": "/tmp"}, "/tmp"), + RenderFixture("default_empty", "p=${p:-}", {}, "p="), + RenderFixture( + "default_with_dash", + "x=${x:-a-b-c}", + {}, + "x=a-b-c", + ), +] + + +@pytest.mark.parametrize( + list(RenderFixture._fields), + RENDER_FIXTURES, + ids=[f.test_id for f in RENDER_FIXTURES], +) +def test_render( + test_id: str, + template: str, + variables: dict[str, str], + expected: str | None, + expected_error: type[Exception] | None, +) -> None: + """Run each render fixture; verify output or error.""" + if expected_error is not None: + with pytest.raises(expected_error): + render(template, variables) + else: + assert render(template, variables) == expected + + +def test_render_non_strict_leaves_missing_alone() -> None: + """strict=False keeps unresolved placeholders for later expansion.""" + out = render("untouched ${HOME}", {}, strict=False) + assert out == "untouched ${HOME}" + + +def test_render_non_strict_still_substitutes_known() -> None: + """strict=False resolves what it can and leaves the rest.""" + out = render("${a} ${b}", {"a": "1"}, strict=False) + assert out == "1 ${b}" + + +def test_render_default_does_not_apply_when_explicit_none() -> None: + """``${name}`` (no default) raises in strict; not silent-empty.""" + with pytest.raises(UnresolvedVariableError): + render("${name}", {}) + + +def test_render_unresolved_error_is_keyerror_subclass() -> None: + """UnresolvedVariableError is a KeyError subclass.""" + try: + render("${x}", {}) + except KeyError: + return + pytest.fail("Expected KeyError-derived UnresolvedVariableError") + + +class ParseVarsFixture(t.NamedTuple): + """Fixture for parse_cli_vars.""" + + test_id: str + args: list[str] + expected: dict[str, str] | None + expected_error: type[Exception] | None = None + + +PARSE_FIXTURES: list[ParseVarsFixture] = [ + ParseVarsFixture("empty", [], {}), + ParseVarsFixture("single", ["app=blog"], {"app": "blog"}), + ParseVarsFixture("multiple", ["a=1", "b=2"], {"a": "1", "b": "2"}), + ParseVarsFixture( + "value_contains_equals", + ["url=https://example.com/?a=1"], + {"url": "https://example.com/?a=1"}, + ), + ParseVarsFixture("empty_value", ["x="], {"x": ""}), + ParseVarsFixture("missing_equals", ["nope"], None, ValueError), +] + + +@pytest.mark.parametrize( + list(ParseVarsFixture._fields), + PARSE_FIXTURES, + ids=[f.test_id for f in PARSE_FIXTURES], +) +def test_parse_cli_vars( + test_id: str, + args: list[str], + expected: dict[str, str] | None, + expected_error: type[Exception] | None, +) -> None: + """Round-trip CLI tokens to the variable dict.""" + if expected_error is not None: + with pytest.raises(expected_error): + parse_cli_vars(args) + else: + assert parse_cli_vars(args) == expected diff --git a/tests/cli/test_here.py b/tests/cli/test_here.py new file mode 100644 index 0000000000..1335e07949 --- /dev/null +++ b/tests/cli/test_here.py @@ -0,0 +1,42 @@ +"""Tests for ``tmuxp load --here``.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tmuxp import exc +from tmuxp.cli.load import load_workspace + + +def test_load_with_here_outside_tmux_errors( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """--here outside an existing tmux session raises TmuxpException.""" + workspace = tmp_path / "ws.yaml" + workspace.write_text( + "session_name: t4-outside\n" + "windows:\n- window_name: w1\n panes:\n - shell_command:\n - echo hi\n", + ) + monkeypatch.delenv("TMUX", raising=False) + with pytest.raises(exc.TmuxpException, match="--here requires being inside"): + load_workspace(workspace, here=True, detached=True) + + +def test_load_with_here_and_append_errors( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """--here + --append are mutually exclusive.""" + workspace = tmp_path / "ws.yaml" + workspace.write_text( + "session_name: t4-mutex\n" + "windows:\n- window_name: w1\n panes:\n - shell_command:\n - echo hi\n", + ) + # Set TMUX so we get past the second precondition; the mutex check fires + # first. + monkeypatch.setenv("TMUX", "/tmp/fake-tmux-socket,0,0") + with pytest.raises(exc.TmuxpException, match="mutually exclusive"): + load_workspace(workspace, here=True, append=True, detached=True) diff --git a/tests/cli/test_manage.py b/tests/cli/test_manage.py new file mode 100644 index 0000000000..892f41e31d --- /dev/null +++ b/tests/cli/test_manage.py @@ -0,0 +1,162 @@ +"""Tests for ``tmuxp new``, ``tmuxp copy``, ``tmuxp delete``, ``tmuxp implode``.""" + +from __future__ import annotations + +import pathlib +import typing as t + +import pytest + +from tmuxp.cli.manage import ( + CLICopyNamespace, + CLIDeleteNamespace, + CLIImplodeNamespace, + CLINewNamespace, + command_copy, + command_delete, + command_implode, + command_new, +) + +if t.TYPE_CHECKING: + pass + + +@pytest.fixture +def configdir(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: + """Force tmuxp config dir to a tmp_path for the test scope.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def no_editor(monkeypatch: pytest.MonkeyPatch) -> None: + """Replace subprocess.call with a no-op so $EDITOR doesn't actually run.""" + monkeypatch.setattr("tmuxp.cli.manage.subprocess.call", lambda *a, **kw: 0) + + +# --------------------------------------------------------------------------- +# new +# --------------------------------------------------------------------------- + + +def test_command_new_creates_file_when_missing( + configdir: pathlib.Path, no_editor: None +) -> None: + """`tmuxp new <name>` writes a starter YAML if file is missing.""" + args = CLINewNamespace(name="proj1", color=None) + command_new(args) + assert (configdir / "proj1.yaml").exists() + content = (configdir / "proj1.yaml").read_text() + assert "session_name: proj1" in content + + +def test_command_new_skips_write_when_file_exists( + configdir: pathlib.Path, no_editor: None +) -> None: + """`tmuxp new <name>` does not overwrite an existing file.""" + target = configdir / "proj1.yaml" + target.write_text("session_name: original\n") + args = CLINewNamespace(name="proj1", color=None) + command_new(args) + assert target.read_text() == "session_name: original\n" + + +# --------------------------------------------------------------------------- +# copy +# --------------------------------------------------------------------------- + + +def test_command_copy_duplicates_file(configdir: pathlib.Path, no_editor: None) -> None: + """`tmuxp copy src dst` writes dst with src's content.""" + src = configdir / "src.yaml" + src.write_text("session_name: src\n") + args = CLICopyNamespace(src=str(src), dst="dst", answer_yes=True, color=None) + command_copy(args) + assert (configdir / "dst.yaml").read_text() == "session_name: src\n" + + +def test_command_copy_aborts_on_overwrite_decline( + configdir: pathlib.Path, + no_editor: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When dst exists and the user declines, dst content is preserved.""" + src = configdir / "src.yaml" + src.write_text("session_name: src\n") + dst = configdir / "dst.yaml" + dst.write_text("session_name: existing\n") + + monkeypatch.setattr("builtins.input", lambda _prompt: "n") + args = CLICopyNamespace(src=str(src), dst="dst", answer_yes=False, color=None) + command_copy(args) + assert dst.read_text() == "session_name: existing\n" + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +def test_command_delete_unlinks_with_yes( + configdir: pathlib.Path, +) -> None: + """`tmuxp delete <name> -y` deletes without prompting.""" + target = configdir / "proj1.yaml" + target.write_text("session_name: proj1\n") + args = CLIDeleteNamespace(names=[str(target)], answer_yes=True, color=None) + command_delete(args) + assert not target.exists() + + +def test_command_delete_skips_missing(configdir: pathlib.Path) -> None: + """Nonexistent name produces an error message but no exception.""" + args = CLIDeleteNamespace( + names=["nope-no-such-config"], + answer_yes=True, + color=None, + ) + command_delete(args) # must not raise + + +# --------------------------------------------------------------------------- +# implode +# --------------------------------------------------------------------------- + + +def test_command_implode_removes_existing_dirs( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + """`tmuxp implode -y` removes every tmuxp config directory found.""" + legacy_dir = tmp_path / "home" / ".tmuxp" + xdg_dir = tmp_path / "config" / "tmuxp" + legacy_dir.mkdir(parents=True) + xdg_dir.mkdir(parents=True) + (legacy_dir / "p1.yaml").write_text("session_name: p1\n") + (xdg_dir / "p2.yaml").write_text("session_name: p2\n") + + monkeypatch.setattr( + "tmuxp.cli.manage._implode_dirs", + lambda: [legacy_dir, xdg_dir], + ) + + args = CLIImplodeNamespace(answer_yes=True, color=None) + command_implode(args) + + assert not legacy_dir.exists() + assert not xdg_dir.exists() + + +def test_command_implode_aborts_on_decline( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + """Declining the global confirmation leaves directories intact.""" + d = tmp_path / "tmuxp" + d.mkdir() + (d / "p.yaml").write_text("session_name: p\n") + monkeypatch.setattr("tmuxp.cli.manage._implode_dirs", lambda: [d]) + monkeypatch.setattr("builtins.input", lambda _prompt: "n") + args = CLIImplodeNamespace(answer_yes=False, color=None) + command_implode(args) + assert d.exists() + assert (d / "p.yaml").exists() diff --git a/tests/cli/test_stop.py b/tests/cli/test_stop.py new file mode 100644 index 0000000000..2b59de2ffc --- /dev/null +++ b/tests/cli/test_stop.py @@ -0,0 +1,86 @@ +"""Tests for ``tmuxp stop``.""" + +from __future__ import annotations + +import pathlib +import typing as t + +from tmuxp.cli.stop import CLIStopNamespace, command_stop + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +def test_command_stop_kills_running_session( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """`tmuxp stop` removes a running session that matches the config.""" + server = session.server + name = session.name or "" + assert name + workspace = tmp_path / "test_stop.yaml" + workspace.write_text( + f"session_name: {name}\n" + "windows:\n- window_name: w1\n panes:\n - shell_command:\n - echo hi\n", + ) + args = CLIStopNamespace( + workspace_file=str(workspace), + socket_name=server.socket_name, + socket_path=server.socket_path, + color=None, + ) + assert server.has_session(name) + command_stop(args) + assert not server.has_session(name) + + +def test_command_stop_runs_on_project_stop_before_kill( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """on_project_stop hook fires before the session is killed.""" + server = session.server + name = session.name or "" + assert name + marker = tmp_path / "marker.log" + hook = tmp_path / "stop_hook.sh" + hook.write_text(f"#!/bin/sh\necho fired > {marker}\n") + hook.chmod(0o755) + + workspace = tmp_path / "test_stop_hook.yaml" + workspace.write_text( + f"session_name: {name}\n" + f"on_project_stop: {hook}\n" + "windows:\n- window_name: w1\n panes:\n - shell_command:\n - echo hi\n", + ) + args = CLIStopNamespace( + workspace_file=str(workspace), + socket_name=server.socket_name, + socket_path=server.socket_path, + color=None, + ) + command_stop(args) + assert marker.exists() + assert marker.read_text().strip() == "fired" + assert not server.has_session(name) + + +def test_command_stop_no_session_is_noop( + server: Server, + tmp_path: pathlib.Path, +) -> None: + """Stopping a non-existent session is a quiet no-op.""" + workspace = tmp_path / "test_stop_missing.yaml" + workspace.write_text( + "session_name: definitely-not-running-aaa-bbb\n" + "windows:\n- window_name: w1\n panes:\n - shell_command:\n - echo hi\n", + ) + args = CLIStopNamespace( + workspace_file=str(workspace), + socket_name=server.socket_name, + socket_path=server.socket_path, + color=None, + ) + command_stop(args) # must not raise diff --git a/tests/fixtures/import_teamocil/__init__.py b/tests/fixtures/import_teamocil/__init__.py index 1ec7c59fd5..9cfd5fd40d 100644 --- a/tests/fixtures/import_teamocil/__init__.py +++ b/tests/fixtures/import_teamocil/__init__.py @@ -2,4 +2,14 @@ from __future__ import annotations -from . import layouts, test1, test2, test3, test4 +from . import ( + layouts, + test1, + test2, + test3, + test4, + test_v1x_full, + test_v1x_string_pane, + test_with_env_var_default, + test_with_env_var_false, +) diff --git a/tests/fixtures/import_teamocil/layouts.py b/tests/fixtures/import_teamocil/layouts.py index 6e3e06bf7f..ef08f4b98d 100644 --- a/tests/fixtures/import_teamocil/layouts.py +++ b/tests/fixtures/import_teamocil/layouts.py @@ -87,6 +87,7 @@ two_windows = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "foo", @@ -102,10 +103,7 @@ "window_name": "bar", "start_directory": "/bar", "panes": [ - { - "shell_command": ["echo 'bar'", "echo 'bar in an array'"], - "target": "bottom-right", - }, + {"shell_command": ["echo 'bar'", "echo 'bar in an array'"]}, {"shell_command": "echo 'bar again'", "focus": True}, ], }, @@ -114,6 +112,7 @@ two_windows_with_filters = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "foo", @@ -136,6 +135,7 @@ two_windows_with_custom_command_options = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "foo", @@ -151,10 +151,7 @@ "window_name": "bar", "start_directory": "/bar", "panes": [ - { - "shell_command": ["echo 'bar'", "echo 'bar in an array'"], - "target": "bottom-right", - }, + {"shell_command": ["echo 'bar'", "echo 'bar in an array'"]}, {"shell_command": "echo 'bar again'", "focus": True}, ], }, @@ -163,6 +160,7 @@ three_windows_within_a_session = { "session_name": "my awesome session", + "environment": {"TEAMOCIL": "1"}, "windows": [ {"window_name": "first window", "panes": [{"shell_command": "echo 'foo'"}]}, {"window_name": "second window", "panes": [{"shell_command": "echo 'foo'"}]}, diff --git a/tests/fixtures/import_teamocil/test1.py b/tests/fixtures/import_teamocil/test1.py index 8e2065fec2..0f141a62a4 100644 --- a/tests/fixtures/import_teamocil/test1.py +++ b/tests/fixtures/import_teamocil/test1.py @@ -18,6 +18,7 @@ expected = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "sample-two-panes", diff --git a/tests/fixtures/import_teamocil/test2.py b/tests/fixtures/import_teamocil/test2.py index 0353a0edbf..556ba511ae 100644 --- a/tests/fixtures/import_teamocil/test2.py +++ b/tests/fixtures/import_teamocil/test2.py @@ -18,6 +18,7 @@ expected = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "sample-four-panes", diff --git a/tests/fixtures/import_teamocil/test3.py b/tests/fixtures/import_teamocil/test3.py index 1bcd32fef1..06911434f2 100644 --- a/tests/fixtures/import_teamocil/test3.py +++ b/tests/fixtures/import_teamocil/test3.py @@ -27,6 +27,7 @@ expected = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "my-first-window", diff --git a/tests/fixtures/import_teamocil/test4.py b/tests/fixtures/import_teamocil/test4.py index 1837abf508..869b909414 100644 --- a/tests/fixtures/import_teamocil/test4.py +++ b/tests/fixtures/import_teamocil/test4.py @@ -18,6 +18,7 @@ expected = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "erb-example", diff --git a/tests/fixtures/import_teamocil/test_v1x_full.py b/tests/fixtures/import_teamocil/test_v1x_full.py new file mode 100644 index 0000000000..e75321b89a --- /dev/null +++ b/tests/fixtures/import_teamocil/test_v1x_full.py @@ -0,0 +1,46 @@ +"""teamocil v1.x fixture: commands, focus, options, mixed pane forms.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test_v1x_full.yaml") +teamocil_dict = { + "name": "v1x-full", + "root": "~/proj", + "windows": [ + { + "name": "editor", + "root": "~/proj/src", + "layout": "main-vertical", + "focus": True, + "options": {"main-pane-width": "120"}, + "panes": [ + {"commands": ["vim"], "focus": True}, + "top", + ], + }, + {"name": "shell", "panes": ["bash"]}, + ], +} +expected = { + "session_name": "v1x-full", + "start_directory": "~/proj", + "windows": [ + { + "window_name": "editor", + "start_directory": "~/proj/src", + "layout": "main-vertical", + "focus": True, + "options": {"main-pane-width": "120"}, + "panes": [ + {"shell_command": ["vim"], "focus": True}, + {"shell_command": ["top"]}, + ], + }, + { + "window_name": "shell", + "panes": [{"shell_command": ["bash"]}], + }, + ], +} diff --git a/tests/fixtures/import_teamocil/test_v1x_full.yaml b/tests/fixtures/import_teamocil/test_v1x_full.yaml new file mode 100644 index 0000000000..d44e48ad2d --- /dev/null +++ b/tests/fixtures/import_teamocil/test_v1x_full.yaml @@ -0,0 +1,17 @@ +name: v1x-full +root: ~/proj +windows: +- name: editor + root: ~/proj/src + layout: main-vertical + focus: true + options: + main-pane-width: '120' + panes: + - commands: + - vim + focus: true + - top +- name: shell + panes: + - bash diff --git a/tests/fixtures/import_teamocil/test_v1x_string_pane.py b/tests/fixtures/import_teamocil/test_v1x_string_pane.py new file mode 100644 index 0000000000..c48740beba --- /dev/null +++ b/tests/fixtures/import_teamocil/test_v1x_string_pane.py @@ -0,0 +1,25 @@ +"""teamocil v1.x fixture: bare string pane -> shell_command list.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file( + "import_teamocil/test_v1x_string_pane.yaml", +) +teamocil_dict = { + "name": "v1x-string", + "windows": [{"name": "editor", "panes": ["vim", "top"]}], +} +expected = { + "session_name": "v1x-string", + "windows": [ + { + "window_name": "editor", + "panes": [ + {"shell_command": ["vim"]}, + {"shell_command": ["top"]}, + ], + }, + ], +} diff --git a/tests/fixtures/import_teamocil/test_v1x_string_pane.yaml b/tests/fixtures/import_teamocil/test_v1x_string_pane.yaml new file mode 100644 index 0000000000..08413e2e42 --- /dev/null +++ b/tests/fixtures/import_teamocil/test_v1x_string_pane.yaml @@ -0,0 +1,6 @@ +name: v1x-string +windows: +- name: editor + panes: + - vim + - top diff --git a/tests/fixtures/import_teamocil/test_with_env_var_default.py b/tests/fixtures/import_teamocil/test_with_env_var_default.py new file mode 100644 index 0000000000..4d9cf478c3 --- /dev/null +++ b/tests/fixtures/import_teamocil/test_with_env_var_default.py @@ -0,0 +1,22 @@ +"""teamocil fixture: v0.x default with_env_var=true exports TEAMOCIL=1.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file( + "import_teamocil/test_with_env_var_default.yaml", +) +teamocil_dict = { + "session": { + "name": "env-default", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, +} +expected = { + "session_name": "env-default", + "environment": {"TEAMOCIL": "1"}, + "windows": [ + {"window_name": "main", "panes": [{"shell_command": "echo hi"}]}, + ], +} diff --git a/tests/fixtures/import_teamocil/test_with_env_var_default.yaml b/tests/fixtures/import_teamocil/test_with_env_var_default.yaml new file mode 100644 index 0000000000..db57192bb7 --- /dev/null +++ b/tests/fixtures/import_teamocil/test_with_env_var_default.yaml @@ -0,0 +1,6 @@ +session: + name: env-default + windows: + - name: main + panes: + - cmd: echo hi diff --git a/tests/fixtures/import_teamocil/test_with_env_var_false.py b/tests/fixtures/import_teamocil/test_with_env_var_false.py new file mode 100644 index 0000000000..a801bf9009 --- /dev/null +++ b/tests/fixtures/import_teamocil/test_with_env_var_false.py @@ -0,0 +1,22 @@ +"""teamocil fixture: explicit with_env_var=false suppresses the env var.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file( + "import_teamocil/test_with_env_var_false.yaml", +) +teamocil_dict = { + "session": { + "name": "env-off", + "with_env_var": False, + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, +} +expected = { + "session_name": "env-off", + "windows": [ + {"window_name": "main", "panes": [{"shell_command": "echo hi"}]}, + ], +} diff --git a/tests/fixtures/import_teamocil/test_with_env_var_false.yaml b/tests/fixtures/import_teamocil/test_with_env_var_false.yaml new file mode 100644 index 0000000000..5057f7f12e --- /dev/null +++ b/tests/fixtures/import_teamocil/test_with_env_var_false.yaml @@ -0,0 +1,7 @@ +session: + name: env-off + with_env_var: false + windows: + - name: main + panes: + - cmd: echo hi diff --git a/tests/fixtures/import_tmuxinator/__init__.py b/tests/fixtures/import_tmuxinator/__init__.py index 84508e0405..0a25a590d2 100644 --- a/tests/fixtures/import_tmuxinator/__init__.py +++ b/tests/fixtures/import_tmuxinator/__init__.py @@ -2,4 +2,17 @@ from __future__ import annotations -from . import test1, test2, test3 +from . import ( + test1, + test2, + test3, + test_cli_args_dash_path, + test_cli_args_multi, + test_pre_alone, + test_pre_combo, + test_pre_shell, + test_rvm, + test_socket_path, + test_startup_window_by_index, + test_startup_window_by_name, +) diff --git a/tests/fixtures/import_tmuxinator/test2.py b/tests/fixtures/import_tmuxinator/test2.py index 97d923a912..4953347b94 100644 --- a/tests/fixtures/import_tmuxinator/test2.py +++ b/tests/fixtures/import_tmuxinator/test2.py @@ -49,7 +49,8 @@ "socket_name": "foo", "config": "~/.tmux.mac.conf", "start_directory": "~/test", - "shell_command_before": ["sudo /etc/rc.d/mysqld start", "rbenv shell 2.0.0-p247"], + "before_script": "sudo /etc/rc.d/mysqld start", + "shell_command_before": ["rbenv shell 2.0.0-p247"], "windows": [ { "window_name": "editor", diff --git a/tests/fixtures/import_tmuxinator/test3.py b/tests/fixtures/import_tmuxinator/test3.py index 86ebd22c16..4dc7b6681d 100644 --- a/tests/fixtures/import_tmuxinator/test3.py +++ b/tests/fixtures/import_tmuxinator/test3.py @@ -50,7 +50,7 @@ "socket_name": "foo", "start_directory": "~/test", "config": "~/.tmux.mac.conf", - "shell_command": "sudo /etc/rc.d/mysqld start", + "before_script": "sudo /etc/rc.d/mysqld start", "shell_command_before": ["rbenv shell 2.0.0-p247"], "windows": [ { diff --git a/tests/fixtures/import_tmuxinator/test_cli_args_dash_path.py b/tests/fixtures/import_tmuxinator/test_cli_args_dash_path.py new file mode 100644 index 0000000000..9635367e75 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_cli_args_dash_path.py @@ -0,0 +1,19 @@ +"""tmuxinator fixture: tmux_options path containing literal `-f` (regression).""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_cli_args_dash_path.yaml", +) +tmuxinator_dict = { + "name": "dash-path", + "tmux_options": "-f /home/me/-f-config.conf", + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "dash-path", + "config": "/home/me/-f-config.conf", + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_cli_args_dash_path.yaml b/tests/fixtures/import_tmuxinator/test_cli_args_dash_path.yaml new file mode 100644 index 0000000000..0db364edc2 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_cli_args_dash_path.yaml @@ -0,0 +1,4 @@ +name: dash-path +tmux_options: -f /home/me/-f-config.conf +windows: +- editor: vim diff --git a/tests/fixtures/import_tmuxinator/test_cli_args_multi.py b/tests/fixtures/import_tmuxinator/test_cli_args_multi.py new file mode 100644 index 0000000000..f6e13b0bf9 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_cli_args_multi.py @@ -0,0 +1,21 @@ +"""tmuxinator fixture: cli_args with -f, -L, -S all extracted.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_cli_args_multi.yaml", +) +tmuxinator_dict = { + "name": "multi-flags", + "cli_args": "-f ~/.tmux.conf -L mysock -S /tmp/tmux.sock", + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "multi-flags", + "config": "~/.tmux.conf", + "socket_name": "mysock", + "socket_path": "/tmp/tmux.sock", + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_cli_args_multi.yaml b/tests/fixtures/import_tmuxinator/test_cli_args_multi.yaml new file mode 100644 index 0000000000..a0d4b1ee11 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_cli_args_multi.yaml @@ -0,0 +1,4 @@ +name: multi-flags +cli_args: -f ~/.tmux.conf -L mysock -S /tmp/tmux.sock +windows: +- editor: vim diff --git a/tests/fixtures/import_tmuxinator/test_pre_alone.py b/tests/fixtures/import_tmuxinator/test_pre_alone.py new file mode 100644 index 0000000000..919295cfbd --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_pre_alone.py @@ -0,0 +1,21 @@ +"""tmuxinator fixture: solo `pre` maps to `before_script`.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_pre_alone.yaml", +) +tmuxinator_dict = { + "name": "alone", + "root": "~/test", + "pre": "sudo /etc/rc.d/mysqld start", + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "alone", + "start_directory": "~/test", + "before_script": "sudo /etc/rc.d/mysqld start", + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_pre_alone.yaml b/tests/fixtures/import_tmuxinator/test_pre_alone.yaml new file mode 100644 index 0000000000..c4d1f226ea --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_pre_alone.yaml @@ -0,0 +1,5 @@ +name: alone +root: ~/test +pre: sudo /etc/rc.d/mysqld start +windows: +- editor: vim diff --git a/tests/fixtures/import_tmuxinator/test_pre_combo.py b/tests/fixtures/import_tmuxinator/test_pre_combo.py new file mode 100644 index 0000000000..728f165024 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_pre_combo.py @@ -0,0 +1,21 @@ +"""tmuxinator fixture: `pre` + `pre_window` map independently.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_pre_combo.yaml", +) +tmuxinator_dict = { + "name": "combo", + "pre": "sudo /etc/rc.d/mysqld start", + "pre_window": ["echo first", "echo second"], + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "combo", + "before_script": "sudo /etc/rc.d/mysqld start", + "shell_command_before": ["echo first", "echo second"], + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_pre_combo.yaml b/tests/fixtures/import_tmuxinator/test_pre_combo.yaml new file mode 100644 index 0000000000..6c65f9360c --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_pre_combo.yaml @@ -0,0 +1,7 @@ +name: combo +pre: sudo /etc/rc.d/mysqld start +pre_window: +- echo first +- echo second +windows: +- editor: vim diff --git a/tests/fixtures/import_tmuxinator/test_pre_shell.py b/tests/fixtures/import_tmuxinator/test_pre_shell.py new file mode 100644 index 0000000000..b65dd99d8a --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_pre_shell.py @@ -0,0 +1,19 @@ +"""tmuxinator fixture: `pre` with shell metacharacters triggers warning.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_pre_shell.yaml", +) +tmuxinator_dict = { + "name": "shell-pre", + "pre": "echo a | grep b && echo done", + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "shell-pre", + "before_script": "echo a | grep b && echo done", + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_pre_shell.yaml b/tests/fixtures/import_tmuxinator/test_pre_shell.yaml new file mode 100644 index 0000000000..96976304f1 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_pre_shell.yaml @@ -0,0 +1,4 @@ +name: shell-pre +pre: echo a | grep b && echo done +windows: +- editor: vim diff --git a/tests/fixtures/import_tmuxinator/test_rvm.py b/tests/fixtures/import_tmuxinator/test_rvm.py new file mode 100644 index 0000000000..d217f70158 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_rvm.py @@ -0,0 +1,17 @@ +"""tmuxinator fixture: `rvm` -> shell_command_before with `rvm use` wrapper.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test_rvm.yaml") +tmuxinator_dict = { + "name": "rvm-project", + "rvm": "3.2.0", + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "rvm-project", + "shell_command_before": ["rvm use 3.2.0"], + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_rvm.yaml b/tests/fixtures/import_tmuxinator/test_rvm.yaml new file mode 100644 index 0000000000..da95ec3c36 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_rvm.yaml @@ -0,0 +1,4 @@ +name: rvm-project +rvm: 3.2.0 +windows: +- editor: vim diff --git a/tests/fixtures/import_tmuxinator/test_socket_path.py b/tests/fixtures/import_tmuxinator/test_socket_path.py new file mode 100644 index 0000000000..ebeb932f5f --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_socket_path.py @@ -0,0 +1,19 @@ +"""tmuxinator fixture: `socket_path` passes through to tmuxp top-level.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_socket_path.yaml", +) +tmuxinator_dict = { + "name": "sock-project", + "socket_path": "/tmp/my-tmux.sock", + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "sock-project", + "socket_path": "/tmp/my-tmux.sock", + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_socket_path.yaml b/tests/fixtures/import_tmuxinator/test_socket_path.yaml new file mode 100644 index 0000000000..8fe84549f5 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_socket_path.yaml @@ -0,0 +1,4 @@ +name: sock-project +socket_path: /tmp/my-tmux.sock +windows: +- editor: vim diff --git a/tests/fixtures/import_tmuxinator/test_startup_window_by_index.py b/tests/fixtures/import_tmuxinator/test_startup_window_by_index.py new file mode 100644 index 0000000000..2fabad3810 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_startup_window_by_index.py @@ -0,0 +1,22 @@ +"""tmuxinator fixture: `startup_window` resolved by 0-based index.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_startup_window_by_index.yaml", +) +tmuxinator_dict = { + "name": "focus-by-idx", + "startup_window": 2, + "windows": [{"shell": "bash"}, {"editor": "vim"}, {"logs": "tail -f log"}], +} +expected = { + "session_name": "focus-by-idx", + "windows": [ + {"window_name": "shell", "panes": ["bash"]}, + {"window_name": "editor", "panes": ["vim"]}, + {"window_name": "logs", "panes": ["tail -f log"], "focus": True}, + ], +} diff --git a/tests/fixtures/import_tmuxinator/test_startup_window_by_index.yaml b/tests/fixtures/import_tmuxinator/test_startup_window_by_index.yaml new file mode 100644 index 0000000000..f579b6d4bc --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_startup_window_by_index.yaml @@ -0,0 +1,6 @@ +name: focus-by-idx +startup_window: 2 +windows: +- shell: bash +- editor: vim +- logs: tail -f log diff --git a/tests/fixtures/import_tmuxinator/test_startup_window_by_name.py b/tests/fixtures/import_tmuxinator/test_startup_window_by_name.py new file mode 100644 index 0000000000..b87d6421a7 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_startup_window_by_name.py @@ -0,0 +1,22 @@ +"""tmuxinator fixture: `startup_window` resolved by window name.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_startup_window_by_name.yaml", +) +tmuxinator_dict = { + "name": "focus-by-name", + "startup_window": "editor", + "windows": [{"shell": "bash"}, {"editor": "vim"}, {"logs": "tail -f log"}], +} +expected = { + "session_name": "focus-by-name", + "windows": [ + {"window_name": "shell", "panes": ["bash"]}, + {"window_name": "editor", "panes": ["vim"], "focus": True}, + {"window_name": "logs", "panes": ["tail -f log"]}, + ], +} diff --git a/tests/fixtures/import_tmuxinator/test_startup_window_by_name.yaml b/tests/fixtures/import_tmuxinator/test_startup_window_by_name.yaml new file mode 100644 index 0000000000..05d0fe8401 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_startup_window_by_name.yaml @@ -0,0 +1,6 @@ +name: focus-by-name +startup_window: editor +windows: +- shell: bash +- editor: vim +- logs: tail -f log diff --git a/tests/fixtures/workspace/builder/pane_titles.yaml b/tests/fixtures/workspace/builder/pane_titles.yaml new file mode 100644 index 0000000000..2aa8bdfd65 --- /dev/null +++ b/tests/fixtures/workspace/builder/pane_titles.yaml @@ -0,0 +1,13 @@ +session_name: pane-titles +enable_pane_titles: true +pane_title_position: bottom +pane_title_format: "#{pane_index}: #{pane_title}" +windows: +- window_name: w1 + panes: + - shell_command: + - echo first + title: editor + - shell_command: + - echo second + title: server diff --git a/tests/fixtures/workspace/builder/shell_command_after.yaml b/tests/fixtures/workspace/builder/shell_command_after.yaml new file mode 100644 index 0000000000..7076b5c005 --- /dev/null +++ b/tests/fixtures/workspace/builder/shell_command_after.yaml @@ -0,0 +1,10 @@ +session_name: shell-command-after +windows: +- window_name: w1 + shell_command_after: + - echo TMUXP_T3_MARKER + panes: + - shell_command: + - echo first + - shell_command: + - echo second diff --git a/tests/fixtures/workspace/builder/synchronize_after.yaml b/tests/fixtures/workspace/builder/synchronize_after.yaml new file mode 100644 index 0000000000..b71ecf60a7 --- /dev/null +++ b/tests/fixtures/workspace/builder/synchronize_after.yaml @@ -0,0 +1,9 @@ +session_name: sync-after +windows: +- window_name: w1 + synchronize: after + panes: + - shell_command: + - echo first + - shell_command: + - echo second diff --git a/tests/fixtures/workspace/builder/synchronize_before.yaml b/tests/fixtures/workspace/builder/synchronize_before.yaml new file mode 100644 index 0000000000..053f1f8af8 --- /dev/null +++ b/tests/fixtures/workspace/builder/synchronize_before.yaml @@ -0,0 +1,9 @@ +session_name: sync-before +windows: +- window_name: w1 + synchronize: before + panes: + - shell_command: + - echo first + - shell_command: + - echo second diff --git a/tests/fixtures/workspace/builder/synchronize_omitted.yaml b/tests/fixtures/workspace/builder/synchronize_omitted.yaml new file mode 100644 index 0000000000..1eb3ea0506 --- /dev/null +++ b/tests/fixtures/workspace/builder/synchronize_omitted.yaml @@ -0,0 +1,8 @@ +session_name: sync-off +windows: +- window_name: w1 + panes: + - shell_command: + - echo first + - shell_command: + - echo second diff --git a/tests/test_util.py b/tests/test_util.py index 098c8c212b..5f3e5d6381 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -12,7 +12,13 @@ from tmuxp import exc from tmuxp.exc import BeforeLoadScriptError, BeforeLoadScriptNotExists -from tmuxp.util import get_pane, get_session, oh_my_zsh_auto_title, run_before_script +from tmuxp.util import ( + get_pane, + get_session, + oh_my_zsh_auto_title, + run_before_script, + run_lifecycle_hook, +) from .constants import FIXTURE_PATH @@ -234,3 +240,52 @@ def patched_exists(path: str) -> bool: warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] assert len(warning_records) >= 1 assert "DISABLE_AUTO_TITLE" in warning_records[0].message + + +def test_run_lifecycle_hook_none_is_noop() -> None: + """Passing None to run_lifecycle_hook returns 0 without invoking shell.""" + assert run_lifecycle_hook("on_project_start", None) == 0 + + +def test_run_lifecycle_hook_string(tmp_path: pathlib.Path) -> None: + """String value runs once via run_before_script.""" + script = tmp_path / "h.sh" + script.write_text("#!/bin/sh\necho ok\n") + script.chmod(0o755) + assert run_lifecycle_hook("on_project_start", str(script)) == 0 + + +def test_run_lifecycle_hook_list_runs_each(tmp_path: pathlib.Path) -> None: + """List value runs each item sequentially.""" + log_file = tmp_path / "out.log" + a = tmp_path / "a.sh" + a.write_text(f"#!/bin/sh\necho A >> {log_file}\n") + a.chmod(0o755) + b = tmp_path / "b.sh" + b.write_text(f"#!/bin/sh\necho B >> {log_file}\n") + b.chmod(0o755) + assert run_lifecycle_hook("on_project_start", [str(a), str(b)]) == 0 + assert log_file.read_text().splitlines() == ["A", "B"] + + +def test_run_lifecycle_hook_aborts_on_first_failure( + tmp_path: pathlib.Path, +) -> None: + """List iteration aborts when any item exits non-zero.""" + a = tmp_path / "ok.sh" + a.write_text("#!/bin/sh\nexit 0\n") + a.chmod(0o755) + b = tmp_path / "fail.sh" + b.write_text("#!/bin/sh\nexit 7\n") + b.chmod(0o755) + c = tmp_path / "never.sh" + c.write_text("#!/bin/sh\nexit 0\n") + c.chmod(0o755) + with pytest.raises(BeforeLoadScriptError): + run_lifecycle_hook("on_project_start", [str(a), str(b), str(c)]) + + +def test_run_lifecycle_hook_propagates_not_found(tmp_path: pathlib.Path) -> None: + """Missing script raises BeforeLoadScriptNotExists like before_script.""" + with pytest.raises(BeforeLoadScriptNotExists): + run_lifecycle_hook("on_project_start", str(tmp_path / "nope.sh")) diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index da95168f46..8e1db57f62 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -1768,3 +1768,166 @@ def test_builder_logs_window_and_pane_creation( assert len(cmd_logs) >= 1 builder.session.kill() + + +class SynchronizeFixture(t.NamedTuple): + """Synchronize-panes fixture.""" + + test_id: str + workspace_file: str + expected_enabled: bool # True if synchronize-panes is "on" after build + + +SYNCHRONIZE_FIXTURES: list[SynchronizeFixture] = [ + SynchronizeFixture( + test_id="before", + workspace_file="workspace/builder/synchronize_before.yaml", + expected_enabled=True, + ), + SynchronizeFixture( + test_id="after", + workspace_file="workspace/builder/synchronize_after.yaml", + expected_enabled=True, + ), + SynchronizeFixture( + test_id="omitted", + workspace_file="workspace/builder/synchronize_omitted.yaml", + expected_enabled=False, + ), +] + + +@pytest.mark.parametrize( + list(SynchronizeFixture._fields), + SYNCHRONIZE_FIXTURES, + ids=[f.test_id for f in SYNCHRONIZE_FIXTURES], +) +def test_synchronize_panes( + test_id: str, + workspace_file: str, + expected_enabled: bool, + session: Session, +) -> None: + """`synchronize` config key turns synchronize-panes on at the right phase.""" + workspace = ConfigReader._from_file(test_utils.get_workspace_file(workspace_file)) + workspace = loader.expand(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + window = builder.session.windows[-1] + assert bool(window.show_option("synchronize-panes")) is expected_enabled + builder.session.kill() + + +def test_synchronize_panes_true_treated_as_before( + session: Session, +) -> None: + """`synchronize: true` is equivalent to `synchronize: before`.""" + workspace = { + "session_name": "sync-true", + "windows": [ + { + "window_name": "w1", + "synchronize": True, + "panes": [{"shell_command": ["echo a"]}, {"shell_command": ["echo b"]}], + }, + ], + } + workspace = loader.expand(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + window = builder.session.windows[-1] + assert bool(window.show_option("synchronize-panes")) is True + builder.session.kill() + + +def test_pane_titles_session_options_set( + session: Session, +) -> None: + """enable_pane_titles applies pane-border-status and -format.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/pane_titles.yaml"), + ) + workspace = loader.expand(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + # pane-border-* are window-scope options applied via global_=True + assert builder.session.show_option("pane-border-status", global_=True) == "bottom" + assert ( + builder.session.show_option("pane-border-format", global_=True) + == "#{pane_index}: #{pane_title}" + ) + builder.session.kill() + + +def test_pane_titles_per_pane_title_applied( + session: Session, +) -> None: + """Per-pane `title` is stored on the pane via set_title().""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/pane_titles.yaml"), + ) + workspace = loader.expand(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = builder.session.windows[-1] + titles = [ + p.cmd("display-message", "-p", "#{pane_title}").stdout[0] for p in window.panes + ] + assert "editor" in titles + assert "server" in titles + builder.session.kill() + + +def test_pane_titles_defaults_when_only_enable( + session: Session, +) -> None: + """enable_pane_titles=true with no other keys uses defaults.""" + workspace = { + "session_name": "titles-defaults", + "enable_pane_titles": True, + "windows": [ + { + "window_name": "w1", + "panes": [{"shell_command": ["echo a"]}], + }, + ], + } + workspace = loader.expand(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert builder.session.show_option("pane-border-status", global_=True) == "top" + assert ( + builder.session.show_option("pane-border-format", global_=True) + == "#{pane_index}: #{pane_title}" + ) + builder.session.kill() + + +@pytest.mark.flaky(reruns=5) +def test_shell_command_after_runs_in_each_pane( + session: Session, +) -> None: + """Window-level `shell_command_after` runs in every pane post-build.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/shell_command_after.yaml"), + ) + workspace = loader.expand(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + def saw_marker(p: Pane) -> bool: + def f() -> bool: + captured = p.cmd("capture-pane", "-p", "-J").stdout + return any("TMUXP_T3_MARKER" in line for line in captured) + + return retry_until(f, raises=False) + + window = builder.session.windows[-1] + for pane in window.panes: + assert saw_marker(pane), ( + f"shell_command_after did not run in pane {pane.pane_id}" + ) + builder.session.kill() diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index fc6d5ccd5b..6cdbfb1326 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -163,6 +163,49 @@ def test_trickle_relative_start_directory(config_fixture: WorkspaceTestData) -> assert test_workspace == config_fixture.trickle.expected +def test_trickle_no_shell_command_before_skips_propagation() -> None: + """trickle(no_shell_command_before=True) skips session/window/pane prep.""" + import copy as _copy + + yaml_text = """ + session_name: t7 + shell_command_before: + - echo SESSION_BEFORE + windows: + - window_name: w1 + shell_command_before: + - echo WINDOW_BEFORE + panes: + - shell_command: + - echo PANE_MAIN + shell_command_before: + - echo PANE_BEFORE + """ + sconfig = ConfigReader._load(fmt="yaml", content=yaml_text) + expanded = loader.expand(sconfig) + + # trickle mutates nested dicts; use deepcopy so the two runs don't share + # window/pane references. + skipped = loader.trickle(_copy.deepcopy(expanded), no_shell_command_before=True) + pane_cmds = skipped["windows"][0]["panes"][0]["shell_command"] + pane_strs = [c.get("cmd", "") if isinstance(c, dict) else c for c in pane_cmds] + assert all("BEFORE" not in s for s in pane_strs) + assert any("PANE_MAIN" in s for s in pane_strs) + + # With flag=False (default): pane.shell_command has all three before's + # prepended. + propagated = loader.trickle(_copy.deepcopy(expanded)) + propagated_cmds = propagated["windows"][0]["panes"][0]["shell_command"] + propagated_strs = [ + c.get("cmd", "") if isinstance(c, dict) else c for c in propagated_cmds + ] + joined = " ".join(propagated_strs) + assert "SESSION_BEFORE" in joined + assert "WINDOW_BEFORE" in joined + assert "PANE_BEFORE" in joined + assert "PANE_MAIN" in joined + + def test_trickle_window_with_no_pane_workspace() -> None: """Verify tmuxp window config automatically infers a single pane.""" test_yaml = """ diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 0ea457e7c6..c0676fb096 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -46,6 +46,30 @@ class TeamocilConfigTestFixture(t.NamedTuple): teamocil_dict=fixtures.test4.teamocil_dict, tmuxp_dict=fixtures.test4.expected, ), + TeamocilConfigTestFixture( + test_id="with_env_var_default", # v0.x default exports TEAMOCIL=1 + teamocil_yaml=fixtures.test_with_env_var_default.teamocil_yaml, + teamocil_dict=fixtures.test_with_env_var_default.teamocil_dict, + tmuxp_dict=fixtures.test_with_env_var_default.expected, + ), + TeamocilConfigTestFixture( + test_id="with_env_var_false", # explicit false suppresses + teamocil_yaml=fixtures.test_with_env_var_false.teamocil_yaml, + teamocil_dict=fixtures.test_with_env_var_false.teamocil_dict, + tmuxp_dict=fixtures.test_with_env_var_false.expected, + ), + TeamocilConfigTestFixture( + test_id="v1x_string_pane", # bare string pane -> shell_command list + teamocil_yaml=fixtures.test_v1x_string_pane.teamocil_yaml, + teamocil_dict=fixtures.test_v1x_string_pane.teamocil_dict, + tmuxp_dict=fixtures.test_v1x_string_pane.expected, + ), + TeamocilConfigTestFixture( + test_id="v1x_full", # commands, focus, options, mixed panes + teamocil_yaml=fixtures.test_v1x_full.teamocil_yaml, + teamocil_dict=fixtures.test_v1x_full.teamocil_dict, + tmuxp_dict=fixtures.test_v1x_full.expected, + ), ] @@ -157,3 +181,122 @@ def test_import_teamocil_logs_debug( records = [r for r in caplog.records if r.msg == "importing teamocil workspace"] assert len(records) >= 1 assert getattr(records[0], "tmux_session", None) == "test" + + +def test_import_teamocil_with_env_var_string_false_suppresses() -> None: + """A YAML-quoted ``with_env_var`` of ``false`` suppresses TEAMOCIL=1.""" + workspace = { + "session": { + "name": "string-false", + "with_env_var": "false", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, + } + result = importers.import_teamocil(workspace) + assert "environment" not in result + + +def test_import_teamocil_warns_on_cmd_separator( + caplog: pytest.LogCaptureFixture, +) -> None: + """`cmd_separator` emits WARNING.""" + workspace = { + "session": { + "name": "sep", + "cmd_separator": " && ", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_teamocil(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING + and getattr(r, "tmux_key", None) == "cmd_separator" + ] + assert len(warnings) == 1 + + +def test_import_teamocil_warns_on_clear( + caplog: pytest.LogCaptureFixture, +) -> None: + """`clear` on a window emits WARNING.""" + workspace = { + "session": { + "name": "clr", + "windows": [ + {"name": "main", "clear": True, "panes": [{"cmd": "echo hi"}]}, + ], + }, + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_teamocil(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and getattr(r, "tmux_key", None) == "clear" + ] + assert len(warnings) == 1 + + +def test_import_teamocil_v1x_skips_env_var( + caplog: pytest.LogCaptureFixture, +) -> None: + """A v1.x config (no `session:` wrapper, no `cmd`/`splits`) skips env.""" + workspace = { + "name": "v1x", + "windows": [{"name": "main", "panes": [{"commands": ["echo hi"]}]}], + } + result = importers.import_teamocil(workspace) + assert "environment" not in result + + +def test_import_teamocil_v1x_unknown_pane_keys_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """A v1.x pane dict with no recognizable keys warns and produces {}.""" + workspace = { + "name": "v1x", + "windows": [{"name": "w", "panes": [{"width": 50}]}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + result = importers.import_teamocil(workspace) + assert result["windows"][0]["panes"][0] == {} + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "no recognizable keys" in r.msg + ] + assert len(warnings) == 1 + assert getattr(warnings[0], "tmux_key", None) == "width" + + +def test_import_teamocil_warns_on_v0x_pane_geometry( + caplog: pytest.LogCaptureFixture, +) -> None: + """v0.x pane width/height/target each emit WARNING.""" + workspace = { + "session": { + "name": "geom", + "windows": [ + { + "name": "w", + "splits": [ + {"cmd": "echo a", "width": 50}, + {"cmd": "echo b", "height": 30}, + {"cmd": "echo c", "target": "bottom-right"}, + ], + }, + ], + }, + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_teamocil(workspace) + keys: list[str] = sorted( + t.cast(str, getattr(r, "tmux_key", "")) + for r in caplog.records + if r.levelno == logging.WARNING + and getattr(r, "tmux_key", None) in {"width", "height", "target"} + ) + assert keys == ["height", "target", "width"] diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 457605f2ab..d5ab394c13 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -40,6 +40,60 @@ class TmuxinatorConfigTestFixture(t.NamedTuple): tmuxinator_dict=fixtures.test3.tmuxinator_dict, tmuxp_dict=fixtures.test3.expected, ), + TmuxinatorConfigTestFixture( + test_id="pre_alone", # solo pre -> before_script + tmuxinator_yaml=fixtures.test_pre_alone.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_pre_alone.tmuxinator_dict, + tmuxp_dict=fixtures.test_pre_alone.expected, + ), + TmuxinatorConfigTestFixture( + test_id="pre_combo", # pre + pre_window map independently + tmuxinator_yaml=fixtures.test_pre_combo.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_pre_combo.tmuxinator_dict, + tmuxp_dict=fixtures.test_pre_combo.expected, + ), + TmuxinatorConfigTestFixture( + test_id="pre_shell_metachars", # warning when pre has shell constructs + tmuxinator_yaml=fixtures.test_pre_shell.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_pre_shell.tmuxinator_dict, + tmuxp_dict=fixtures.test_pre_shell.expected, + ), + TmuxinatorConfigTestFixture( + test_id="cli_args_multi_flags", # -f/-L/-S all extracted + tmuxinator_yaml=fixtures.test_cli_args_multi.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_cli_args_multi.tmuxinator_dict, + tmuxp_dict=fixtures.test_cli_args_multi.expected, + ), + TmuxinatorConfigTestFixture( + test_id="cli_args_dash_in_path", # regression: -f in path doesn't corrupt + tmuxinator_yaml=fixtures.test_cli_args_dash_path.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_cli_args_dash_path.tmuxinator_dict, + tmuxp_dict=fixtures.test_cli_args_dash_path.expected, + ), + TmuxinatorConfigTestFixture( + test_id="rvm", # rvm wrapped as `rvm use <ver>` + tmuxinator_yaml=fixtures.test_rvm.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_rvm.tmuxinator_dict, + tmuxp_dict=fixtures.test_rvm.expected, + ), + TmuxinatorConfigTestFixture( + test_id="startup_window_by_name", # name -> focus + tmuxinator_yaml=fixtures.test_startup_window_by_name.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_startup_window_by_name.tmuxinator_dict, + tmuxp_dict=fixtures.test_startup_window_by_name.expected, + ), + TmuxinatorConfigTestFixture( + test_id="startup_window_by_index", # int -> focus + tmuxinator_yaml=fixtures.test_startup_window_by_index.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_startup_window_by_index.tmuxinator_dict, + tmuxp_dict=fixtures.test_startup_window_by_index.expected, + ), + TmuxinatorConfigTestFixture( + test_id="socket_path", # socket_path passes through + tmuxinator_yaml=fixtures.test_socket_path.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_socket_path.tmuxinator_dict, + tmuxp_dict=fixtures.test_socket_path.expected, + ), ] @@ -76,3 +130,150 @@ def test_import_tmuxinator_logs_debug( records = [r for r in caplog.records if r.msg == "importing tmuxinator workspace"] assert len(records) >= 1 assert getattr(records[0], "tmux_session", None) == "test" + + +def test_import_tmuxinator_warns_on_shell_metachars_in_pre( + caplog: pytest.LogCaptureFixture, +) -> None: + """`pre` containing shell constructs emits WARNING with tmux_key=pre.""" + workspace = { + "name": "shell-pre", + "pre": "echo a | grep b && echo done", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and getattr(r, "tmux_key", None) == "pre" + ] + assert len(warnings) == 1 + assert getattr(warnings[0], "tmux_session", None) == "shell-pre" + + +def test_import_tmuxinator_no_warning_when_pre_is_plain( + caplog: pytest.LogCaptureFixture, +) -> None: + """A `pre` value without shell metacharacters emits no warning.""" + workspace = { + "name": "plain-pre", + "pre": "sudo /etc/rc.d/mysqld start", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and getattr(r, "tmux_key", None) == "pre" + ] + assert warnings == [] + + +def test_import_tmuxinator_warns_on_unknown_cli_args_flag( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unrecognized tmux flag in cli_args emits a WARNING.""" + workspace = { + "name": "weird", + "cli_args": "-f ~/.tmux.conf -X bogus", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and getattr(r, "tmux_key", None) == "-X" + ] + assert len(warnings) == 1 + assert getattr(warnings[0], "tmux_session", None) == "weird" + + +def test_import_tmuxinator_warns_on_attach_false( + caplog: pytest.LogCaptureFixture, +) -> None: + """`attach: false` emits WARNING directing user to CLI `-d` flag.""" + workspace = { + "name": "no-attach", + "attach": False, + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and getattr(r, "tmux_key", None) == "attach" + ] + assert len(warnings) == 1 + + +def test_import_tmuxinator_on_project_first_start_falls_back_to_pre( + caplog: pytest.LogCaptureFixture, +) -> None: + """`on_project_first_start` maps to before_script when `pre` absent.""" + workspace = { + "name": "fallback", + "on_project_first_start": "echo first", + "windows": [{"editor": "vim"}], + } + result = importers.import_tmuxinator(workspace) + assert result["before_script"] == "echo first" + + +def test_import_tmuxinator_pre_window_chain_first_match_wins( + caplog: pytest.LogCaptureFixture, +) -> None: + """Rbenv wins over rvm/pre_tab/pre_window per project.rb:175-188.""" + workspace = { + "name": "chain", + "rbenv": "2.7.0", + "rvm": "3.2.0", + "pre_tab": "echo pre_tab", + "pre_window": "echo pre_window", + "windows": [{"editor": "vim"}], + } + result = importers.import_tmuxinator(workspace) + assert result["shell_command_before"] == ["rbenv shell 2.7.0"] + + +def test_import_tmuxinator_warns_on_unresolved_startup_window( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unresolvable `startup_window` value emits WARNING.""" + workspace = { + "name": "miss", + "startup_window": "nonexistent", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING + and getattr(r, "tmux_key", None) == "startup_window" + ] + assert len(warnings) == 1 + + +def test_import_tmuxinator_warns_on_out_of_range_startup_window_int( + caplog: pytest.LogCaptureFixture, +) -> None: + """An integer `startup_window` past the window count emits WARNING.""" + workspace = { + "name": "oor", + "startup_window": 99, + "windows": [{"editor": "vim"}, {"shell": "bash"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING + and getattr(r, "tmux_key", None) == "startup_window" + ] + assert len(warnings) == 1