From 3627c614ba5024c9d13016329b9a86726c43765b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 13:41:54 +0000 Subject: [PATCH 1/2] try-pr.sh: try a pull request the cheapest way, live when it needs no build scripts/cmux-next/try-pr.sh PR reads the PR's diff with git and sorts it with live_apply.py (the rules from the 8,315-PR live-apply study). A web-only PR goes straight to web-overlay.sh follow, so the running app hot-reloads it with no build; a CI/tests/docs PR is nothing to try; a daemon, Swift or resource change says why it needs a new binary and, with --build, requests the fleet dev build tagged pr-N. Tests: python3 -m unittest scripts/cmux-next/tests/test_live_apply.py (10 pass); dry runs on #35314 (rebuild), #35408 (no-app), #35206 and #35205 (web-live). Leo-ask: ask-ffdcb76e34 Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-f164a6206bf2ef9c6b7d --- docs/cmux-next/hot-reload.md | 15 ++ scripts/cmux-next/live_apply.py | 156 +++++++++++++++++++++ scripts/cmux-next/tests/test_live_apply.py | 87 ++++++++++++ scripts/cmux-next/try-pr.sh | 81 +++++++++++ 4 files changed, 339 insertions(+) create mode 100755 scripts/cmux-next/live_apply.py create mode 100644 scripts/cmux-next/tests/test_live_apply.py create mode 100755 scripts/cmux-next/try-pr.sh diff --git a/docs/cmux-next/hot-reload.md b/docs/cmux-next/hot-reload.md index 1b1c77039a3d..70dc8ed0912f 100644 --- a/docs/cmux-next/hot-reload.md +++ b/docs/cmux-next/hot-reload.md @@ -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 ` | | A pull request's web UI, live | `scripts/cmux-next/web-overlay.sh follow ` (again after each push) | +| Try any pull request the cheapest way | `scripts/cmux-next/try-pr.sh `: 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) | @@ -76,6 +77,20 @@ 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 # --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-`. +The rules are in `scripts/cmux-next/live_apply.py`. Over the last 3,895 NEXT-era PRs, 66% of the +ones that touch the app needed no new binary. + ### No dev server: built bundles `fleet` needs the team's `cmux-ci` client. diff --git a/scripts/cmux-next/live_apply.py b/scripts/cmux-next/live_apply.py new file mode 100755 index 000000000000..78e553ee3373 --- /dev/null +++ b/scripts/cmux-next/live_apply.py @@ -0,0 +1,156 @@ +#!/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 + +NOAPP = re.compile(r"(^|/)(docs?|plans|research|\.github|tests?|Tests|UITests|scripts|skills|release-notes|schemas|fixtures|bench|benchmarks|tools|build-fleet)(/|$)|\.(md|txt|yml|yaml|toml|lock|sh|py|mjs)$|^(web|backend|workers|services|cmux-tui/relays)/|(^|/)CHANGELOG|LICENSE|\.gitignore$|\.test\.(ts|tsx)$|\.gallery\.(ts|tsx|css)$|/gallery/") +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.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 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()) diff --git a/scripts/cmux-next/tests/test_live_apply.py b/scripts/cmux-next/tests/test_live_apply.py new file mode 100644 index 000000000000..9443c00f7ecf --- /dev/null +++ b/scripts/cmux-next/tests/test_live_apply.py @@ -0,0 +1,87 @@ +#!/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_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() diff --git a/scripts/cmux-next/try-pr.sh b/scripts/cmux-next/try-pr.sh new file mode 100755 index 000000000000..186e350a6b0e --- /dev/null +++ b/scripts/cmux-next/try-pr.sh @@ -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) 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 From 89ae0b8fabac0b446d0d9dbd99e1e35ab2b3be4a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 13:45:02 +0000 Subject: [PATCH 2/2] try-pr.sh: lockfiles, bundled scripts and web config are not no-app; honest doc numbers Review follow-ups on #35450: the extension rule now runs after the web, resource and Rust rules, the doc no longer counts swift-live as needing no binary, and --port with no value prints usage. Leo-ask: ask-ffdcb76e34 Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-f164a6206bf2ef9c6b7d --- docs/cmux-next/hot-reload.md | 6 ++++-- scripts/cmux-next/live_apply.py | 10 ++++++++-- scripts/cmux-next/tests/test_live_apply.py | 7 +++++++ scripts/cmux-next/try-pr.sh | 2 +- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/cmux-next/hot-reload.md b/docs/cmux-next/hot-reload.md index 70dc8ed0912f..ab39f7fb81f9 100644 --- a/docs/cmux-next/hot-reload.md +++ b/docs/cmux-next/hot-reload.md @@ -88,8 +88,10 @@ reads the PR's diff with git and takes the cheapest path. A web-only PR goes str 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-`. -The rules are in `scripts/cmux-next/live_apply.py`. Over the last 3,895 NEXT-era PRs, 66% of the -ones that touch the app needed no new binary. +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 diff --git a/scripts/cmux-next/live_apply.py b/scripts/cmux-next/live_apply.py index 78e553ee3373..a9c687aa943c 100755 --- a/scripts/cmux-next/live_apply.py +++ b/scripts/cmux-next/live_apply.py @@ -24,7 +24,11 @@ import subprocess import sys -NOAPP = re.compile(r"(^|/)(docs?|plans|research|\.github|tests?|Tests|UITests|scripts|skills|release-notes|schemas|fixtures|bench|benchmarks|tools|build-fleet)(/|$)|\.(md|txt|yml|yaml|toml|lock|sh|py|mjs)$|^(web|backend|workers|services|cmux-tui/relays)/|(^|/)CHANGELOG|LICENSE|\.gitignore$|\.test\.(ts|tsx)$|\.gallery\.(ts|tsx|css)$|/gallery/") +# 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)/") @@ -79,7 +83,7 @@ def note(kind, path, why=None): for status, path in files: if IOS.search(path): note("ios-only", path) - elif NOAPP.search(path): + elif NOAPP_PATH.search(path): note("no-app", path) elif WEB.search(path): note("web-live", path) @@ -87,6 +91,8 @@ def note(kind, path, why=None): 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") diff --git a/scripts/cmux-next/tests/test_live_apply.py b/scripts/cmux-next/tests/test_live_apply.py index 9443c00f7ecf..d8ca716a5d5d 100644 --- a/scripts/cmux-next/tests/test_live_apply.py +++ b/scripts/cmux-next/tests/test_live_apply.py @@ -31,6 +31,13 @@ def test_a_web_change_is_live(self): 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") diff --git a/scripts/cmux-next/try-pr.sh b/scripts/cmux-next/try-pr.sh index 186e350a6b0e..60f3c1fb18a2 100755 --- a/scripts/cmux-next/try-pr.sh +++ b/scripts/cmux-next/try-pr.sh @@ -26,7 +26,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --dry-run) dry=1; shift ;; --build) build=1; shift ;; - --port) follow+=(--port "$2"); shift 2 ;; + --port) [[ $# -ge 2 ]] || usage 1; follow+=(--port "$2"); shift 2 ;; --fork) follow+=(--fork); shift ;; -h|--help) usage ;; *) usage 1 ;;