Skip to content

Add support for using jj and jj worspaces#71

Draft
sundbp wants to merge 49 commits intoraine:mainfrom
sundbp:main
Draft

Add support for using jj and jj worspaces#71
sundbp wants to merge 49 commits intoraine:mainfrom
sundbp:main

Conversation

@sundbp
Copy link
Copy Markdown

@sundbp sundbp commented Mar 4, 2026

This PR adds support for jj workspaces. I attempted to start a discussion in #59 - making this draft PR as a next step in seeing if there's any interest to support this.

Infonautica and others added 30 commits February 25, 2026 10:36
* docs: add zellij screenshot to docs page

* docs: update zellij screenshot
Retina (2x) screenshots look blurry on non-retina displays because
browser downscaling produces poor results. This adds a Vite plugin that
uses sharp to generate proper half-resolution 1x WebP variants at build
time and injects srcset density descriptors into <img> tags
automatically.

The plugin has two parts:
- A transform hook that adds srcset="/_1x/foo.webp 1x, /foo.webp 2x"
  to all <img> tags with .webp src in markdown files
- A buildStart hook that generates half-size images into public/_1x/
  using sharp's Lanczos resampling

Images with existing srcset or data-no-retina attributes are skipped.
No markdown files need editing -- the transform is fully automatic.
close.rs was deriving mode and constructing MuxHandle from raw CLI input
instead of resolving through git::find_worktree first. When a user ran
'workmux close feature/foo' but the worktree handle was 'api-foo', it
would look up the wrong mode (workmux.worktree.feature/foo.mode instead
of workmux.worktree.api-foo.mode) and construct an incorrect target name
(wm-feature/foo instead of wm-api-foo).

Now find_worktree is called during handle resolution. The actual handle
is extracted from the worktree path basename before mode lookup and
MuxHandle construction. This matches the pattern already used in
workflow::open.

Adds integration test that creates a worktree with --name (custom handle)
and closes it using the branch name to verify resolution works correctly.
When users click on any content image in the docs, the full-size
version now displays in a modal overlay with a dimmed background.
Click outside the image or press Escape to dismiss.

Implementation uses vanilla JS with document-level event delegation,
which handles VitePress SPA route changes automatically without
rebinding listeners. Images are filtered to only zoom .vp-doc content
images, excluding SVGs and linked images. Retina srcset attributes
are preserved in the zoomed view.

Includes smooth open/close CSS animations (fade + scale) and body
scroll lock while the overlay is open.
- Click on zoomed image now closes the overlay (was blocked before,
  creating confusing UX with zoom-out cursor)
- SVG exclusion uses regex to handle query params/hashes in URLs
- Add popstate listener to close overlay on browser back/forward
- Guard against double-close race condition during animation
- Use window sentinel instead of module var for HMR safety
- Don't copy srcset to modal so full-size image always displays
  (on DPR=1 screens, srcset 1x candidate would be half-size)
wait-timeout was listed in Cargo.toml but never imported or used
anywhere in the codebase. Removing dead dependency.
strip-ansi-escapes was used in a single call site (capture.rs).
The console crate, already a dependency, provides strip_ansi_codes
which does the same thing. Removes a redundant dependency.
fs_extra was used in a single file (setup.rs) for copying files and
directories into new worktrees. Replaced with std::fs::copy for files
and a small copy_dir_recursive helper for directories.

The helper properly handles:
- Overwriting existing files/symlinks at the destination
- Preserving symlinks (avoids infinite recursion on symlink loops)
- Skipping special files like sockets/FIFOs (avoids blocking)
Add Apple Container (`container` binary) as a third container runtime
alongside Docker and Podman. This enables sandboxing on macOS using
Apple's native container/VM technology (macOS 26+, Apple Silicon).

Key changes:

- Add `AppleContainer` variant to `SandboxRuntime` with serde name
  `apple-container` (alias `apple`)
- Add capability methods on the enum (`binary_name`, `needs_add_host`,
  `needs_userns_keep_id`, `needs_deny_mode_caps`, `pull_args`,
  `supports_file_mounts`) to replace match blocks throughout
- Auto-detection prefers `container` > `docker` > `podman` on macOS
  (gated behind `cfg!(target_os = "macos")` to avoid false positives)
- Skip freshness checking for Apple Container (no Docker-buildx
  equivalent available)
- Store runtime in StateStore marker files for cleanup correctness,
  so containers are stopped with the correct binary even if config
  changes between start and stop
- `stop_containers_for_handle` groups by stored runtime, no longer
  takes a config parameter
- `list_containers` returns `Vec<(String, SandboxRuntime)>` with
  tolerant parsing (empty marker files default to Docker for backwards
  compatibility)
- `sandbox shell --exec` reads stored runtime from state instead of
  current config
- Apple Container only supports directory mounts (virtiofs), so Claude
  config uses `~/.claude-sandbox-config/claude.json` with a directory
  mount and in-container symlink, while Docker/Podman keep the existing
  `~/.claude-sandbox.json` file mount
- `rpc_host` defaults to `192.168.64.1` for Apple Container
- Apple Container uses `image pull` subcommand (vs `pull` for others)
- Skip `--cap-add=NET_ADMIN` and `--security-opt` in deny mode since
  Apple Container VMs already have full capabilities as root
Add `workmux update` that downloads and installs the latest release
binary from GitHub Releases. Uses curl/tar subprocesses to avoid adding
HTTP or archive dependencies.

The update flow:
- Fetches latest version from GitHub API
- Downloads the platform-appropriate tar.gz and .sha256 checksum
- Verifies SHA-256 integrity before installing
- Replaces the binary atomically with rollback on failure
- Detects Homebrew installs and directs to `brew upgrade` instead

Spinner shows progress through each phase and cleanly reports
success or failure.
On CLI startup, workmux now reads a local cache file to check if a
newer version is available and shows a one-line stderr notice at most
once per 24 hours. A detached background process (`_check-update`)
refreshes the cache by querying the GitHub API, keeping the main
command non-blocking.

Design choices:
- Detached subprocess avoids blocking the main command
- Cache at ~/.cache/workmux/update_check.json prevents repeated API calls
- Numeric version comparison (not string equality) handles 0.1.10 > 0.1.9
- Suppressed in non-interactive contexts (piped stdout/stderr)
- Background curl has 5s connect / 10s max timeout
- Spawn failure doesn't suppress future checks (no silent blackout)

Disable via config (`auto_update_check: false`) or environment
(`WORKMUX_NO_UPDATE_CHECK=1`).
Only check for updates during `workmux add` to avoid unexpected
output on other commands. This is the most common entry point and
keeps the notification surface minimal.
jq is a fundamental CLI tool that hooks and scripts commonly depend
on. Including it in the base image ensures all agent containers
(claude, codex, gemini, opencode) have it available without needing
per-image installs.
Under parallel pytest load (12 workers), tmux pane startup and script
execution can exceed the previous 2-second timeouts. Bump wait_for_file
default from 2.0s to 5.0s and run_workmux_command timeout from 5.0s to
10.0s. Remove explicit timeout=2.0 overrides in test_agents.py so they
inherit the new default.
Allow TUI agents (Claude Code, opencode) inside sandboxed containers/VMs
to paste images via Ctrl+V by proxying clipboard reads to the host.

Built-in wl-paste and xclip shims translate Linux clipboard tool CLIs
into `workmux clipboard-read` calls. A ClipboardRead RPC request triggers
the host to read the clipboard (osascript on macOS, wl-paste/xclip on
Linux), write the PNG to the shared worktree filesystem (.workmux/tmp/),
and return the absolute path. The guest reads the file, writes raw bytes
to stdout, and deletes it. No binary data travels over JSON-lines RPC.

Key details:
- Only image/png supported for v1
- Clipboard shims use separate ClipboardRead RPC, not host-exec Exec RPC
- Shims are regular script files (not symlinks to _shim dispatcher)
- 20 MB size limit, stale file pruning >1 hour
- xclip write mode rejected with error message
Apple Container's `container build` doesn't resolve "." correctly when
the process working directory is set via current_dir. Pass the absolute
context path as a positional argument instead, which works with both
Apple Container and Docker/Podman.
SOPS stores age secret keys and PGP material in ~/.config/sops/.
A compromised guest build file could exfiltrate these via an
allowlisted host command. Add the directory to DENY_READ_DIRS so
both Seatbelt (macOS) and bwrap (Linux) block access.
The retina images plugin's buildStart hook fires async but Vite's dev
server doesn't block module resolution on it. When index.md gets
processed, the transform hook injects /_1x/ image references before
generateRetina1x has finished creating the files, causing import
resolution errors on first run in a new worktree.

Store the generation promise and await it in the transform hook so
markdown processing is blocked until images exist.
raine and others added 19 commits March 1, 2026 21:19
The dashboard rendered tmux style codes like #[fg=#a6e3a1] as literal
text because ratatui uses its own styling system, not tmux's. Meanwhile
the tmux status bar handled them correctly since tmux interprets its own
format codes natively.

Add a tmux style parser (parse_tmux_styles) that converts #[...] blocks
into ratatui styled spans. Change get_status_display() to return
Vec<(String, Style)> instead of (String, Color), matching the pattern
already used by git and PR column formatting.

Supported tmux attributes: fg=, bg=, default/none. Color formats: hex
(#RRGGBB), named colors (red, blue, etc.), indexed (colour0-colour255).
Malformed or unknown directives are handled gracefully.

Closes raine#66
The previous architecture defined _workmux (clap-generated) and
_workmux_dynamic (wrapper), relying on `compdef _workmux_dynamic workmux`
inside the autoloaded function to swap the completion handler. This
reassignment didn't reliably take effect for subsequent completions
when loaded via zsh's fpath autoloading mechanism.

Restructure so the dynamic wrapper IS the _workmux function:

- Post-process clap's zsh output via prepare_zsh_base() to rename
  _workmux -> _workmux_base and strip clap's autoload/eval block
- The dynamic wrapper is now _workmux itself, delegating flag
  completion to _workmux_base and positional args to the dynamic
  helpers (_workmux_handles, _workmux_git_branches)
- _comps[workmux] stays _workmux throughout -- no compdef swap needed

Works correctly with both `eval "$(workmux completions zsh)"` and
file-based fpath autoloading.
Commands like open, remove, merge, add only accept worktree handles or
branch names, never file paths. Previously _workmux_base was called
alongside the dynamic completers, which dragged in _default (file
completion) via clap's generated _arguments positional specs.

Stop calling _workmux_base for positional args on these commands. Flag
completion still delegates to _workmux_base when the current word
starts with -.
\`"${(@f)$(cmd)}"\` on empty command output produces a single-element
array containing an empty string. compadd then offers that empty string
as a completion candidate, resulting in phantom whitespace.

Filter empty strings from the array with \`${handles:#}\` and skip
compadd entirely when there are no candidates.
git worktree prune silently skips locked worktrees. If git worktree add
was interrupted, it leaves a "locked" file (containing "initializing")
in $GIT_COMMON_DIR/worktrees/<name>/ that never gets cleaned up. This
caused wm rm to fail with "cannot delete branch used by worktree"
because prune didn't remove the metadata.

Before pruning, the cleanup now resolves the worktree admin directory
(by reading the .git file's gitdir pointer, with a fallback to the
conventional path) and removes any lock file. This applies to both
the synchronous and deferred cleanup paths. Relative gitdir paths
are normalized to absolute, and parse failures are logged.
Allow users to configure a custom command for LLM-based branch name
generation instead of the hardcoded `llm` CLI. The command string is
parsed with shlex into program + fixed arguments, and the composed
prompt (system prompt + user input) is appended as the final argument
at execution time. For example, `command: "claude -p"` becomes
`claude -p "<prompt>"`.

When `command` is set, the `model` option is ignored since it is
specific to the `llm` CLI. When `command` is absent or empty, behavior
is unchanged and `llm` is used as before.

Implementation splits the monolithic generate_branch_name into a
dispatcher (run_generator_command) that routes to either
run_custom_command or run_llm_command, with shared prompt composition
and branch name sanitization.
When no auto_name.command is configured, automatically use the
configured agent's CLI for branch name generation with a fast/cheap
model. The resolution order is:

1. auto_name.command (explicit config)
2. Agent profile default (claude, gemini, codex, opencode)
3. llm CLI fallback

Agent profile defaults:
- claude: claude --model haiku -p
- gemini: gemini -m gemini-2.5-flash-lite -p
- codex: codex exec --config model_reasoning_effort="low" -m gpt-5.1-codex-mini
- opencode: opencode run

Additional improvements from review feedback:
- auto_name.command is now global-only for security (prevents RCE
  via malicious .workmux.yaml)
- Deep merge for auto_name config (project can override model,
  system_prompt, background but not command)
- Custom commands now pipe prompt via stdin instead of argv
  (avoids E2BIG and process listing exposure)
- Spinner shows which tool is being used ("Generating branch name
  with claude")
- Error messages fall back to stdout when stderr is empty
- Whitespace-only command no longer bypasses agent inference
Introduce src/vcs/ module with:
- Vcs trait encapsulating all VCS operations needed by workmux
- GitVcs implementation delegating to existing git:: functions
- JjVcs stub implementation with todo errors for future phases
- detect_vcs() factory function that walks up from CWD to find .jj or .git
- VcsStatus type alias for GitStatus

This is Phase 1 of jj support: purely additive, no existing behavior changes.
The existing git:: module remains intact and all callers still use it directly.
Replace all direct git::* calls with Vcs trait calls throughout the
codebase. WorkflowContext now holds vcs: Arc<dyn Vcs> and exposes
shared_dir (renamed from git_common_dir). All workflow/ modules use
context.vcs.* methods. All command/ modules detect VCS via
vcs::detect_vcs() or vcs::try_detect_vcs(). cli.rs and config.rs
use VCS-agnostic repo discovery.

Key changes:
- DeferredCleanup stores pre-computed vcs_cleanup_commands instead
  of git_common_dir, keeping it VCS-agnostic
- workflow::list() and agent_resolve functions take &dyn Vcs param
- GitStatus type alias renamed to VcsStatus
- All worktree terminology replaced with workspace in VCS calls
- git::parse_remote_branch_spec kept as git-specific string parsing
Implement the core jj operations needed for workmux add/list/open:

Repo detection:
- get_repo_root/get_repo_root_for via .jj/ directory walking
- get_shared_dir returns repo root (where .jj/ lives)
- has_commits always true (jj has root commit)

Workspace lifecycle:
- create_workspace: jj workspace add + jj bookmark create
- list_workspaces: parse jj workspace list + metadata lookup
- find_workspace: match by handle, bookmark, or workspace name
- prune_workspaces: forget workspaces whose paths no longer exist

Metadata storage:
- set/get_workspace_meta via jj config set/get --repo
- get_all_workspace_modes by parsing .jj/repo/config.toml
- remove_workspace_meta by editing config.toml directly

Branch/bookmark operations:
- get_default_branch: probe main/master bookmarks
- branch_exists via jj bookmark list with template
- get_current_branch via jj log -r @ -T bookmarks
- list_checkout_branches, delete_branch, get_merge_base
- get_unmerged_branches via revset queries

Status operations:
- has_uncommitted_changes via jj diff --stat
- has_staged/unstaged both map to has_uncommitted (no staging area)
- has_untracked_files always false (jj auto-tracks)
- get_status with conflict detection via conflicts() revset

Also implements:
- Base branch tracking via jj config (workmux.base.<branch>)
- Deferred cleanup commands (workspace forget, bookmark delete)
- Unit tests for workspace list and diff stat parsing
Implement the merge/cleanup operations needed for workmux merge/remove:

Merge operations:
- commit_with_editor: jj commit (prompts for description)
- merge_in_workspace: jj new @ <branch> (creates merge commit)
- rebase_onto_base: jj rebase -s @ -d <base>
- merge_squash: jj squash --from <branch> --into @
- switch_branch: jj edit <bookmark>
- reset_hard: jj restore (restores working copy to parent)
- abort_merge: jj undo (undoes last operation)
- stash_push/pop remain no-ops (jj auto-commits working copy)
Implement remote operations for jj repos with git backend:

Remote operations:
- list_remotes: jj git remote list (parse name/url pairs)
- remote_exists, add_remote: jj git remote add
- set_remote_url: remove + re-add (jj has no set-url)
- get_remote_url: parse jj git remote list output
- fetch_remote: jj git fetch --remote <name>
- fetch_prune: jj git fetch (auto-prunes)
- ensure_fork_remote: construct fork URL from origin, reuses
  git-url-parse for URL manipulation
- get_repo_owner: parse owner from origin remote URL

Helper functions:
- parse_owner_from_url: extract owner from HTTPS/SSH git URLs
- construct_fork_url: build fork URL preserving host and protocol
Add comprehensive unit tests for jj VCS implementation:

Parser tests:
- Workspace list parsing (single, multiple, empty, descriptions,
  hyphenated names)
- Diff stat totals parsing (full, insertions-only, deletions-only,
  empty, single file, no changes)

URL parsing tests:
- HTTPS, SSH, HTTP formats
- GitHub Enterprise domains
- Invalid URLs, local paths

Cleanup command tests:
- Full cleanup (workspace forget + bookmark delete + config unsets)
- Keep-branch mode (no bookmark delete)
- Special characters in paths (shell quoting)

Metadata tests:
- TOML config parsing for get_all_workspace_modes
- Verifies section header matching and mode extraction

Behavioral tests:
- VCS name returns "jj"
- has_commits always returns true (jj has root commit)
- stash_push/pop are no-ops (jj auto-commits)
- has_untracked_files always false (jj auto-tracks)

26 jj-specific tests total.
Add jj workspace mentions alongside git worktree references across all
docs, skills, and README. workmux now supports both Git and jj as VCS
backends via the Vcs trait, so the documentation should reflect this.

Key changes:
- Add jj as alternative VCS in taglines, descriptions, and requirements
- Rename "Git worktree caveats" page to "Worktree caveats" with
  git-specific sections marked
- Add VCS detection step (Step 0) to merge and rebase skills
- Add jj command equivalents to merge strategies and remove --gone flag
- Broaden VCS-specific wording (e.g., "gitignored" → "ignored")
git worktree prune silently skips locked worktrees. If git worktree add
was interrupted, it leaves a "locked" file in $GIT_COMMON_DIR/worktrees/<name>/
that prevents prune from cleaning up the metadata, causing "cannot delete
branch used by worktree" errors during cleanup.

Add resolve_worktree_admin_dir() to GitVcs which reads the worktree's
.git file to find the admin directory (with fallback to the conventional
path), then:

- remove_workspace_lock(): resolves admin dir and removes the lock file
  before pruning in the synchronous cleanup path
- build_cleanup_commands(): includes an rm -f <locked> command before
  the prune command in deferred cleanup scripts

Both methods are no-ops in JjVcs since jj has no equivalent lock
mechanism.
@raine
Copy link
Copy Markdown
Owner

raine commented Mar 5, 2026

Thanks for the PR.

if there's any interest to support this

Maybe, eventually as an additional VCS backend alternative to git, with a single new documentation page covering it.

It's far too niche to be woven into every existing documentation page. This PR touches 40+ files and adds jj mentions, parenthetical alternatives, and terminology changes throughout the entire docs, README, and skill files. That level of interleaving dilutes the clarity of the existing documentation for the 99% of users who use git.

If support were to land, I'd prefer it to be contained to a single dedicated docs page (e.g., "Using workmux with jj") covering setup, differences, and caveats, with at most a brief mention and link from the main docs. The core documentation should remain git-focused. Same way as tmux is the primary multiplexer backend.

@raine raine force-pushed the main branch 2 times, most recently from a657335 to a475cb1 Compare April 5, 2026 11:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants