Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ jobs:
files: |
cmux.sh
VERSION
NOTES.md
install.sh
18 changes: 18 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Tests

on:
pull_request:
push:
branches: [main]

jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Run tests (system bash)
run: /bin/bash tests/run.sh
14 changes: 14 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Release notes

Add a new `## X.Y.Z` section at the top for each release. `cmux` reads this
file to show what's new after an update and in `cmux version`.

## 0.1.4
- Release notes: run "cmux version" anytime, and the first run after an update shows what's new
- `cmux new` now branches from the repo's default branch, with a new `--from` flag to override
- `cmux ls` shows merge status per worktree (`[merged]` / `[ahead N]`)
- `cmux new` and `cmux start` accept `-p <prompt>` to seed Claude with an initial prompt
- Pass extra flags to the Claude CLI via `--` (e.g. `cmux new foo -- --model sonnet`)
- New `.cmux/teardown` hook runs when a worktree is removed
- Configurable worktree layouts: `nested` (default), `outer-nested`, `sibling` — see `cmux config`
- Fixed: repo-root resolution no longer triggers direnv
63 changes: 59 additions & 4 deletions cmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# cmux init [--replace] — Generate .cmux/setup hook using Claude
# cmux config — View or set worktree layout configuration
# cmux update — Update cmux to the latest version
# cmux version — Show current version
# cmux version — Show current version and release notes

_CMUX_DOWNLOAD_URL="https://github.com/craigsc/cmux/releases/latest/download"
CMUX_VERSION="unknown"
Expand All @@ -25,6 +25,7 @@ cmux() {
shift 2>/dev/null

_cmux_check_update
_cmux_show_update_notes

case "$cmd" in
new) _cmux_new "$@" ;;
Expand All @@ -36,9 +37,9 @@ cmux() {
init) _cmux_init "$@" ;;
config) _cmux_config "$@" ;;
update) _cmux_update "$@" ;;
version) echo "cmux $CMUX_VERSION" ;;
version) _cmux_version "$@" ;;
--help|-h|"")
echo "Usage: cmux <new|start|cd|ls|merge|rm|init|config|update> [branch]"
echo "Usage: cmux <new|start|cd|ls|merge|rm|init|config|update|version> [branch]"
echo ""
echo " new <branch> [--from <base>] [-p <prompt>] [-- <claude-args>]"
echo " New worktree + branch, run setup hook, launch Claude"
Expand All @@ -52,7 +53,7 @@ cmux() {
echo " init [--replace] Generate .cmux/setup hook using Claude"
echo " config View or set worktree layout configuration"
echo " update Update cmux to the latest version"
echo " version Show current version"
echo " version Show current version and release notes"
return 0
;;
*)
Expand Down Expand Up @@ -252,6 +253,59 @@ _cmux_check_update() {
disown 2>/dev/null
}

# Extract the current version's section from NOTES.md (sectioned by "## X.Y.Z").
_cmux_version_notes() {
local notes_file="$HOME/.cmux/NOTES.md"
[[ -f "$notes_file" ]] || return 1
local output
output="$(awk -v hdr="## $CMUX_VERSION" '
$0 == hdr { in_section = 1; next }
/^## / { in_section = 0 }
in_section { print }
' "$notes_file")"
[[ -z "$output" ]] && return 1
printf '%s' "$output"
}

# Silent on first run (no seen file yet) so fresh installs and pre-existing
# users upgrading into this feature don't get a surprise blast of notes.
_cmux_show_update_notes() {
local seen_file="$HOME/.cmux/.last_seen_version"
local last_seen=""
[[ -f "$seen_file" ]] && last_seen="$(<"$seen_file")"

if [[ -z "$last_seen" ]]; then
printf '%s' "$CMUX_VERSION" > "$seen_file"
return 0
fi

[[ "$last_seen" == "$CMUX_VERSION" ]] && return 0

local notes
if notes="$(_cmux_version_notes 2>/dev/null)"; then
echo ""
echo "cmux $CMUX_VERSION — what's new:"
echo "$notes"
echo ""
fi
printf '%s' "$CMUX_VERSION" > "$seen_file"
}

_cmux_version() {
if [[ "$1" == "--help" || "$1" == "-h" ]]; then
echo "Usage: cmux version"
echo ""
echo " Show the installed cmux version and its release notes."
return 0
fi
echo "cmux $CMUX_VERSION"
local notes
if notes="$(_cmux_version_notes 2>/dev/null)"; then
echo ""
echo "$notes"
fi
}

# ── Subcommands ──────────────────────────────────────────────────────

_cmux_new() {
Expand Down Expand Up @@ -1033,6 +1087,7 @@ _cmux_update() {
if curl -fsSL "${_CMUX_DOWNLOAD_URL}/cmux.sh" -o "$install_path"; then
printf '%s' "$remote_version" > "$HOME/.cmux/VERSION"
printf '%s' "$remote_version" > "$HOME/.cmux/.latest_version"
curl -fsSL "${_CMUX_DOWNLOAD_URL}/NOTES.md" -o "$HOME/.cmux/NOTES.md" 2>/dev/null
source "$install_path"
echo "cmux updated to $CMUX_VERSION."
else
Expand Down
1 change: 1 addition & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mkdir -p "$INSTALL_DIR"
echo "Downloading cmux..."
curl -fsSL "$RELEASE_URL/cmux.sh" -o "$INSTALL_PATH"
curl -fsSL "$RELEASE_URL/VERSION" | tr -d '[:space:]' > "$INSTALL_DIR/VERSION"
curl -fsSL "$RELEASE_URL/NOTES.md" -o "$INSTALL_DIR/NOTES.md" 2>/dev/null || true

# Clear stale update-check cache from any previous install
rm -f "$INSTALL_DIR/.latest_version" "$INSTALL_DIR/.last_check"
Expand Down
31 changes: 31 additions & 0 deletions tests/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Discover and run every test_*.sh file in this directory.
# Exit nonzero if any fail. Usage: bash tests/run.sh
set -u

cd "$(dirname "$0")"

shopt -s nullglob
files=(test_*.sh)
if [[ ${#files[@]} -eq 0 ]]; then
echo "No test files found in tests/"
exit 0
fi

failed=0
for f in "${files[@]}"; do
echo "── $f ──"
if ! bash "$f"; then
failed=$((failed + 1))
fi
echo ""
done

total=${#files[@]}
if [[ $failed -eq 0 ]]; then
echo "All $total test file(s) passed."
exit 0
else
echo "$failed of $total test file(s) failed."
exit 1
fi
106 changes: 106 additions & 0 deletions tests/test_version_notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Tests for cmux version notes.
# Run: bash tests/test_version_notes.sh

set -u

CMUX_SH="$(cd "$(dirname "$0")/.." && pwd)/cmux.sh"
SAMPLE_NOTES='## 0.1.3
- Branch from the repo default branch
- New --from flag

## 0.1.2
- Older change'

fails=0
passes=0

# Run $* in an isolated $HOME with a fake VERSION + NOTES.md so we never
# touch the real ~/.cmux.
isolated() {
local fake_version="$1"; shift
local tmp
tmp="$(mktemp -d)"
mkdir -p "$tmp/.cmux"
printf '%s' "$fake_version" > "$tmp/.cmux/VERSION"
printf '%s\n' "$SAMPLE_NOTES" > "$tmp/.cmux/NOTES.md"
HOME="$tmp" bash -c "source '$CMUX_SH' 2>/dev/null; $*"
local rc=$?
rm -rf "$tmp"
return $rc
}

# Same, but with NOTES.md removed before the test body runs.
isolated_no_notes() {
local fake_version="$1"; shift
isolated "$fake_version" 'rm -f "$HOME/.cmux/NOTES.md"; '"$*"
}

check() {
local desc="$1"; shift
if "$@"; then
printf ' ok %s\n' "$desc"
passes=$((passes + 1))
else
printf ' FAIL %s\n' "$desc"
fails=$((fails + 1))
fi
}

# ── _cmux_version_notes ──
check "_cmux_version_notes returns the current version's section" \
isolated 0.1.3 '[[ "$(_cmux_version_notes)" == *"--from flag"* ]]'

check "_cmux_version_notes ignores other versions' sections" \
isolated 0.1.3 '[[ "$(_cmux_version_notes)" != *"Older change"* ]]'

check "_cmux_version_notes returns nonzero when current version has no notes" \
isolated 999.0.0 '! _cmux_version_notes >/dev/null 2>&1'

check "_cmux_version_notes fails gracefully when NOTES.md is missing" \
isolated_no_notes 0.1.3 '! _cmux_version_notes >/dev/null 2>&1'

# ── cmux version ──
check "cmux version prints version string" \
isolated 0.1.3 'echo 0.1.3 > "$HOME/.cmux/.last_seen_version"; [[ "$(cmux version 2>&1)" == *"cmux 0.1.3"* ]]'

check "cmux version prints notes when they exist" \
isolated 0.1.3 'echo 0.1.3 > "$HOME/.cmux/.last_seen_version"; [[ "$(cmux version 2>&1)" == *"--from flag"* ]]'

check "cmux version works even when NOTES.md is missing" \
isolated_no_notes 0.1.3 'echo 0.1.3 > "$HOME/.cmux/.last_seen_version"; [[ "$(cmux version 2>&1)" == *"cmux 0.1.3"* ]]'

# ── _cmux_show_update_notes: no seen file ──
check "no seen file: output is silent" \
isolated 0.1.3 '[[ -z "$(_cmux_show_update_notes 2>&1)" ]]'

check "no seen file: seen file is created with current version" \
isolated 0.1.3 '_cmux_show_update_notes; [[ "$(<"$HOME/.cmux/.last_seen_version")" == "0.1.3" ]]'

# ── _cmux_show_update_notes: seen file matches ──
check "seen file matches current: output is silent" \
isolated 0.1.3 'echo 0.1.3 > "$HOME/.cmux/.last_seen_version"; [[ -z "$(_cmux_show_update_notes 2>&1)" ]]'

# ── _cmux_show_update_notes: seen file differs ──
check "seen file older: prints notes" \
isolated 0.1.3 'echo 0.1.2 > "$HOME/.cmux/.last_seen_version"; [[ "$(_cmux_show_update_notes 2>&1)" == *"what"*"new"* ]]'

check "seen file older: updates seen file to current version" \
isolated 0.1.3 'echo 0.1.2 > "$HOME/.cmux/.last_seen_version"; _cmux_show_update_notes >/dev/null 2>&1; [[ "$(<"$HOME/.cmux/.last_seen_version")" == "0.1.3" ]]'

check "seen file older, no notes for current version: silent but still updates seen file" \
isolated 999.0.0 'echo 0.1.2 > "$HOME/.cmux/.last_seen_version"; out="$(_cmux_show_update_notes 2>&1)"; [[ -z "$out" && "$(<"$HOME/.cmux/.last_seen_version")" == "999.0.0" ]]'

check "seen file older, NOTES.md missing: silent and still updates seen file" \
isolated_no_notes 0.1.3 'echo 0.1.2 > "$HOME/.cmux/.last_seen_version"; out="$(_cmux_show_update_notes 2>&1)"; [[ -z "$out" && "$(<"$HOME/.cmux/.last_seen_version")" == "0.1.3" ]]'

# ── Summary ──
echo ""
total=$((passes + fails))
if [[ $fails -eq 0 ]]; then
echo "All $total tests passed."
exit 0
else
echo "$passes/$total passed, $fails failed."
exit 1
fi
Loading