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
17 changes: 17 additions & 0 deletions docs/cmux-next/hot-reload.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Running apps can pick up edits to them without a Swift build or a relaunch, two
| Your edits live in your app | `scripts/cmux-next/web-overlay.sh server --local` |
| Another worktree's edits live | `scripts/cmux-next/web-overlay.sh server --local --worktree <path>` |
| A pull request's web UI, live | `scripts/cmux-next/web-overlay.sh follow <PR>` (again after each push) |
| Try any pull request the cheapest way | `scripts/cmux-next/try-pr.sh <PR>`: live when it can be, else it names the dev build |
| The web UI of every merge, no checkout | Settings > Updates > Web UI Updates > **Every Merge** |
| Stop your dev server | `scripts/cmux-next/web-overlay.sh server off` (an installed overlay, if any, still serves) |
| The shipped pages back | `scripts/cmux-next/web-overlay.sh off` (ignores the file; run `server off` first to stop the server) |
Expand Down Expand Up @@ -76,6 +77,22 @@ The dev server runs the head's own code (its Vite config and dev-server plugins)
head that no branch of the repo points at, such as an outside contributor's fork, is refused until
you have read its `webviews/` changes and add `--fork`.

### Try a pull request without knowing what it changed

```sh
scripts/cmux-next/try-pr.sh <PR> # --dry-run to only look, --build to request the dev build
```

reads the PR's diff with git and takes the cheapest path. A web-only PR goes straight to
`web-overlay.sh follow`, so the running app shows it in seconds with no build. A PR that changes
only CI, tests or docs is reported as nothing to try. A PR that changes the daemon, Swift, a
resource or a manifest needs a new binary: the script says why (a stored property, a changed
signature, a new file, and so on), and with `--build` it requests the fleet dev build tagged `pr-<N>`.
The rules are in `scripts/cmux-next/live_apply.py`. Over the last 3,895 NEXT-era PRs, about half of
the ones that touch the app changed only web code or nothing in the app, so they need no new binary.
A Swift change confined to function bodies could be injected live once the app takes live code
(the view shell does today); until then it takes the dev build.

### No dev server: built bundles

`fleet` needs the team's `cmux-ci` client.
Expand Down
162 changes: 162 additions & 0 deletions scripts/cmux-next/live_apply.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""What a running cmux NEXT app could take from a change without a new build (try-pr.sh).

live_apply.py [--repo DIR] [--json] BASE HEAD

Reads `git diff BASE HEAD` (no GitHub API) and prints the verdict of the heaviest change in it:

no-app docs, CI, tests, scripts, website and backend only: nothing in the app changes
web-live plus the app's web bundles (webviews/): hot-swapped into the running app
daemon plus Rust (cmux-tui, acpmux): a daemon or CLI restart with the change's binary
swift-live plus app Swift changed only inside function bodies and computed properties
rebuild a new binary: a stored property, a changed signature, a new type, conformance or
enum case, a new Swift file, a resource, manifest, plist or entitlement
ios-only iOS only: the Mac app does not change

The rules are regular expressions over the diff lines, checked by hand on 20 merged PRs (19 right):
leo-workspace research/2026-11-04-try-pr-live-apply.md. A miss in either direction is about 3
points, so a `swift-live` verdict is a hint for a person, never a license to load a patch blind.
"""
import argparse
import json
import os
import re
import subprocess
import sys

# Folders and files that never ship in the app, checked before everything else.
NOAPP_PATH = re.compile(r"(^|/)(docs?|plans|research|\.github|tests?|Tests|UITests|scripts|skills|release-notes|schemas|fixtures|bench|benchmarks|tools|build-fleet)(/|$)|^(web|backend|workers|services|cmux-tui/relays)/|(^|/)CHANGELOG|LICENSE|\.gitignore$|\.test\.(ts|tsx)$|\.gallery\.(ts|tsx|css)$|/gallery/")
# Extensions that change nothing in the app, checked after the web, resource and Rust rules, so a
# Cargo.lock, a bundled Resources/x.sh or webviews' vite.config.mjs still counts.
NOAPP_EXT = re.compile(r"\.(md|txt|yml|yaml|toml|lock|sh|py|mjs)$")
WEB = re.compile(r"^(webviews|cmux-tui/frontends/web)/")
RUST = re.compile(r"\.rs$|(^|/)Cargo\.toml$|^cmux-tui/|^acpmux/")
IOS = re.compile(r"^(ios|cmux-next-ios|Packages/iOS)/")
REBUILD_FILE = re.compile(r"\.(xcstrings|strings|xcassets|plist|entitlements|pbxproj|xcconfig|storyboard|xib|metal|png|pdf|car|icns)$|(^|/)Package\.(swift|resolved)$|Contents\.json$|\.xcodeproj/|/Resources/|^ghostty|\.(c|h|m|mm|zig)$")
COMMENT = re.compile(r"^\s*(//|/\*|\*|\*/)")
STORED = re.compile(r"^(\s*)(@\w+(\([^)]*\))?\s+)*((public|private|fileprivate|internal|package|open|final|static|nonisolated|lazy|weak|unowned|override)(\([^)]*\))?\s+)*(let|var)\s+\w+[^{]*$")
FUNC = re.compile(r"^\s*(@\w+(\([^)]*\))?\s+)*((public|private|fileprivate|internal|package|open|final|static|class|override|mutating|nonisolated|required|convenience)(\([^)]*\))?\s+)*(func\s+\w+|init[?!]?\s*[(<]|subscript\s*[(<])")
TYPE_DECL = re.compile(r"^\s*(@\w+(\([^)]*\))?\s+)*((public|private|fileprivate|internal|package|open|final|indirect)\s+)*(struct|class|enum|actor|protocol|extension)\s+\w+")
CASE = re.compile(r"^ {4}(\t)?case\s+\w+|^\tcase\s+\w+")

# Heaviest first: a change takes the verdict of its heaviest file.
ORDER = ["rebuild", "swift-live", "daemon", "web-live", "ios-only", "no-app"]


def swift_reasons(lines, added_file):
"""Why a Swift file's change needs a rebuild; empty when it only changes function bodies.
`lines` are the diff's ("+" or "-", text) pairs."""
if added_file:
return ["new file"]
reasons = set()
removed, added = [], []
for sign, line in lines:
body = line.rstrip()
if not body.strip() or COMMENT.match(body):
continue
indent = len(body) - len(body.lstrip(" "))
if TYPE_DECL.match(body):
reasons.add("type or conformance")
elif STORED.match(body) and indent <= 4 and not body.endswith("{"):
reasons.add("stored property") # member level (one indent) or a global
elif CASE.match(body) and indent == 4:
reasons.add("enum case")
elif FUNC.match(body):
sig = re.sub(r"\s+", " ", body.strip().rstrip("{").strip())
(removed if sign == "-" else added).append(sig)
if set(removed) - set(added):
reasons.add("signature") # one went away or changed; a new function alone is fine
return sorted(reasons)


def classify(files, diffs):
"""`files`: (status letter, path) pairs, as `git diff --name-status --no-renames`.
`diffs`: path -> ("+" or "-", text) pairs of that Swift file's `-U0` diff.
Returns {"verdict", "reasons": {reason: [paths]}, "files": {kind: [paths]}}."""
kinds, reasons = {}, {}

def note(kind, path, why=None):
kinds.setdefault(kind, []).append(path)
if why:
reasons.setdefault(why, []).append(path)

for status, path in files:
if IOS.search(path):
note("ios-only", path)
elif NOAPP_PATH.search(path):
note("no-app", path)
elif WEB.search(path):
note("web-live", path)
elif REBUILD_FILE.search(path):
note("rebuild", path, "resource or manifest")
elif RUST.search(path):
note("daemon", path)
elif NOAPP_EXT.search(path):
note("no-app", path)
elif path.endswith(".swift"):
# A deleted file's code stays in the running binary, and nothing calls it any more.
why = [] if status == "D" else swift_reasons(diffs.get(path, []), status == "A")
for reason in why:
note("rebuild", path, reason)
if not why:
note("swift-live", path)
else:
note("rebuild", path, "other app file")
verdict = next((v for v in ORDER if v in kinds), "no-app")
return {"verdict": verdict, "reasons": {k: sorted(set(v)) for k, v in reasons.items()},
"files": {k: sorted(set(v)) for k, v in kinds.items()}}


def parse_name_status(text):
files = []
for line in text.splitlines():
parts = line.split("\t")
if len(parts) >= 2 and parts[0]:
files.append((parts[0][0], parts[-1]))
return files


def parse_diff(text):
"""path -> ("+" or "-", text) pairs, from a unified diff."""
diffs, current = {}, None
for line in text.splitlines():
if line.startswith("+++ "):
current = line[6:] if line.startswith("+++ b/") else None
if current:
diffs.setdefault(current, [])
elif line.startswith("--- "):
continue
elif current and line[:1] in ("+", "-"):
diffs[current].append((line[0], line[1:]))
return diffs


def classify_range(repo, base, head):
def git(*args):
return subprocess.run(["git", "-C", repo, *args], check=True, capture_output=True, text=True,
errors="replace").stdout
files = parse_name_status(git("diff", "--name-status", "--no-renames", base, head))
swift = [p for s, p in files if p.endswith(".swift") and s != "D"]
diffs = parse_diff(git("diff", "-U0", "--no-renames", base, head, "--", *swift)) if swift else {}
return classify(files, diffs)


def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--repo", default=os.getcwd())
parser.add_argument("--json", action="store_true")
parser.add_argument("base")
parser.add_argument("head")
args = parser.parse_args(argv)
result = classify_range(args.repo, args.base, args.head)
if args.json:
print(json.dumps(result, indent=2))
return 0
print(result["verdict"])
for reason, paths in sorted(result["reasons"].items()):
print(f" {reason}: {', '.join(paths[:5])}{' …' if len(paths) > 5 else ''}")
return 0


if __name__ == "__main__":
sys.exit(main())
94 changes: 94 additions & 0 deletions scripts/cmux-next/tests/test_live_apply.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""live_apply.py: what a running cmux NEXT app could take from a PR without a new build.

Runs on any host (Linux included): no app, no fleet.

python3 -m unittest scripts/cmux-next/tests/test_live_apply.py
"""
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import live_apply # noqa: E402

VIEW = "Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift"


class ClassifyTests(unittest.TestCase):
def verdict(self, files, diffs=None):
return live_apply.classify(files, diffs or {})["verdict"]

def test_tests_docs_and_ci_change_nothing_in_the_app(self):
self.assertEqual(self.verdict([("M", "docs/x.md"), ("M", ".github/workflows/a.yml"),
("A", "scripts/cmux-next/tests/test_x.py")]), "no-app")

def test_a_web_change_is_live(self):
self.assertEqual(self.verdict([("M", "webviews/src/agent-pane/Composer.tsx"), ("M", "docs/a.md")]), "web-live")

def test_rust_is_a_daemon_restart_and_outweighs_web(self):
self.assertEqual(self.verdict([("M", "cmux-tui/src/main.rs"), ("M", "webviews/src/a.tsx")]), "daemon")

def test_lockfiles_bundled_scripts_and_web_config_are_not_no_app(self):
self.assertEqual(self.verdict([("M", "cmux-tui/Cargo.lock")]), "daemon")
self.assertEqual(self.verdict([("M", "acpmux/Cargo.toml")]), "daemon")
self.assertEqual(self.verdict([("M", "Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/upgrade.sh")]), "rebuild")
self.assertEqual(self.verdict([("M", "webviews/vite.config.mjs"), ("M", "webviews/bun.lock")]), "web-live")
self.assertEqual(self.verdict([("M", "scripts/cmux-next/x.sh"), ("M", "README.txt")]), "no-app")

def test_a_body_only_swift_edit_is_swift_live(self):
diffs = {VIEW: [("-", " Text(\"old\")"), ("+", " Text(\"new\")")]}
self.assertEqual(self.verdict([("M", VIEW)], diffs), "swift-live")

def test_a_stored_property_needs_a_rebuild(self):
result = live_apply.classify([("M", VIEW)], {VIEW: [("+", " var isPinned = false")]})
self.assertEqual(result["verdict"], "rebuild")
self.assertEqual(result["reasons"], {"stored property": [VIEW]})

def test_a_changed_signature_needs_a_rebuild_and_a_new_function_does_not(self):
changed = {VIEW: [("-", " func draw(_ a: Int) {"), ("+", " func draw(_ a: Int, b: Int) {")]}
self.assertEqual(self.verdict([("M", VIEW)], changed), "rebuild")
added = {VIEW: [("+", " func helper() -> Int {"), ("+", " 1"), ("+", " }")]}
self.assertEqual(self.verdict([("M", VIEW)], added), "swift-live")

def test_new_files_types_enum_cases_and_resources_need_a_rebuild(self):
self.assertEqual(self.verdict([("A", VIEW)]), "rebuild")
self.assertEqual(self.verdict([("M", VIEW)], {VIEW: [("+", "struct Chip: View {")]}), "rebuild")
self.assertEqual(self.verdict([("M", VIEW)], {VIEW: [("+", " case pinned")]}), "rebuild")
self.assertEqual(self.verdict([("M", "Resources/Localizable.xcstrings")]), "rebuild")
self.assertEqual(self.verdict([("M", "Packages/macOS/CmuxNext/Package.swift")]), "rebuild")

def test_a_deleted_swift_file_is_live_and_ios_alone_leaves_the_mac_app(self):
self.assertEqual(self.verdict([("D", VIEW)]), "swift-live")
self.assertEqual(self.verdict([("M", "cmux-next-ios/App/Root.swift")]), "ios-only")

def test_the_heaviest_file_wins(self):
diffs = {VIEW: [("+", " var x = 1")]}
self.assertEqual(self.verdict([("M", "webviews/src/a.tsx"), ("M", VIEW)], diffs), "rebuild")


class GitRangeTests(unittest.TestCase):
def test_classify_range_reads_the_diff_from_git(self):
with tempfile.TemporaryDirectory() as repo:
def git(*args):
subprocess.run(["git", "-C", repo, *args], check=True, capture_output=True)
git("init", "-q")
git("config", "user.email", "t@example.com")
git("config", "user.name", "t")
view = Path(repo, VIEW)
view.parent.mkdir(parents=True)
view.write_text("struct V {\n var body: some View {\n Text(\"old\")\n }\n}\n")
git("add", "-A")
git("commit", "-qm", "base")
view.write_text("struct V {\n var body: some View {\n Text(\"new\")\n }\n}\n")
git("commit", "-qam", "body")
self.assertEqual(live_apply.classify_range(repo, "HEAD~1", "HEAD")["verdict"], "swift-live")
view.write_text("struct V {\n var pinned = false\n var body: some View {\n Text(\"new\")\n }\n}\n")
git("commit", "-qam", "stored")
self.assertEqual(live_apply.classify_range(repo, "HEAD~1", "HEAD")["verdict"], "rebuild")


if __name__ == "__main__":
unittest.main()
81 changes: 81 additions & 0 deletions scripts/cmux-next/try-pr.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Try a pull request in a running cmux NEXT without waiting for a dev build when it does not need
# one (docs/cmux-next/hot-reload.md, "Try a pull request"). Fetches the PR's head with git (no
# GitHub API), sorts its change with live_apply.py, then takes the cheapest path:
#
# no-app nothing in the app changes: says so and stops
# web-live web-overlay.sh follow PR: the running app hot-reloads the PR's web UI now
# daemon, swift-live, rebuild
# says why it needs a new binary, and with --build requests the fleet dev build
# (cmux-ci build cmux, the same build the HQ request gives, tagged pr-N)
# ios-only the Mac app does not change
#
# try-pr.sh PR [--dry-run] [--build] [--port N] [--fork]
#
# --dry-run prints the verdict and the command it would run. --port and --fork go to
# web-overlay.sh follow. The Swift verdict is a hint: live Swift patches exist only in the view
# shell today (Prototypes/ViewShell, "Live code"), so the app still takes a dev build for them.
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
usage() { awk 'NR > 1 && !/^#/ { exit } NR > 1 { sub(/^# ?/, ""); print }' "$0"; exit "${1:-0}"; }

pr="${1:-}"; [[ "$pr" =~ ^[0-9]+$ ]] || usage 1; shift
dry="" build="" follow=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) dry=1; shift ;;
--build) build=1; shift ;;
--port) [[ $# -ge 2 ]] || usage 1; follow+=(--port "$2"); shift 2 ;;
--fork) follow+=(--fork); shift ;;
-h|--help) usage ;;
*) usage 1 ;;
esac
done

base_ref="${CMUX_TRY_PR_BASE:-feat-cmux-next}"
git -C "$ROOT" fetch -q origin "refs/pull/$pr/head" "refs/heads/$base_ref"
head="$(git -C "$ROOT" rev-parse "FETCH_HEAD")"
base="$(git -C "$ROOT" merge-base "$head" "origin/$base_ref")"

result="$(python3 "$ROOT/scripts/cmux-next/live_apply.py" --repo "$ROOT" --json "$base" "$head")"
verdict="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["verdict"])' <<<"$result")"
echo "#$pr at $head: $verdict"
python3 -c '
import json, sys
for reason, paths in sorted(json.load(sys.stdin)["reasons"].items()):
print(" " + reason + ": " + ", ".join(paths[:5]) + (" …" if len(paths) > 5 else ""))
' <<<"$result"

# Runs a command, or prints it under --dry-run.
run() {
if [[ -n "$dry" ]]; then printf 'would run:'; printf ' %q' "$@"; echo; else "$@"; fi
}

case "$verdict" in
no-app)
echo "nothing in the app changes (CI, tests, docs, tools): nothing to try"
;;
ios-only)
echo "only the iPhone app changes: nothing to try in the Mac app"
;;
web-live)
echo "web only: no build, the running app hot-reloads the PR's web UI"
run "$ROOT/scripts/cmux-next/web-overlay.sh" follow "$pr" ${follow[@]+"${follow[@]}"}
;;
daemon|swift-live|rebuild)
case "$verdict" in
daemon) echo "the daemon or CLI changes: it needs the PR's binary, so a dev build" ;;
swift-live) echo "Swift changes only inside function bodies: injectable once the app takes live code, a dev build until then" ;;
rebuild) echo "the app needs a new binary: a dev build" ;;
esac
if [[ -z "$build" ]]; then
echo "run again with --build to request it from the fleet (cmux DEV pr-$pr)"
exit 0
fi
ci="${CMUX_CI:-$HOME/.local/bin/cmux-ci}"
[[ -n "$dry" || -x "$ci" ]] || { echo "no cmux-ci client at $ci (CMUX_CI names another)" >&2; exit 1; }
# A later push with the same tag and workspace supersedes a queued build of an older head.
run "$ci" build cmux --ref "$head" --tag "pr-$pr" --backend-mode local --workspace "try-pr:$pr:$(id -un)"
;;
esac
Loading