From 685272399d7dbc35aea6028d6741963399d84e3f Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 19 May 2026 13:01:19 -0500 Subject: feat(tmux-util): add go subcommand (attach-or-create) tmux-util go attaches to a session named if it exists, creates it otherwise. Behavior depends on whether the caller is already inside tmux: - Outside tmux: `tmux attach-session -t ` (existing) or `tmux new-session -s -c $PWD` (new). - Inside tmux (TMUX env set): `tmux switch-client -t ` (existing) or `tmux new-session -d -s -c $PWD` followed by `switch-client` (new). Attaching from inside tmux would nest sessions and break the outer view, so the inside path uses switch-client instead. The existence check uses `tmux has-session -t =` with the leading `=` to force exact-match. Without it, tmux does prefix matching, which would let `go foo` resolve to a session named `foobar`. I added 6 new tests covering both inside/outside-tmux paths, both create/attach paths, plus error handling for missing or empty name arguments. fake-tmux picked up handlers for new-session (mutates state), attach-session and switch-client (record-only), and the `=`-prefix form of has-session. Total suite: 32 tests, all green. --- dotfiles/common/.local/bin/tmux-util | 36 +++++++++++- tests/tmux-util/fake-tmux | 21 +++++++ tests/tmux-util/test_tmux_util.py | 103 +++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) diff --git a/dotfiles/common/.local/bin/tmux-util b/dotfiles/common/.local/bin/tmux-util index 2fa861d..4a7b596 100755 --- a/dotfiles/common/.local/bin/tmux-util +++ b/dotfiles/common/.local/bin/tmux-util @@ -35,6 +35,37 @@ Run with no arguments to print this help. EOF } +# ----------------------------------------------------------------------------- +# go +# ----------------------------------------------------------------------------- + +cmd_go() { + local name="${1:-}" + if [ -z "$name" ]; then + echo "tmux-util go: missing session name" >&2 + echo "Usage: tmux-util go " >&2 + return 2 + fi + + # `=name` forces exact match in `tmux has-session` (otherwise tmux does + # prefix matching, which would let `go foo` attach to a session named + # `foobar`). + if tmux has-session -t "=$name" 2>/dev/null; then + if [ -n "${TMUX:-}" ]; then + tmux switch-client -t "$name" + else + tmux attach-session -t "$name" + fi + else + if [ -n "${TMUX:-}" ]; then + tmux new-session -d -s "$name" -c "$PWD" + tmux switch-client -t "$name" + else + tmux new-session -s "$name" -c "$PWD" + fi + fi +} + # ----------------------------------------------------------------------------- # ls # ----------------------------------------------------------------------------- @@ -144,7 +175,10 @@ main() { ls) cmd_ls "$@" ;; - go|pick|find|rename) + go) + cmd_go "$@" + ;; + pick|find|rename) echo "tmux-util: subcommand '$sub' is not implemented yet" >&2 return 2 ;; diff --git a/tests/tmux-util/fake-tmux b/tests/tmux-util/fake-tmux index ba2d5cc..163ea24 100755 --- a/tests/tmux-util/fake-tmux +++ b/tests/tmux-util/fake-tmux @@ -97,6 +97,8 @@ case "$cmd" in *) shift ;; esac done + # tmux accepts a `=name` form to force exact match; strip the prefix. + session="${session#=}" while IFS=' ' read -r name attached pids _rest; do if [ "$name" = "$session" ]; then exit 0 @@ -104,6 +106,25 @@ case "$cmd" in done < "$STATE" exit 1 ;; + new-session) + detached=0 + name="" + cwd="" + while [ "$#" -gt 0 ]; do + case "$1" in + -s) shift; name="$1"; shift ;; + -c) shift; cwd="$1"; shift ;; + -d) detached=1; shift ;; + *) shift ;; + esac + done + attached=1 + [ "$detached" -eq 1 ] && attached=0 + printf '%s %s - 0 1 %s\n' "$name" "$attached" "${cwd:-/tmp}" >> "$STATE" + ;; + attach-session|switch-client) + # No state mutation needed — the call log already records intent. + ;; kill-session) session="" while [ "$#" -gt 0 ]; do diff --git a/tests/tmux-util/test_tmux_util.py b/tests/tmux-util/test_tmux_util.py index 283c3c7..24cc335 100644 --- a/tests/tmux-util/test_tmux_util.py +++ b/tests/tmux-util/test_tmux_util.py @@ -374,5 +374,108 @@ class TestLsBoundary(TmuxUtilHarness): self.assertNotIn(cwd_under_home, result.stdout) +# ----------------------------------------------------------------------------- +# go — Normal cases +# ----------------------------------------------------------------------------- + +class TestGoNormal(TmuxUtilHarness): + + def test_go_existing_session_outside_tmux_attaches(self): + self.set_sessions([ + ("work", 0, [201], 900, 1, "/tmp/work"), + ]) + # Outside tmux: TMUX env should be unset. + env = {k: v for k, v in os.environ.items() if k != "TMUX"} + result = subprocess.run( + [SCRIPT, "go", "work"], + env={**env, "PATH": self.bin_dir + os.pathsep + env.get("PATH", ""), + "FAKE_TMUX_DIR": self.tmp}, + capture_output=True, text=True, timeout=10, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr) + calls = self.tmux_calls() + # has-session should be the first existence check (exact-match form) + self.assertTrue(any("has-session" in c and "work" in c for c in calls), + f"expected has-session probe in {calls!r}") + self.assertTrue(any("attach-session -t work" in c for c in calls), + f"expected attach-session -t work in {calls!r}") + # Must NOT create a new session + self.assertFalse(any("new-session" in c for c in calls), + f"unexpected new-session in {calls!r}") + + def test_go_existing_session_inside_tmux_switches_client(self): + self.set_sessions([ + ("work", 0, [201], 900, 1, "/tmp/work"), + ]) + result = self.run_script("go", "work", env_extra={"TMUX": "/tmp/fake-tmux-socket,1234,0"}) + self.assertEqual(result.returncode, 0, msg=result.stderr) + calls = self.tmux_calls() + self.assertTrue(any("switch-client -t work" in c for c in calls), + f"expected switch-client in {calls!r}") + # attach-session shouldn't be called inside tmux + self.assertFalse(any("attach-session" in c for c in calls), + f"unexpected attach-session in {calls!r}") + + def test_go_missing_session_outside_tmux_creates(self): + self.set_sessions([]) + env = {k: v for k, v in os.environ.items() if k != "TMUX"} + result = subprocess.run( + [SCRIPT, "go", "fresh"], + env={**env, "PATH": self.bin_dir + os.pathsep + env.get("PATH", ""), + "FAKE_TMUX_DIR": self.tmp}, + cwd="/tmp", + capture_output=True, text=True, timeout=10, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr) + calls = self.tmux_calls() + # new-session with -s and -c pointing at the subprocess's cwd + self.assertTrue( + any("new-session" in c and "-s fresh" in c and "-c /tmp" in c + for c in calls), + f"expected new-session -s fresh -c /tmp in {calls!r}", + ) + + def test_go_missing_session_inside_tmux_creates_detached_then_switches(self): + self.set_sessions([]) + env = os.environ.copy() + env.update({ + "PATH": self.bin_dir + os.pathsep + env.get("PATH", ""), + "FAKE_TMUX_DIR": self.tmp, + "TMUX": "/tmp/fake-tmux-socket,1234,0", + }) + result = subprocess.run( + [SCRIPT, "go", "fresh"], + env=env, + cwd="/tmp", + capture_output=True, text=True, timeout=10, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr) + calls = self.tmux_calls() + self.assertTrue( + any("new-session" in c and "-d" in c and "-s fresh" in c and "-c /tmp" in c + for c in calls), + f"expected detached new-session in {calls!r}", + ) + self.assertTrue(any("switch-client -t fresh" in c for c in calls), + f"expected switch-client after create in {calls!r}") + + +# ----------------------------------------------------------------------------- +# go — Error / Boundary cases +# ----------------------------------------------------------------------------- + +class TestGoErrors(TmuxUtilHarness): + + def test_go_with_no_name_exits_nonzero(self): + result = self.run_script("go") + self.assertNotEqual(result.returncode, 0) + self.assertIn("missing session name", result.stderr) + + def test_go_with_empty_string_name_exits_nonzero(self): + result = self.run_script("go", "") + self.assertNotEqual(result.returncode, 0) + self.assertIn("missing session name", result.stderr) + + if __name__ == "__main__": unittest.main() -- cgit v1.2.3