Skip to content

Commit 7c43fe7

Browse files
committed
v1
1 parent db398b2 commit 7c43fe7

4 files changed

Lines changed: 244 additions & 22 deletions

File tree

dot_files/devcube/devcube-session.sh

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,54 @@
1515
# interpreted as "add these tabs to the (not-yet-existing) session" and zellij
1616
# errors with "There is no active session!". The flag that always starts a fresh
1717
# named session with a layout is --new-session-with-layout, used below.
18+
#
19+
# Recovering from an unsaved exit. The home volume (/root) outlives any single
20+
# `--rm` container, but the live zellij socket lives in the container's
21+
# ephemeral /tmp. If a devcube is torn down WITHOUT going through Ctrl+Space q
22+
# (the terminal/container is just killed), the socket dies with the container
23+
# while a stale scrap of session state can linger on the persisted volume. zellij
24+
# then refuses to reopen that name -- "session ... already exists, but is dead" --
25+
# and the next `devc` wedges instead of recovering. So when no usable session of
26+
# this name is left to attach to, we clear any dead remnant before creating a
27+
# fresh one (see below). Cleanly saved sessions still resurrect; only the wedged
28+
# leftovers get cleared.
1829
set -euo pipefail
1930

2031
session="$1"
2132
layout="${2:-}"
2233

23-
# A session of this name already exists (running or serialized/resurrectable)?
24-
# `list-sessions` prints the name as the first field for both; awk matches it
25-
# exactly. With no sessions it errors to stderr (suppressed) and prints nothing.
26-
if zellij list-sessions --no-formatting 2>/dev/null |
27-
awk -v s="$session" '$1 == s { found = 1 } END { exit !found }'; then
28-
exec zellij attach "$session"
29-
elif [ -n "$layout" ]; then
30-
exec zellij --session "$session" --new-session-with-layout "$layout"
34+
# The zellij binary, overridable so the logic can be exercised by tests with a
35+
# stub on this seam (see scripts/devcube/test-devcube-session.sh). Defaults to
36+
# the real `zellij` on PATH in the container.
37+
zellij_bin="${ZELLIJ_BIN:-zellij}"
38+
39+
# Does a session of this name currently exist that we can attach to -- either
40+
# running, or serialized/resurrectable (left by Ctrl+Space q -> Save, or by the
41+
# periodic snapshot of a session we never cleanly closed)? `list-sessions` prints
42+
# the name as the first field for both kinds, tagging the resurrectable ones; awk
43+
# matches the name exactly. With no sessions it errors to stderr (suppressed) and
44+
# prints nothing, so awk finds no match.
45+
session_attachable() {
46+
"$zellij_bin" list-sessions --no-formatting 2>/dev/null |
47+
awk -v s="$session" '$1 == s { found = 1 } END { exit !found }'
48+
}
49+
50+
if session_attachable; then
51+
# Running -> reattach; serialized -> resurrect. `attach` does both, restoring
52+
# the saved tabs / panes / worktrees.
53+
exec "$zellij_bin" attach "$session"
54+
fi
55+
56+
# Nothing attachable by this name. A session killed uncleanly (container torn
57+
# down without Ctrl+Space q) can still leave a *dead* remnant that list-sessions
58+
# won't surface yet `zellij --session NAME` trips over with "already exists, but
59+
# is dead" -- the wedged state that breaks the next `devc`. Clear any such remnant
60+
# first; delete-session is a harmless no-op (non-zero, swallowed) when there's
61+
# nothing to remove, so a truly first-run session is unaffected.
62+
"$zellij_bin" delete-session "$session" --force >/dev/null 2>&1 || true
63+
64+
if [ -n "$layout" ]; then
65+
exec "$zellij_bin" --session "$session" --new-session-with-layout "$layout"
3166
else
32-
exec zellij --session "$session"
67+
exec "$zellij_bin" --session "$session"
3368
fi

dot_files/devcube/scripts/devcube.sh

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020
# zellij/workmux run the parallel-agent flow: each `workmux add <branch>`
2121
# creates a git worktree on a PER-PROJECT named volume mounted at /worktrees,
2222
# so worktrees (and workmux's state) persist across restarts and never collide
23-
# with another project's. The project mount is the git repo root (discovered from
24-
# the launch dir), or the launch dir itself when not in a repo; workmux requires
25-
# a repo, the other tools don't. Plus your personal overrides and a few host
26-
# facts (git identity, SSH agent, terminal). Portable across Linux and macOS.
23+
# with another project's. The project itself (the git repo root, discovered from
24+
# the launch dir, or the launch dir when not in a repo) is mounted at /workspace
25+
# -- a fixed, branch-independent path that's kept out of /worktrees so it can
26+
# never collide with a /worktrees/<branch> linked worktree. The direct tools
27+
# instead get the project at its host path. workmux requires a repo, the other
28+
# tools don't. Plus your personal overrides and a few host facts (git identity,
29+
# SSH agent, terminal). Portable across Linux and macOS.
2730
#
2831
# Env overrides:
2932
# DEVCUBE_IMAGE container image (default ghcr.io/binarypie-dev/devcube:latest)
@@ -67,6 +70,11 @@ if [ "$TOOL" = workmux ] && [ -z "$git_root" ]; then
6770
exit 1
6871
fi
6972

73+
# Where the project shows up inside the container. By default it's mounted at
74+
# its own host path (identity) so file paths line up on both sides. The
75+
# orchestrators override this below.
76+
mount_target="$project_dir"
77+
7078
# Default: run the tool with only the project mounted. The orchestrators
7179
# (zellij/workmux) additionally get a per-project worktree volume + the zellij
7280
# backend, and run inside a zellij session.
@@ -87,6 +95,11 @@ zellij | workmux)
8795
# project -> include phash. zellij session names can't contain '.', so the
8896
# slug's dots are squashed to '-' (the volume name keeps them; it's separate).
8997
session="devcube-$(printf '%s' "$slug" | tr '.' '-')-${phash}"
98+
# Mount the project at a fixed /workspace -- a stable, branch-independent path
99+
# regardless of which branch the host checkout is on -- kept separate from the
100+
# /worktrees volume so it never collides with a workmux /worktrees/<branch>
101+
# linked worktree.
102+
mount_target="/workspace"
90103
extra=(
91104
# Worktrees live here (podman auto-creates the volume on first mount).
92105
-v "${wt_volume}:/worktrees:rw"
@@ -122,12 +135,13 @@ args=(
122135
# /root on first run (podman copy-up); never use a fixed container --name so
123136
# multiple sessions can run concurrently.
124137
-v "${VOLUME}:/root"
125-
# The project -- the git repo root, or the launch dir when not in a repo --
126-
# at the same path inside the container.
127-
-v "${project_dir}:${project_dir}:rw"
138+
# The project -- the git repo root, or the launch dir when not in a repo.
139+
# Mounted at its host path for the direct tools, or at /workspace for the
140+
# orchestrators (see mount_target above).
141+
-v "${project_dir}:${mount_target}:rw"
128142
# Personal plugin overrides (nested over the home volume).
129143
-v "${OVERRIDE_DIR}:/root/.config/hypercube/nvim:rw"
130-
-w "${project_dir}"
144+
-w "${mount_target}"
131145
)
132146

133147
# Read-only git identity (name/email/aliases) from the host.

dot_files/ghostty/config

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
# Hypercube Ghostty configuration.
22
#
3-
# Ghostty is a plain, chrome-less renderer. Multiplexing — tabs, splits, panes —
4-
# is owned by zellij (leader Ctrl+Space; see KEYBINDINGS.md and
5-
# dot_files/zellij/config.kdl, #198), so Ghostty defines no multiplexer leader.
3+
# Ghostty is a plain, chrome-less renderer. Splits and panes are owned by zellij
4+
# (leader Ctrl+Space; see KEYBINDINGS.md and dot_files/zellij/config.kdl, #198),
5+
# so Ghostty defines no multiplexer leader. Ghostty DOES keep native tabs, though:
6+
# each `devc` is its own container with its own in-container zellij, so to work
7+
# across several repos at once (microservices) you want OS-level tabs holding
8+
# separate containers — zellij tabs can't span them. See KEYBINDINGS below.
69

710
# ----------------------------------------------------------------
811
# APPLICATION - Behavior and Configuraiton
@@ -83,8 +86,8 @@ palette = 15=#c8d3f5
8386
# ----------------------------------------------------------------
8487
# KEYBINDINGS
8588
# ----------------------------------------------------------------
86-
# Tabs / splits / panes are owned by zellij (leader Ctrl+Space). Ghostty keeps
87-
# no multiplexer leader, so its keys never shadow what zellij or a focused agent
89+
# Splits / panes are owned by zellij (leader Ctrl+Space). Ghostty keeps no
90+
# multiplexer leader, so its keys never shadow what zellij or a focused agent
8891
# needs — notably, dropping the old Ctrl+a prefix frees Ctrl+a as readline
8992
# "beginning of line" when you run `devc claude` in a plain window.
9093
#
@@ -93,3 +96,16 @@ palette = 15=#c8d3f5
9396
# built-in copy/paste — Ctrl+Shift+C/V — and scrollback stay at their defaults.)
9497
keybind = ctrl+shift+n=new_window
9598
keybind = ctrl+shift+comma=reload_config
99+
100+
# Native tabs: each tab is its own host shell, so you can run a separate `devc`
101+
# (separate container, separate repo) per tab and switch between microservices.
102+
# These are Ghostty defaults made explicit here so they survive any future
103+
# `keybind = clear`. Ctrl+Shift chords don't collide with zellij's Ctrl+Space
104+
# leader, readline, or the agent CLIs. Splits/panes stay with zellij — no
105+
# new_split binding by design. (Tabs render along the bottom; see
106+
# gtk-tabs-location above.)
107+
keybind = ctrl+shift+t=new_tab
108+
keybind = ctrl+shift+right=next_tab
109+
keybind = ctrl+shift+left=previous_tab
110+
keybind = ctrl+shift+o=toggle_tab_overview
111+
keybind = ctrl+shift+w=close_surface
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
#!/bin/bash
2+
# Test suite for devcube-session.sh — the attach-or-create zellij launcher that
3+
# the `devc` wrapper runs inside the container.
4+
#
5+
# The script execs the real `zellij` binary, so to drive its branches in
6+
# isolation we point ZELLIJ_BIN at a stub. The stub:
7+
#
8+
# * answers `list-sessions` from the FAKE_SESSIONS env var (one session name
9+
# per line; empty -> behaves like zellij with no sessions: error to stderr,
10+
# non-zero exit), and
11+
# * records every other invocation (attach / delete-session / --session ...)
12+
# to FAKE_LOG, one argv per line, so a test can assert exactly what the
13+
# script asked zellij to do.
14+
#
15+
# This lets us cover the three states that matter: a saved/attachable session
16+
# (Save), a deleted/absent session (Discard -> create fresh), and a wedged dead
17+
# remnant left by an unsaved exit (clear it, then create fresh).
18+
19+
set -euo pipefail
20+
21+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22+
SCRIPT="$SCRIPT_DIR/../../dot_files/devcube/devcube-session.sh"
23+
24+
RED='\033[0;31m'
25+
GREEN='\033[0;32m'
26+
NC='\033[0m'
27+
28+
test_count=0
29+
pass_count=0
30+
31+
WORK="$(mktemp -d)"
32+
trap 'rm -rf "$WORK"' EXIT
33+
34+
# A stub `zellij` on ZELLIJ_BIN. `list-sessions` is sourced from FAKE_SESSIONS;
35+
# the "already exists, but is dead" wedge is modelled by FAKE_DEAD: while it's
36+
# set, a create (`--session`) fails like real zellij until delete-session clears
37+
# it (the stub removes the FAKE_DEAD marker file when asked to delete).
38+
STUB="$WORK/zellij"
39+
cat >"$STUB" <<'STUB_EOF'
40+
#!/bin/bash
41+
log() { printf '%s\n' "$*" >>"$FAKE_LOG"; }
42+
43+
case "$1" in
44+
list-sessions)
45+
if [ -z "${FAKE_SESSIONS:-}" ]; then
46+
echo "No active zellij sessions found." >&2
47+
exit 1
48+
fi
49+
# Mimic `--no-formatting`: "<name> [Created ...]", name in the first field.
50+
while IFS= read -r s; do
51+
[ -n "$s" ] && printf '%s [Created 1s ago]\n' "$s"
52+
done <<<"$FAKE_SESSIONS"
53+
;;
54+
delete-session)
55+
log "$*"
56+
# Clearing the wedge: drop the dead-remnant marker if present.
57+
[ -n "${FAKE_DEAD:-}" ] && rm -f "$FAKE_DEAD"
58+
;;
59+
attach)
60+
log "$*"
61+
;;
62+
--session)
63+
log "$*"
64+
# A live dead remnant makes `zellij --session` refuse to start.
65+
if [ -n "${FAKE_DEAD:-}" ] && [ -e "$FAKE_DEAD" ]; then
66+
echo "Session with name $2 already exists, but is dead." >&2
67+
exit 1
68+
fi
69+
;;
70+
*)
71+
log "$*"
72+
;;
73+
esac
74+
STUB_EOF
75+
chmod +x "$STUB"
76+
77+
# Run devcube-session.sh with the stub. Args: session [layout].
78+
# Env in: FAKE_SESSIONS (newline list), FAKE_DEAD (marker file path or empty).
79+
# Sets globals: RUN_RC, RUN_LOG (path to the recorded argv log).
80+
run_script() {
81+
FAKE_LOG="$WORK/log.$test_count"
82+
: >"$FAKE_LOG"
83+
set +e
84+
ZELLIJ_BIN="$STUB" FAKE_LOG="$FAKE_LOG" \
85+
FAKE_SESSIONS="${FAKE_SESSIONS:-}" FAKE_DEAD="${FAKE_DEAD:-}" \
86+
bash "$SCRIPT" "$@" >/dev/null 2>&1
87+
RUN_RC=$?
88+
set -e
89+
RUN_LOG="$FAKE_LOG"
90+
}
91+
92+
# Assert the recorded log matches the expected argv lines exactly.
93+
check() {
94+
local test_name=$1
95+
local expected=$2
96+
test_count=$((test_count + 1))
97+
local actual
98+
actual="$(cat "$RUN_LOG")"
99+
if [[ "$RUN_RC" -eq 0 && "$actual" == "$expected" ]]; then
100+
printf "Test $test_count: $test_name\n ${GREEN}✓ PASS${NC}\n\n"
101+
pass_count=$((pass_count + 1))
102+
else
103+
printf "Test $test_count: $test_name\n"
104+
printf " ${RED}✗ FAIL${NC} (rc=$RUN_RC)\n"
105+
printf " expected: %q\n" "$expected"
106+
printf " actual: %q\n\n" "$actual"
107+
fi
108+
}
109+
110+
echo "Running tests for devcube-session.sh"
111+
echo "=========================================="
112+
echo ""
113+
114+
# 1. Saved session present -> resurrect/attach it, never create. (Save path.)
115+
FAKE_SESSIONS="devcube-proj-123" FAKE_DEAD="" run_script "devcube-proj-123" "workmux"
116+
check "attaches an existing (saved/resurrectable) session" \
117+
"attach devcube-proj-123"
118+
119+
# 2. No session at all (e.g. Discard deleted it) with a layout -> create fresh
120+
# with the layout. A stray delete-session first is fine (clears nothing).
121+
FAKE_SESSIONS="" FAKE_DEAD="" run_script "devcube-proj-123" "workmux"
122+
check "creates fresh with layout when no session exists" \
123+
"$(printf 'delete-session devcube-proj-123 --force\n--session devcube-proj-123 --new-session-with-layout workmux')"
124+
125+
# 3. No session and no layout -> create a fresh plain named session.
126+
FAKE_SESSIONS="" FAKE_DEAD="" run_script "devcube-proj-123"
127+
check "creates fresh plain session when no session and no layout" \
128+
"$(printf 'delete-session devcube-proj-123 --force\n--session devcube-proj-123')"
129+
130+
# 4. Unsaved exit left a wedged dead remnant: list-sessions doesn't surface it,
131+
# and a naive create would fail with "already exists, but is dead". The script
132+
# must clear it first, then create fresh and succeed. (The regression fix.)
133+
DEAD_MARK="$WORK/dead.marker"
134+
: >"$DEAD_MARK"
135+
FAKE_SESSIONS="" FAKE_DEAD="$DEAD_MARK" run_script "devcube-proj-123" "workmux"
136+
check "clears a dead remnant then creates fresh (unsaved-exit recovery)" \
137+
"$(printf 'delete-session devcube-proj-123 --force\n--session devcube-proj-123 --new-session-with-layout workmux')"
138+
139+
# 5. A different session is running, but not ours -> we don't attach to it; we
140+
# create our own. Guards against a loose substring match in list-sessions.
141+
FAKE_SESSIONS="$(printf 'some-other-session\ndevcube-proj-999')" FAKE_DEAD="" \
142+
run_script "devcube-proj-123" "workmux"
143+
check "ignores unrelated sessions and creates our own" \
144+
"$(printf 'delete-session devcube-proj-123 --force\n--session devcube-proj-123 --new-session-with-layout workmux')"
145+
146+
# Summary
147+
echo "=========================================="
148+
echo "Test Results: $pass_count/$test_count passed"
149+
echo "=========================================="
150+
151+
if [[ $pass_count -eq $test_count ]]; then
152+
printf "${GREEN}All tests passed!${NC}\n"
153+
exit 0
154+
else
155+
printf "${RED}Some tests failed${NC}\n"
156+
exit 1
157+
fi

0 commit comments

Comments
 (0)