From 6d0c90c8c8b259c22ba3293716ce58060b6862ea Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 5 Mar 2026 12:45:04 -0800 Subject: [PATCH] Fix sidebar branch refresh after checkout (issue #666) (#905) * Fix sidebar branch refresh after checkout * Fix bash PR probe not refreshing on checkout (PR review feedback) When HEAD changes (e.g. git checkout), the bash integration now resets _CMUX_PR_LAST_RUN=0 so the PR probe is forced to re-run immediately. This matches the zsh integration which already sets _CMUX_PR_FORCE=1 on HEAD change. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../cmux-bash-integration.bash | 65 ++++++++++- .../cmux-zsh-integration.zsh | 66 ++++------- ...sue_666_sidebar_branch_checkout_refresh.py | 105 ++++++++++++++++++ 3 files changed, 186 insertions(+), 50 deletions(-) create mode 100644 tests/test_issue_666_sidebar_branch_checkout_refresh.py diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 85027ee4..242dcb7d 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -41,6 +41,9 @@ _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}" _CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}" _CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}" _CMUX_GIT_JOB_STARTED_AT="${_CMUX_GIT_JOB_STARTED_AT:-0}" +_CMUX_GIT_HEAD_LAST_PWD="${_CMUX_GIT_HEAD_LAST_PWD:-}" +_CMUX_GIT_HEAD_PATH="${_CMUX_GIT_HEAD_PATH:-}" +_CMUX_GIT_HEAD_SIGNATURE="${_CMUX_GIT_HEAD_SIGNATURE:-}" _CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}" _CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}" _CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}" @@ -51,6 +54,41 @@ _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" _CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}" +_cmux_git_resolve_head_path() { + # Resolve the HEAD file path without invoking git (fast; works for worktrees). + local dir="$PWD" + while :; do + if [[ -d "$dir/.git" ]]; then + printf '%s\n' "$dir/.git/HEAD" + return 0 + fi + if [[ -f "$dir/.git" ]]; then + local line gitdir + IFS= read -r line < "$dir/.git" || line="" + if [[ "$line" == gitdir:* ]]; then + gitdir="${line#gitdir:}" + gitdir="${gitdir## }" + gitdir="${gitdir%% }" + [[ -n "$gitdir" ]] || return 1 + [[ "$gitdir" != /* ]] && gitdir="$dir/$gitdir" + printf '%s\n' "$gitdir/HEAD" + return 0 + fi + fi + [[ "$dir" == "/" || -z "$dir" ]] && break + dir="$(dirname "$dir")" + done + return 1 +} + +_cmux_git_head_signature() { + local head_path="$1" + [[ -n "$head_path" && -r "$head_path" ]] || return 1 + local line + IFS= read -r line < "$head_path" || return 1 + printf '%s\n' "$line" +} + _cmux_report_tty_once() { # Send the TTY name to the app once per session so the batched port scanner # knows which TTY belongs to this panel. @@ -126,12 +164,31 @@ _cmux_prompt_command() { } >/dev/null 2>&1 & fi + # Branch can change via aliases/tools while an older probe is still in flight. + # Track .git/HEAD content so we can restart stale probes immediately. + local git_head_changed=0 + if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then + _CMUX_GIT_HEAD_LAST_PWD="$pwd" + _CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)" + _CMUX_GIT_HEAD_SIGNATURE="" + fi + if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then + local head_signature + head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" + if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 + # Also invalidate the PR probe so it refreshes with the new branch. + _CMUX_PR_LAST_RUN=0 + fi + fi + # Git branch/dirty can change without a directory change (e.g. `git checkout`), # so update on every prompt (still async + de-duped by the running-job check). # When pwd changes (cd into a different repo), kill the old probe and start fresh # so the sidebar picks up the new branch immediately. if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then - if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then + if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" || "$git_head_changed" == "1" ]]; then kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true _CMUX_GIT_JOB_PID="" _CMUX_GIT_JOB_STARTED_AT=0 @@ -158,16 +215,16 @@ _cmux_prompt_command() { fi # Pull request metadata (number/state/url): - # refresh on cwd change and periodically to avoid stale status. + # refresh on cwd change, HEAD change, and periodically to avoid stale status. if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then - if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]]; then kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true _CMUX_PR_JOB_PID="" _CMUX_PR_JOB_STARTED_AT=0 fi fi - if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then _CMUX_PR_LAST_PWD="$pwd" _CMUX_PR_LAST_RUN=$now diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 988be2f1..f35814bc 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -45,9 +45,8 @@ typeset -g _CMUX_GIT_JOB_STARTED_AT=0 typeset -g _CMUX_GIT_FORCE=0 typeset -g _CMUX_GIT_HEAD_LAST_PWD="" typeset -g _CMUX_GIT_HEAD_PATH="" -typeset -g _CMUX_GIT_HEAD_MTIME=0 +typeset -g _CMUX_GIT_HEAD_SIGNATURE="" typeset -g _CMUX_GIT_HEAD_WATCH_PID="" -typeset -g _CMUX_HAVE_ZSTAT=0 typeset -g _CMUX_PR_LAST_PWD="" typeset -g _CMUX_PR_LAST_RUN=0 typeset -g _CMUX_PR_JOB_PID="" @@ -60,19 +59,6 @@ typeset -g _CMUX_CMD_START=0 typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 -_cmux_ensure_zstat() { - # zstat is substantially cheaper than spawning external `stat`. - if (( _CMUX_HAVE_ZSTAT != 0 )); then - return 0 - fi - if zmodload -F zsh/stat b:zstat 2>/dev/null; then - _CMUX_HAVE_ZSTAT=1 - return 0 - fi - _CMUX_HAVE_ZSTAT=-1 - return 1 -} - _cmux_git_resolve_head_path() { # Resolve the HEAD file path without invoking git (fast; works for worktrees). local dir="$PWD" @@ -100,27 +86,15 @@ _cmux_git_resolve_head_path() { return 1 } -_cmux_git_head_mtime() { +_cmux_git_head_signature() { local head_path="$1" - [[ -n "$head_path" && -f "$head_path" ]] || { print -r -- 0; return 0; } - - if _cmux_ensure_zstat; then - typeset -A st - if zstat -H st +mtime -- "$head_path" 2>/dev/null; then - print -r -- "${st[mtime]:-0}" - return 0 - fi - fi - - # Fallback for environments where zsh/stat isn't available. - if command -v stat >/dev/null 2>&1; then - local mtime - mtime="$(stat -f %m "$head_path" 2>/dev/null || stat -c %Y "$head_path" 2>/dev/null || echo 0)" - print -r -- "$mtime" + [[ -n "$head_path" && -r "$head_path" ]] || return 1 + local line="" + if IFS= read -r line < "$head_path"; then + print -r -- "$line" return 0 fi - - print -r -- 0 + return 1 } _cmux_report_tty_once() { @@ -184,23 +158,23 @@ _cmux_start_git_head_watch() { watch_head_path="$(_cmux_git_resolve_head_path 2>/dev/null || true)" [[ -n "$watch_head_path" ]] || return 0 - local watch_head_mtime - watch_head_mtime="$(_cmux_git_head_mtime "$watch_head_path" 2>/dev/null || echo 0)" + local watch_head_signature + watch_head_signature="$(_cmux_git_head_signature "$watch_head_path" 2>/dev/null || true)" _CMUX_GIT_HEAD_LAST_PWD="$watch_pwd" _CMUX_GIT_HEAD_PATH="$watch_head_path" - _CMUX_GIT_HEAD_MTIME="$watch_head_mtime" + _CMUX_GIT_HEAD_SIGNATURE="$watch_head_signature" _cmux_stop_git_head_watch { - local last_mtime="$watch_head_mtime" + local last_signature="$watch_head_signature" while true; do sleep 1 - local mtime - mtime="$(_cmux_git_head_mtime "$watch_head_path" 2>/dev/null || echo 0)" - if [[ -n "$mtime" && "$mtime" != 0 && "$mtime" != "$last_mtime" ]]; then - last_mtime="$mtime" + local signature + signature="$(_cmux_git_head_signature "$watch_head_path" 2>/dev/null || true)" + if [[ -n "$signature" && "$signature" != "$last_signature" ]]; then + last_signature="$signature" _cmux_report_git_branch_for_path "$watch_pwd" fi done @@ -299,13 +273,13 @@ _cmux_precmd() { if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then _CMUX_GIT_HEAD_LAST_PWD="$pwd" _CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)" - _CMUX_GIT_HEAD_MTIME=0 + _CMUX_GIT_HEAD_SIGNATURE="" fi if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then - local head_mtime - head_mtime="$(_cmux_git_head_mtime "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || echo 0)" - if [[ -n "$head_mtime" && "$head_mtime" != 0 && "$head_mtime" != "$_CMUX_GIT_HEAD_MTIME" ]]; then - _CMUX_GIT_HEAD_MTIME="$head_mtime" + local head_signature + head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" + if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" # Treat HEAD file change like a git command — force-replace any # running probe so the sidebar picks up the new branch immediately. _CMUX_GIT_FORCE=1 diff --git a/tests/test_issue_666_sidebar_branch_checkout_refresh.py b/tests/test_issue_666_sidebar_branch_checkout_refresh.py new file mode 100644 index 00000000..751a8c70 --- /dev/null +++ b/tests/test_issue_666_sidebar_branch_checkout_refresh.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Regression guard for issue #666 (sidebar branch stuck after checkout).""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + zsh_path = repo_root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh" + bash_path = repo_root / "Resources" / "shell-integration" / "cmux-bash-integration.bash" + + required_paths = [zsh_path, bash_path] + missing_paths = [str(path) for path in required_paths if not path.exists()] + if missing_paths: + print("Missing expected files:") + for path in missing_paths: + print(f" - {path}") + return 1 + + zsh_content = zsh_path.read_text(encoding="utf-8") + bash_content = bash_path.read_text(encoding="utf-8") + + failures: list[str] = [] + + require( + zsh_content, + "_CMUX_GIT_HEAD_SIGNATURE", + "zsh integration is missing git HEAD signature tracking", + failures, + ) + require( + zsh_content, + "_cmux_git_head_signature", + "zsh integration is missing git HEAD signature helper", + failures, + ) + require( + zsh_content, + '"$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE"', + "zsh integration no longer compares git HEAD signatures", + failures, + ) + require( + zsh_content, + "_CMUX_GIT_FORCE=1", + "zsh integration no longer forces git probe refresh on HEAD changes", + failures, + ) + + require( + bash_content, + "_CMUX_GIT_HEAD_SIGNATURE", + "bash integration is missing git HEAD signature tracking", + failures, + ) + require( + bash_content, + "_cmux_git_head_signature", + "bash integration is missing git HEAD signature helper", + failures, + ) + require( + bash_content, + "git_head_changed=1", + "bash integration no longer flags HEAD changes for immediate refresh", + failures, + ) + require( + bash_content, + '|| "$git_head_changed" == "1"', + "bash integration no longer restarts running git probes on HEAD change", + failures, + ) + + if failures: + print("FAIL: issue #666 regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: issue #666 checkout refresh guards are present") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())