tmuxp-plugin-worktree 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ Metadata-Version: 2.4
2
+ Name: tmuxp-plugin-worktree
3
+ Version: 0.1.0
4
+ Summary: tmuxp plugin: pick the git worktree a session starts in
5
+ Author: Robbert Helling
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rhelling/tmuxp-plugin-worktree
8
+ Project-URL: Issues, https://github.com/rhelling/tmuxp-plugin-worktree/issues
9
+ Project-URL: Changelog, https://github.com/rhelling/tmuxp-plugin-worktree/releases
10
+ Keywords: tmux,tmuxp,git,worktree,plugin
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Environment :: Plugins
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Topic :: Software Development :: Version Control :: Git
20
+ Classifier: Topic :: Terminals
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: tmuxp>=1.73
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest>=7; extra == "test"
28
+ Dynamic: license-file
29
+
30
+ # tmuxp-plugin-worktree
31
+
32
+ A [tmuxp](https://github.com/tmux-python/tmuxp) plugin that lets you pick the git
33
+ worktree a session starts in.
34
+
35
+ When the repository of a session's `start_directory` has more than one worktree,
36
+ `tmuxp load <session>` first shows a picker with the configured directory
37
+ preselected:
38
+
39
+ ```text
40
+ worktree>
41
+ Session myapp: pick a worktree (Enter = start, Esc = cancel)
42
+ > myrepo [main] (default)
43
+ myrepo-feature-x [feature/x]
44
+ myrepo-hotfix [hotfix/login]
45
+ ```
46
+
47
+ Picking another worktree:
48
+
49
+ - moves every session, window and pane `start_directory` inside the repository
50
+ into that worktree (relative `cd` commands in your panes keep working), and a
51
+ `before_script` that lives in the repository;
52
+ - renames the session to `<session>-<worktree>` (for example `myapp-feature-x`),
53
+ so it can run next to the default session.
54
+
55
+ A branch may not have every directory your workspace uses. A directory that
56
+ exists in the default worktree but is missing in the chosen worktree is replaced
57
+ by its nearest existing parent, with a note on stderr (tmux would otherwise
58
+ silently start the pane in `$HOME`). Window and pane directories are checked
59
+ after `before_script`, so setup can create them first. The session directory is
60
+ resolved earlier to provide setup with a working directory.
61
+
62
+ A `before_script` the chosen worktree does not have, such as an untracked file,
63
+ keeps running from the default worktree. Its arguments are preserved, including
64
+ quoting and spaces; only the executable's path is rewritten.
65
+
66
+ With a single worktree, or when the start directory is not a git repository,
67
+ nothing changes and no picker is shown.
68
+
69
+ ## Requirements
70
+
71
+ - tmuxp 1.73 or newer for the no-install setup (`workspace_builder_paths`);
72
+ tested with tmuxp 1.73, 1.74 and 1.76
73
+ - git
74
+ - fzf (optional; without it you get a numbered menu)
75
+ - Python 3.10 or newer (the Python that tmuxp runs on)
76
+
77
+ ## Installation
78
+
79
+ ### Option A: install from PyPI (recommended)
80
+
81
+ Install the package into the Python environment tmuxp runs in; then only the
82
+ `plugins` key is needed in your workspace files.
83
+
84
+ ```bash
85
+ # tmuxp installed with pipx
86
+ pipx inject tmuxp tmuxp-plugin-worktree
87
+
88
+ # tmuxp installed with uv
89
+ uv tool install tmuxp --with tmuxp-plugin-worktree
90
+
91
+ # tmuxp installed with pip in a virtualenv (activate it first)
92
+ python -m pip install tmuxp-plugin-worktree
93
+ ```
94
+
95
+ ```yaml
96
+ session_name: myapp
97
+ start_directory: ~/code/myrepo
98
+ plugins:
99
+ - tmuxp_plugin_worktree.WorktreePlugin
100
+ windows:
101
+ - window_name: editor
102
+ panes:
103
+ - nvim
104
+ ```
105
+
106
+ ### Option B: no install (works with Homebrew's tmuxp)
107
+
108
+ Homebrew installs tmuxp in a private virtualenv without pip. Since 1.73, tmuxp
109
+ can add import paths per workspace with `workspace_builder_paths`, so you can
110
+ point it at a checkout instead:
111
+
112
+ ```bash
113
+ git clone https://github.com/rhelling/tmuxp-plugin-worktree.git ~/Documents/Projects/tmuxp-plugin-worktree
114
+ ```
115
+
116
+ ```yaml
117
+ session_name: myapp
118
+ start_directory: ~/code/myrepo
119
+ workspace_builder_paths:
120
+ - ~/Documents/Projects/tmuxp-plugin-worktree/src
121
+ plugins:
122
+ - tmuxp_plugin_worktree.WorktreePlugin
123
+ windows:
124
+ - window_name: editor
125
+ panes:
126
+ - nvim
127
+ ```
128
+
129
+ Relative entries in `workspace_builder_paths` are resolved against the directory
130
+ of the workspace file.
131
+
132
+ ### Check that it works
133
+
134
+ ```bash
135
+ tmuxp load myapp # picker when the repo has 2+ worktrees
136
+ WT_PATH=myrepo-feature-x tmuxp load -d myapp # straight into that worktree, detached
137
+ ```
138
+
139
+ ## Usage
140
+
141
+ - **fzf picker:** Enter starts the highlighted entry (the default is preselected),
142
+ typing filters the list, Esc or Ctrl-C cancels without starting anything.
143
+ `FZF_DEFAULT_OPTS` and `FZF_DEFAULT_OPTS_FILE` are ignored for this picker to
144
+ keep its output format predictable.
145
+ - **Without fzf:** type a number and press Enter, or press Enter for the default.
146
+ Esc, Ctrl-C or Ctrl-D cancels.
147
+ - **No picker:** `WT_PATH=myrepo-feature-x tmuxp load myapp`. A relative
148
+ `WT_PATH` is resolved against the directory that contains the default
149
+ repository; absolute paths and `~/...` work too. An unknown worktree aborts
150
+ with the list of valid choices. A session whose start directory is not in a
151
+ git repository (or that has no `start_directory`) ignores `WT_PATH`, with a
152
+ note.
153
+ - **No terminal** (scripts, cron, CI): the default worktree is used unless
154
+ `WT_PATH` is set.
155
+ - Prunable worktrees (their directory is gone) and bare repositories are not
156
+ offered.
157
+
158
+ ## How it works
159
+
160
+ tmuxp creates plugins after it has expanded the workspace file, but before it
161
+ checks for an existing session and builds the windows. The plugin uses that
162
+ moment to rewrite the expanded configuration: every path inside the default
163
+ repository is moved into the chosen worktree and the session name gets the
164
+ worktree suffix. tmuxp does not pass the configuration to plugins, so the plugin
165
+ finds it in the calling frames; this is verified against tmuxp 1.73, 1.74 and 1.76.
166
+
167
+ When a directory fallback needs to wait for `before_script`, the plugin runs
168
+ setup once through tmuxp's script runner in its `before_workspace_builder` hook,
169
+ then finalizes the window and pane paths. Existing sessions do not rerun setup.
170
+ For this case, tmuxp's script-progress callbacks are bypassed. If using other
171
+ plugins, list this plugin last so their preparation hooks still run before setup.
172
+
173
+ ## Limitations
174
+
175
+ - Absolute paths inside `shell_command` or `shell_command_before` are not
176
+ rewritten; use `start_directory` and relative `cd` commands instead.
177
+ - The session-name suffix comes from the worktree's directory name:
178
+ `myrepo-feature-x` becomes `feature-x`, any other name is used as is. Dots and
179
+ colons become `-`, because tmux does not allow them in session names. If
180
+ multiple worktrees produce the same session name, selecting one aborts with
181
+ a list of the conflicting paths. Rename those worktree directories rather
182
+ than silently reusing another worktree's session.
183
+
184
+ ## Development
185
+
186
+ ```bash
187
+ python3 -m venv .venv
188
+ .venv/bin/pip install -e '.[test]'
189
+ .venv/bin/pytest
190
+ ```
191
+
192
+ The end-to-end tests need `tmux` and `git`. They create throwaway repositories
193
+ and run tmuxp against a private tmux server, so your own sessions are never
194
+ touched. The fzf tests are skipped when fzf is not installed.
195
+
196
+ See [TESTING.md](TESTING.md) for what the tests cover, running a subset or
197
+ another tmuxp version, a manual test checklist and troubleshooting.
198
+
199
+ ## Releasing
200
+
201
+ 1. Bump `__version__` in `src/tmuxp_plugin_worktree.py` and commit.
202
+ 2. Tag and push: `git tag v0.1.0 && git push origin main v0.1.0`.
203
+ 3. The `publish` workflow builds the package and uploads it to PyPI with
204
+ trusted publishing; no API token is needed.
205
+
206
+ ## License
207
+
208
+ [MIT](LICENSE).
@@ -0,0 +1,6 @@
1
+ tmuxp_plugin_worktree.py,sha256=W8Q2fcl0L8Q1Ezb4QzfISPZmKFs6zaoq73o-mutaPhA,16052
2
+ tmuxp_plugin_worktree-0.1.0.dist-info/licenses/LICENSE,sha256=WIxTLAzE5lqOksv3uO_2PVy0RwFr8HSM6N__66keR0U,1072
3
+ tmuxp_plugin_worktree-0.1.0.dist-info/METADATA,sha256=lYp7WyURag4ec-1GMbGLwiWD33wKF7Xuj-Zy84R6PiE,7873
4
+ tmuxp_plugin_worktree-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
5
+ tmuxp_plugin_worktree-0.1.0.dist-info/top_level.txt,sha256=TJxSZEqHNyXOajXebeK4fFkSp8ZyrGiVmt5uRpWj_e8,22
6
+ tmuxp_plugin_worktree-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robbert Helling
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ tmuxp_plugin_worktree
@@ -0,0 +1,425 @@
1
+ """tmuxp-plugin-worktree: pick a git worktree when loading a tmuxp session.
2
+
3
+ Enable it in a workspace file::
4
+
5
+ workspace_builder_paths: # tmuxp >= 1.73; not needed when the plugin is installed
6
+ - ~/Documents/Projects/tmuxp-plugin-worktree/src
7
+ plugins:
8
+ - tmuxp_plugin_worktree.WorktreePlugin
9
+
10
+ The session's ``start_directory`` is the default. When that repository has more
11
+ worktrees, the plugin shows a picker (fzf, or a numbered list without fzf) with
12
+ the default preselected. Picking another worktree moves every session, window
13
+ and pane path into it and renames the session to ``<session>-<worktree>``, so it
14
+ can run next to the default session.
15
+
16
+ Non-interactive: ``WT_PATH=<worktree> tmuxp load <session>``. A relative path is
17
+ resolved against the directory that contains the default repository (for example
18
+ ``WT_PATH=myrepo-feature-x``). Esc or Ctrl-C in the picker cancels the load;
19
+ nothing is started.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import inspect
25
+ import os
26
+ import re
27
+ import shlex
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import termios
32
+ import tty
33
+ from collections.abc import Callable
34
+
35
+ from libtmux import Session
36
+ from tmuxp.plugin import TmuxpPlugin
37
+ from tmuxp.util import run_before_script
38
+
39
+ __version__ = "0.1.0"
40
+
41
+ ENV_VAR = "WT_PATH"
42
+
43
+
44
+ class WorktreePlugin(TmuxpPlugin):
45
+ """Let the user pick the git worktree a session starts in."""
46
+
47
+ def __init__(self) -> None:
48
+ super().__init__(plugin_name="tmuxp-plugin-worktree")
49
+ self._config = _workspace_config()
50
+ self._finish: Callable[[], None] | None = None
51
+ if self._config is None:
52
+ _say("workspace config not found; starting in the default directory")
53
+ return
54
+ self._finish = choose_worktree(self._config)
55
+
56
+ def before_workspace_builder(self, session: Session) -> None:
57
+ if self._config is None or self._finish is None:
58
+ return
59
+ # This hook runs only for new builds, before any configured windows.
60
+ # Take ownership of setup so fallback decisions can follow it.
61
+ try:
62
+ if "before_script" in self._config:
63
+ run_before_script(
64
+ self._config.pop("before_script"),
65
+ cwd=self._config.get("start_directory"),
66
+ )
67
+ self._finish()
68
+ except Exception:
69
+ session.kill()
70
+ raise
71
+ finally:
72
+ self._finish = None
73
+
74
+
75
+ def choose_worktree(config: dict) -> Callable[[], None] | None:
76
+ """Point an (already expanded) tmuxp workspace config at the chosen worktree."""
77
+ wanted = os.environ.get(ENV_VAR)
78
+ start = config.get("start_directory")
79
+ if not isinstance(start, str) or not os.path.isdir(start):
80
+ _ignored(wanted, "the session has no existing start_directory")
81
+ return
82
+ toplevel = _git(start, "rev-parse", "--show-toplevel")
83
+ if not toplevel:
84
+ _ignored(wanted, f"{start} is not in a git repository")
85
+ return
86
+
87
+ worktrees = _worktrees(start)
88
+ default = next((w for w in worktrees if _same(w["path"], toplevel)), None)
89
+ if default is None:
90
+ _ignored(wanted, f"git does not list {toplevel} as a worktree")
91
+ return
92
+ others = sorted((w for w in worktrees if w is not default), key=lambda w: w["path"])
93
+ choices = [default, *others]
94
+
95
+ if wanted:
96
+ chosen = _from_env(wanted, toplevel, choices)
97
+ elif others:
98
+ chosen = _menu(str(config.get("session_name", "")), choices, toplevel)
99
+ else:
100
+ return
101
+ if chosen is default:
102
+ return
103
+
104
+ session = str(config.get("session_name", ""))
105
+ name = _session_name(session, chosen["path"], toplevel)
106
+ conflicts = [
107
+ w for w in choices if w is not chosen
108
+ and (session if w is default else _session_name(session, w["path"], toplevel)) == name
109
+ ]
110
+ if conflicts:
111
+ _say(f"session name {name!r} is ambiguous; rename the conflicting worktree directories:")
112
+ for worktree in [chosen, *conflicts]:
113
+ _say(" " + worktree["path"])
114
+ raise SystemExit(1)
115
+ finish = _rebase_config(config, _config_root(start, toplevel), chosen["path"])
116
+ config["session_name"] = name
117
+ _say(f"{_short(chosen['path'], toplevel)} -> session {config['session_name']}")
118
+ return finish
119
+
120
+
121
+ def _workspace_config() -> dict | None:
122
+ """Return the workspace dict tmuxp is creating this plugin for.
123
+
124
+ tmuxp calls ``load_plugins(session_config)`` after expanding the workspace but
125
+ before checking for an existing session and before building it, so changes to
126
+ that dict (paths, session name) apply to the whole session.
127
+ """
128
+ frame = inspect.currentframe()
129
+ try:
130
+ caller = frame.f_back if frame else None
131
+ for _ in range(20):
132
+ if caller is None:
133
+ break
134
+ for value in list(caller.f_locals.values()):
135
+ if (
136
+ isinstance(value, dict)
137
+ and "session_name" in value
138
+ and isinstance(value.get("windows"), list)
139
+ ):
140
+ return value
141
+ caller = caller.f_back
142
+ finally:
143
+ del frame
144
+ return None
145
+
146
+
147
+ def _git(cwd: str, *args: str) -> str | None:
148
+ try:
149
+ proc = subprocess.run(
150
+ ["git", "-C", cwd, *args], capture_output=True, text=True, check=True
151
+ )
152
+ except (OSError, subprocess.CalledProcessError):
153
+ return None
154
+ return proc.stdout.strip()
155
+
156
+
157
+ def _worktrees(cwd: str) -> list[dict]:
158
+ """Usable worktrees according to ``git worktree list --porcelain``."""
159
+ out = _git(cwd, "worktree", "list", "--porcelain") or ""
160
+ worktrees: list[dict] = []
161
+ current: dict | None = None
162
+ for line in [*out.splitlines(), ""]:
163
+ if line.startswith("worktree "):
164
+ current = {"path": line[9:], "branch": None, "head": "", "usable": True}
165
+ elif current is None:
166
+ continue
167
+ elif line.startswith("HEAD "):
168
+ current["head"] = line[5:]
169
+ elif line.startswith("branch "):
170
+ current["branch"] = line[7:].removeprefix("refs/heads/")
171
+ elif line == "bare" or line.startswith("prunable"):
172
+ current["usable"] = False
173
+ elif not line:
174
+ if current["usable"] and os.path.isdir(current["path"]):
175
+ worktrees.append(current)
176
+ current = None
177
+ return worktrees
178
+
179
+
180
+ def _same(a: str, b: str) -> bool:
181
+ return os.path.realpath(a) == os.path.realpath(b)
182
+
183
+
184
+ def _from_env(wanted: str, toplevel: str, choices: list[dict]) -> dict:
185
+ path = os.path.expanduser(wanted)
186
+ if not os.path.isabs(path):
187
+ path = os.path.join(os.path.dirname(toplevel), path)
188
+ for worktree in choices:
189
+ if _same(worktree["path"], path):
190
+ return worktree
191
+ _say(f"{ENV_VAR}={wanted} is not a worktree of {_short(toplevel, toplevel)}. Choose from:")
192
+ for worktree in choices:
193
+ _say(" " + _short(worktree["path"], toplevel))
194
+ raise SystemExit(1)
195
+
196
+
197
+ def _ignored(wanted: str | None, reason: str) -> None:
198
+ """Tell the user that an explicit WT_PATH could not be applied to this session."""
199
+ if wanted:
200
+ _say(f"{ENV_VAR}={wanted} ignored: {reason}")
201
+
202
+
203
+ def _menu(session: str, choices: list[dict], toplevel: str) -> dict:
204
+ if not _interactive():
205
+ return choices[0]
206
+ labels = [_label(w, toplevel, default=(i == 0)) for i, w in enumerate(choices)]
207
+ fzf = shutil.which("fzf")
208
+ try:
209
+ index = _fzf(fzf, session, labels) if fzf else _numbered(session, labels)
210
+ except KeyboardInterrupt:
211
+ index = None
212
+ if index is None:
213
+ _say("cancelled; nothing was started")
214
+ raise SystemExit(1)
215
+ return choices[index]
216
+
217
+
218
+ def _interactive() -> bool:
219
+ if not (sys.stdin and sys.stdin.isatty()):
220
+ return False
221
+ try:
222
+ with open("/dev/tty"):
223
+ return True
224
+ except OSError:
225
+ return False
226
+
227
+
228
+ def _fzf(fzf: str, session: str, labels: list[str]) -> int | None:
229
+ lines = "".join(f"{i}\t{label}\n" for i, label in enumerate(labels))
230
+ proc = subprocess.run(
231
+ [
232
+ fzf,
233
+ "--height=40%",
234
+ "--layout=reverse",
235
+ "--border=rounded",
236
+ "--no-multi",
237
+ "--delimiter=\t",
238
+ "--with-nth=2..",
239
+ "--prompt=worktree> ",
240
+ f"--header=Session {session}: pick a worktree (Enter = start, Esc = cancel)",
241
+ ],
242
+ input=lines,
243
+ stdout=subprocess.PIPE,
244
+ text=True,
245
+ env={
246
+ key: value for key, value in os.environ.items()
247
+ if key not in {"FZF_DEFAULT_OPTS", "FZF_DEFAULT_OPTS_FILE"}
248
+ },
249
+ )
250
+ if proc.returncode != 0 or not proc.stdout.strip():
251
+ return None
252
+ try:
253
+ index = int(proc.stdout.split("\t", 1)[0])
254
+ except ValueError:
255
+ return None
256
+ return index if 0 <= index < len(labels) else None
257
+
258
+
259
+ def _numbered(session: str, labels: list[str]) -> int | None:
260
+ with open("/dev/tty") as tty_in, open("/dev/tty", "w") as tty_out:
261
+ tty_out.write(f"Session {session}: pick a worktree\n")
262
+ for i, label in enumerate(labels, 1):
263
+ tty_out.write(f" {i}) {label}\n")
264
+ fd = tty_in.fileno()
265
+ previous = termios.tcgetattr(fd)
266
+ try:
267
+ tty.setcbreak(fd, termios.TCSANOW)
268
+ while True:
269
+ tty_out.write(f"Choice [1-{len(labels)}, Enter = 1, Esc = cancel]: ")
270
+ tty_out.flush()
271
+ answer = ""
272
+ while True:
273
+ char = tty_in.read(1)
274
+ if char in ("", "\x04", "\x1b"):
275
+ tty_out.write("\n")
276
+ return None
277
+ if char in ("\r", "\n"):
278
+ tty_out.write("\n")
279
+ answer = answer.strip()
280
+ if not answer:
281
+ return 0
282
+ if answer.isdecimal() and 1 <= int(answer) <= len(labels):
283
+ return int(answer) - 1
284
+ break
285
+ if char in ("\x7f", "\b"):
286
+ if answer:
287
+ answer = answer[:-1]
288
+ tty_out.write("\b \b")
289
+ elif char.isprintable():
290
+ answer += char
291
+ tty_out.write(char)
292
+ tty_out.flush()
293
+ except KeyboardInterrupt:
294
+ tty_out.write("\n")
295
+ raise
296
+ finally:
297
+ termios.tcsetattr(fd, termios.TCSANOW, previous)
298
+
299
+
300
+ def _label(worktree: dict, toplevel: str, default: bool) -> str:
301
+ ref = worktree["branch"] or f"detached {worktree['head'][:9]}".strip()
302
+ text = f"{_short(worktree['path'], toplevel):<40} [{ref}]"
303
+ return f"{text} (default)" if default else text
304
+
305
+
306
+ def _short(path: str, toplevel: str) -> str:
307
+ """Path relative to the directory containing the default repository, else with ~."""
308
+ parent = os.path.dirname(os.path.realpath(toplevel))
309
+ real = os.path.realpath(path)
310
+ if real.startswith(parent + os.sep):
311
+ return os.path.relpath(real, parent)
312
+ home = os.path.expanduser("~")
313
+ return "~" + real[len(home):] if real.startswith(home + os.sep) else real
314
+
315
+
316
+ def _suffix(path: str, toplevel: str) -> str:
317
+ """'myrepo-feature-x' -> 'feature-x'; otherwise the worktree's directory name."""
318
+ name = os.path.basename(os.path.realpath(path))
319
+ main = os.path.basename(os.path.realpath(toplevel))
320
+ match = re.fullmatch(rf"{re.escape(main)}[-_. ]+(.+)", name)
321
+ return match.group(1) if match else name
322
+
323
+
324
+ def _session_name(session: str, path: str, toplevel: str) -> str:
325
+ return re.sub(r"[.:]", "-", f"{session}-{_suffix(path, toplevel)}").strip("-")
326
+
327
+
328
+ def _config_root(start: str, toplevel: str) -> str:
329
+ """The repository root as spelled in the config (start_directory may be a subdirectory)."""
330
+ prefix = (_git(start, "rev-parse", "--show-prefix") or "").strip("/")
331
+ start = start.rstrip(os.sep)
332
+ if not prefix:
333
+ return start
334
+ tail = os.sep + prefix
335
+ return start[: -len(tail)] if start.endswith(tail) else toplevel
336
+
337
+
338
+ def _rebase(path: object, old_root: str, new_root: str) -> object:
339
+ if not isinstance(path, str) or not os.path.isabs(path):
340
+ return path
341
+ real_root, real_path = os.path.realpath(old_root), os.path.realpath(path)
342
+ if os.path.commonpath((real_root, real_path)) != real_root:
343
+ return path
344
+ # Keep internal symlink spellings, except when resolving .. requires following them.
345
+ if os.pardir not in path.split(os.sep):
346
+ normalized = os.path.normpath(path)
347
+ for root in dict.fromkeys((os.path.normpath(old_root), real_root)):
348
+ if normalized == root or normalized.startswith(root + os.sep):
349
+ return os.path.normpath(new_root + normalized[len(root):])
350
+ return os.path.normpath(os.path.join(new_root, os.path.relpath(real_path, real_root)))
351
+
352
+
353
+ def _rebase_dir(path: object, old_root: str, new_root: str, warned: set[str]) -> object:
354
+ """Like ``_rebase``, but a directory the chosen worktree lacks becomes its nearest
355
+ existing parent; tmux would silently start in $HOME instead."""
356
+ rebased = _rebase(path, old_root, new_root)
357
+ # A directory missing from the default worktree too may be created later (for
358
+ # example by before_script); only directories the other branch lost need a fallback.
359
+ if rebased == path or not os.path.isdir(path) or os.path.isdir(rebased):
360
+ return rebased
361
+ fallback = rebased
362
+ while fallback != new_root and not os.path.isdir(fallback):
363
+ fallback = os.path.dirname(fallback)
364
+ if rebased not in warned:
365
+ warned.add(rebased)
366
+ _say(f"{_short(rebased, old_root)} does not exist; using {_short(fallback, old_root)}")
367
+ return fallback
368
+
369
+
370
+ def _rebase_script(command: object, old_root: str, new_root: str) -> object:
371
+ """Like ``_rebase``, but keep the default worktree's script when the chosen one has
372
+ no copy (for example an untracked file); tmuxp aborts when before_script is missing."""
373
+ if not isinstance(command, str):
374
+ return command
375
+ try:
376
+ args = shlex.split(command)
377
+ except ValueError:
378
+ return command
379
+ if not args:
380
+ return command
381
+ script = _rebase(args[0], old_root, new_root)
382
+ if script == args[0]:
383
+ return command
384
+ if os.path.isfile(script):
385
+ args[0] = script
386
+ return shlex.join(args)
387
+ _say(f"{_short(script, old_root)} does not exist; keeping the default worktree's script")
388
+ return command
389
+
390
+
391
+ def _rebase_config(config: dict, old_root: str, new_root: str) -> Callable[[], None] | None:
392
+ """Rebase paths, returning any fallback work that must wait for setup."""
393
+ warned: set[str] = set()
394
+ pending: list[tuple[dict, str]] = []
395
+
396
+ def rebase(holder: dict, defer: bool = False) -> None:
397
+ if "start_directory" in holder:
398
+ path = holder["start_directory"]
399
+ rebased = _rebase(path, old_root, new_root)
400
+ if defer and rebased != path and os.path.isdir(path) and not os.path.isdir(rebased):
401
+ holder["start_directory"] = rebased
402
+ pending.append((holder, path))
403
+ else:
404
+ holder["start_directory"] = _rebase_dir(path, old_root, new_root, warned)
405
+
406
+ rebase(config)
407
+ if "before_script" in config:
408
+ config["before_script"] = _rebase_script(config["before_script"], old_root, new_root)
409
+ for window in config.get("windows") or []:
410
+ if not isinstance(window, dict):
411
+ continue
412
+ rebase(window, defer="before_script" in config)
413
+ for pane in window.get("panes") or []:
414
+ if isinstance(pane, dict):
415
+ rebase(pane, defer="before_script" in config)
416
+
417
+ def finish() -> None:
418
+ for holder, path in pending:
419
+ holder["start_directory"] = _rebase_dir(path, old_root, new_root, warned)
420
+
421
+ return finish if pending else None
422
+
423
+
424
+ def _say(message: str) -> None:
425
+ print(f"[worktree] {message}", file=sys.stderr, flush=True)