diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index a9f1137a..988be2f1 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -46,6 +46,7 @@ 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_WATCH_PID="" typeset -g _CMUX_HAVE_ZSTAT=0 typeset -g _CMUX_PR_LAST_PWD="" typeset -g _CMUX_PR_LAST_RUN=0 @@ -148,6 +149,65 @@ _cmux_ports_kick() { } >/dev/null 2>&1 &! } +_cmux_report_git_branch_for_path() { + local repo_path="$1" + [[ -n "$repo_path" ]] || return 0 + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + local branch dirty_opt="" first + branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" + if [[ -n "$branch" ]]; then + first="$(git -C "$repo_path" status --porcelain -uno 2>/dev/null | head -1)" + [[ -n "$first" ]] && dirty_opt="--status=dirty" + _cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + _cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + fi +} + +_cmux_stop_git_head_watch() { + if [[ -n "$_CMUX_GIT_HEAD_WATCH_PID" ]]; then + kill "$_CMUX_GIT_HEAD_WATCH_PID" >/dev/null 2>&1 || true + _CMUX_GIT_HEAD_WATCH_PID="" + fi +} + +_cmux_start_git_head_watch() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + local watch_pwd="$PWD" + local watch_head_path + 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)" + + _CMUX_GIT_HEAD_LAST_PWD="$watch_pwd" + _CMUX_GIT_HEAD_PATH="$watch_head_path" + _CMUX_GIT_HEAD_MTIME="$watch_head_mtime" + + _cmux_stop_git_head_watch + { + local last_mtime="$watch_head_mtime" + 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" + _cmux_report_git_branch_for_path "$watch_pwd" + fi + done + } >/dev/null 2>&1 &! + _CMUX_GIT_HEAD_WATCH_PID=$! +} + _cmux_preexec() { if [[ -z "$_CMUX_TTY_NAME" ]]; then local t @@ -169,9 +229,12 @@ _cmux_preexec() { # Register TTY + kick batched port scan for foreground commands (servers). _cmux_report_tty_once _cmux_ports_kick + _cmux_start_git_head_watch } _cmux_precmd() { + _cmux_stop_git_head_watch + # Skip if socket doesn't exist yet [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 @@ -227,6 +290,8 @@ _cmux_precmd() { fi # Git branch/dirty: update immediately on directory change, otherwise every ~3s. + # While a foreground command is running, _cmux_start_git_head_watch probes HEAD + # once per second so agent-initiated git checkouts still surface quickly. local should_git=0 # Git branch can change without a `git ...`-prefixed command (aliases like `gco`, @@ -280,16 +345,7 @@ _cmux_precmd() { _CMUX_GIT_LAST_PWD="$pwd" _CMUX_GIT_LAST_RUN=$now { - local branch dirty_opt="" - branch=$(git branch --show-current 2>/dev/null) - if [[ -n "$branch" ]]; then - local first - first=$(git status --porcelain -uno 2>/dev/null | head -1) - [[ -n "$first" ]] && dirty_opt="--status=dirty" - _cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - else - _cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - fi + _cmux_report_git_branch_for_path "$pwd" } >/dev/null 2>&1 &! _CMUX_GIT_JOB_PID=$! _CMUX_GIT_JOB_STARTED_AT=$now @@ -387,7 +443,12 @@ _cmux_fix_path() { add-zsh-hook -d precmd _cmux_fix_path } +_cmux_zshexit() { + _cmux_stop_git_head_watch +} + autoload -Uz add-zsh-hook add-zsh-hook preexec _cmux_preexec add-zsh-hook precmd _cmux_precmd add-zsh-hook precmd _cmux_fix_path +add-zsh-hook zshexit _cmux_zshexit diff --git a/tests/test_sidebar_cwd_git.py b/tests/test_sidebar_cwd_git.py index e6168a1f..520a0831 100644 --- a/tests/test_sidebar_cwd_git.py +++ b/tests/test_sidebar_cwd_git.py @@ -72,6 +72,7 @@ def _wait_for_git_branch( expected: str, timeout: float = 12.0, interval: float = 0.15, + allow_force_fallback: bool = True, ) -> dict[str, str]: def pred(): state = _parse_sidebar_state(client.sidebar_state()) @@ -82,6 +83,8 @@ def _wait_for_git_branch( try: return _wait_for(pred, timeout=timeout, interval=interval, label=f"git_branch={expected!r}") except AssertionError as original_error: + if not allow_force_fallback: + raise original_error # VM shells can occasionally skip a prompt hook; force a one-shot report so # the remainder of the flow can still validate transition behavior. try: @@ -180,6 +183,18 @@ def main() -> int: _send_cd_and_wait(client, repo) _wait_for_git_branch(client, "main") + # Branch changes during a long-running foreground command should still + # propagate before the prompt returns (agent-style workflows). + client.send("bash -lc 'git checkout -b feature/agent-live >/dev/null 2>&1; sleep 6'\n") + _wait_for_git_branch( + client, + "feature/agent-live", + timeout=3.5, + interval=0.1, + allow_force_fallback=False, + ) + time.sleep(6.3) + # Branch change should update. # Cover alias/non-`git ...` command paths too (regression: branch could # stick for ~3s when switching via alias/tools like `gh pr checkout`).