From 5e8937981d867f64a59ddc229b6216075af55f6f Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 17 Aug 2026 21:25:26 -0700 Subject: [PATCH] =?UTF-8?q?Release=20=E2=80=94=20suppress=20Windows=20git?= =?UTF-8?q?=20console=20windows=20(#6534)=20(#7115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: suppress console windows for git subprocess calls on Windows On Windows, every git.exe spawned by the WebUI (update checks, version detection, workspace git info, rollback inspection, worktree management) creates a visible console window. When Windows Terminal is the default terminal, each short-lived git process opens a new tab. Combined with the 60-second version-skew monitor polling /api/settings and the boot-time update check, this produces a flood of flashing terminal tabs. Root cause: subprocess.run() calls in api/updates.py, api/rollback.py, api/worktrees.py, and api/workspace.py did not pass creationflags=CREATE_NO_WINDOW on Windows. api/workspace_git.py already had the fix (#5692); these four files were missed. Backend fix: - Add _windows_hide_flags() helper to each affected module (mirrors the existing pattern in api/workspace_git.py) - Pass creationflags=_windows_hide_flags() to all git subprocess.run() calls (10 call sites across 4 files) Frontend fix (prevent timeout->retry cascades): - Increase update-check timeout from 30s/60s to 300s (5 min) - Increase /api/settings fetches from 30s to 60s - Increase /api/git-info fetch from 30s to 60s The backend git fetch to GitHub can take 15s per repo; with both WebUI and Agent repos checked sequentially plus other git calls, the total easily exceeds the old 30s default frontend timeout, triggering the api() wrapper auto-retry (3 attempts) and amplifying the git process flood. * [verified] fix: address Windows git subprocess review --------- Co-authored-by: ya1yi2fa3 Co-authored-by: n --- CHANGELOG.md | 2 + api/agent_runtime.py | 4 + api/rollback.py | 6 + api/subprocess_utils.py | 18 +++ api/updates.py | 12 +- api/workspace.py | 2 + api/workspace_git.py | 24 +--- api/worktrees.py | 4 + static/boot.js | 2 +- static/panels.js | 2 +- tests/test_4961_url_query_prefill.py | 7 +- tests/test_5167_busy_input_mode_boot.py | 9 +- tests/test_api_timeout.py | 10 +- tests/test_git_subprocess_windows_flags.py | 132 +++++++++++++++++++++ tests/test_workspace_blank_page_fix.py | 8 +- tests/test_workspace_git.py | 16 +-- 16 files changed, 218 insertions(+), 40 deletions(-) create mode 100644 api/subprocess_utils.py create mode 100644 tests/test_git_subprocess_windows_flags.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f82fa4..909b171a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Git operations no longer flash a console window on Windows.** Every git subprocess boundary (agent runtime, rollback, updates, workspace, worktrees) now routes through a shared `windows_hide_flags()` helper that passes `CREATE_NO_WINDOW` on Win32 and stays a no-op on POSIX, so short-lived git commands no longer pop a visible console window on Windows while keeping captured stdout/stderr intact. Thanks @ya1yi2fa3. (#6534) + - **A `/goal` launch no longer silently switches providers mid-session after a deliberate model pick.** When a session had a deliberate cross-provider model/provider choice, running `/goal` omitted the `explicit_model_pick` marker that `/api/chat/start` sends, so the model resolver treated the persisted pick as stale and "repaired" it back to the profile default — silently changing providers while the goal ran. Both the frontend `/goal` command and the backend `_handle_goal_command` (initial kickoff and kickoff-prompt fallback paths) now carry the explicit-pick marker and stamp the pick signature, matching `/api/chat/start`; ordinary default sessions are unaffected (they still omit the marker, so a default pick stays repairable). Thanks @webtecnica. (#6705) - **A completed assistant turn no longer renders twice in the feed.** In `renderMessages()`, the live-turn preserve guard kept the in-flight assistant node across the DOM rebuild whenever it held any rendered content, so when a stream ended but its inflight bookkeeping was not yet cleared the just-completed turn was re-attached over the rebuilt settled transcript (a first copy without the model label, then a second with it). The stored data was always clean (state.db, sidecar, and `/api/session` each hold one row) — the duplicate existed only in the DOM. Preservation now requires a provable live owner (`S.activeStreamId` or explicit live-projection markers) instead of bare DOM content; the mid-stream flicker guard and blank-turn guard are preserved. Thanks @webtecnica. (#7109) diff --git a/api/agent_runtime.py b/api/agent_runtime.py index 865a2fd5..adeb6821 100644 --- a/api/agent_runtime.py +++ b/api/agent_runtime.py @@ -16,6 +16,7 @@ import threading # Retain the discovered path as a diagnostic/test-visible compatibility value; # runtime identity is deliberately captured from the loaded module below. from api.config import _AGENT_DIR # noqa: F401 +from api.subprocess_utils import windows_hide_flags _RESTART_MESSAGE = ( "Hermes Agent was updated while Hermes WebUI was running. " @@ -49,6 +50,7 @@ def _read_agent_revision( capture_output=True, text=True, timeout=2, + creationflags=windows_hide_flags(), ) if worktree_result.returncode != 0: return None @@ -69,6 +71,7 @@ def _read_agent_revision( capture_output=True, text=True, timeout=2, + creationflags=windows_hide_flags(), ) if tracked_result.returncode != 0: return None @@ -78,6 +81,7 @@ def _read_agent_revision( capture_output=True, text=True, timeout=2, + creationflags=windows_hide_flags(), ) except (OSError, subprocess.TimeoutExpired, RuntimeError, ValueError): return None diff --git a/api/rollback.py b/api/rollback.py index d89dee76..b9a2d697 100644 --- a/api/rollback.py +++ b/api/rollback.py @@ -17,6 +17,8 @@ from datetime import datetime from pathlib import Path from typing import Any +from api.subprocess_utils import windows_hide_flags + logger = logging.getLogger(__name__) # Checkpoint identifiers are SHA-style hex hashes from the agent's @@ -102,6 +104,7 @@ def _checkpoint_entry_modes(git: str, ckpt_dir: Path) -> dict[str, int]: result = subprocess.run( [git, "-C", str(ckpt_dir), "ls-files", "-s"], capture_output=True, text=True, timeout=10, + creationflags=windows_hide_flags(), ) if result.returncode != 0: raise ValueError("Failed to list checkpoint files") @@ -136,6 +139,7 @@ def _read_checkpoint_blob(git: str, ckpt_dir: Path, rel_path: str) -> bytes | No result = subprocess.run( [git, "-C", str(ckpt_dir), "show", f"HEAD:{rel_path}"], capture_output=True, timeout=10, + creationflags=windows_hide_flags(), ) if result.returncode != 0: return None @@ -250,6 +254,7 @@ def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None: result = subprocess.run( [git, "-C", str(ckpt_path), "log", "--format=%H%n%s%n%aI", "-1"], capture_output=True, text=True, timeout=5, + creationflags=windows_hide_flags(), ) if result.returncode != 0 or not result.stdout.strip(): return None @@ -272,6 +277,7 @@ def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None: files_result = subprocess.run( [git, "-C", str(ckpt_path), "ls-files"], capture_output=True, text=True, timeout=5, + creationflags=windows_hide_flags(), ) file_count = len(files_result.stdout.strip().split("\n")) if files_result.stdout.strip() else 0 diff --git a/api/subprocess_utils.py b/api/subprocess_utils.py new file mode 100644 index 00000000..c6a3770e --- /dev/null +++ b/api/subprocess_utils.py @@ -0,0 +1,18 @@ +"""Dependency-light helpers for launching child processes consistently.""" + +from __future__ import annotations + +import subprocess +import sys + + +def windows_hide_flags() -> int: + """Hide a short-lived console child on Win32 and remain a POSIX no-op. + + ``CREATE_NO_WINDOW`` keeps captured stdout and stderr connected, unlike + detaching the process. Passing ``0`` elsewhere preserves the subprocess + default. See #5692. + """ + if sys.platform == "win32": + return getattr(subprocess, "CREATE_NO_WINDOW", 0) + return 0 diff --git a/api/updates.py b/api/updates.py index 5e03cc5e..8262ac7f 100644 --- a/api/updates.py +++ b/api/updates.py @@ -28,6 +28,7 @@ from api.agent_health import get_active_profile_gateway_running_pid from api.gateway_restart import restart_active_profile_gateway from api.profiles import get_active_profile_name from api.config import REPO_ROOT, STREAMS, STREAMS_LOCK +from api.subprocess_utils import windows_hide_flags logger = logging.getLogger(__name__) @@ -215,9 +216,14 @@ def _run_git(args, cwd, timeout=10): return 'git executable not found', False try: r = subprocess.run( - [git_executable] + args, cwd=str(cwd), capture_output=True, - text=True, timeout=timeout, - encoding='utf-8', errors='replace', + [git_executable] + args, + cwd=str(cwd), + capture_output=True, + text=True, + timeout=timeout, + encoding='utf-8', + errors='replace', + creationflags=windows_hide_flags(), ) # On non-UTF-8 locales (e.g. Chinese Windows GBK), a binary git # output that fails to decode used to leave r.stdout = None and crash diff --git a/api/workspace.py b/api/workspace.py index a88aee5f..5a108687 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -35,6 +35,7 @@ from api.config import ( DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE, MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS ) +from api.subprocess_utils import windows_hide_flags # ── Profile-aware path resolution ─────────────────────────────────────────── @@ -1779,6 +1780,7 @@ def _run_git(args, cwd, timeout=3): r = subprocess.run( ['git'] + args, cwd=str(cwd), capture_output=True, text=True, timeout=timeout, + creationflags=windows_hide_flags(), ) return r.stdout.strip() if r.returncode == 0 else None except (subprocess.TimeoutExpired, FileNotFoundError, OSError): diff --git a/api/workspace_git.py b/api/workspace_git.py index 457d8720..a63a3f65 100644 --- a/api/workspace_git.py +++ b/api/workspace_git.py @@ -12,7 +12,6 @@ import logging import os import shutil import subprocess -import sys import tempfile import threading import re @@ -21,24 +20,11 @@ from dataclasses import dataclass from pathlib import Path from typing import Iterable -logger = logging.getLogger(__name__) - - -def _windows_hide_flags() -> int: - """Win32 ``creationflags`` that hide a short-lived console child's window - (``CREATE_NO_WINDOW``) without detaching it, so ``capture_output`` still - works. Returns ``0`` on non-Windows — the ``subprocess`` default, a genuine - no-op. Mirrors the ``api/updates.py`` pattern; kept local so workspace-git - never takes a hard dependency on the optional ``hermes_cli`` package (a - standalone/agent-less WebUI must keep full git functionality). See #5692. - """ - if sys.platform == "win32": - return getattr(subprocess, "CREATE_NO_WINDOW", 0) - return 0 - - +from api.subprocess_utils import windows_hide_flags from api.workspace import rmtree_anchored, safe_resolve_ws, unlink_anchored +logger = logging.getLogger(__name__) + GIT_TIMEOUT = 5 GIT_REMOTE_TIMEOUT = 60 @@ -263,7 +249,7 @@ def _run_git( text=True, timeout=timeout, env=run_env, - creationflags=_windows_hide_flags(), + creationflags=windows_hide_flags(), ) except subprocess.TimeoutExpired as exc: raise GitWorkspaceError("Git command timed out", "timeout") from exc @@ -306,7 +292,7 @@ def _config_names_for_scope( capture_output=True, timeout=GIT_TIMEOUT, env=env, - creationflags=_windows_hide_flags(), + creationflags=windows_hide_flags(), ) if result.returncode not in {0, 1}: if ignore_unsupported: diff --git a/api/worktrees.py b/api/worktrees.py index 761ed3fd..3fe5de88 100644 --- a/api/worktrees.py +++ b/api/worktrees.py @@ -10,6 +10,8 @@ from pathlib import Path import logging +from api.subprocess_utils import windows_hide_flags + logger = logging.getLogger(__name__) @@ -21,6 +23,7 @@ def _run_git(args: list[str], cwd: str | Path, timeout: float = 2) -> subprocess capture_output=True, timeout=timeout, check=False, + creationflags=windows_hide_flags(), ) @@ -326,6 +329,7 @@ def find_git_repo_root(workspace: str | Path) -> Path: capture_output=True, timeout=5, check=False, + creationflags=windows_hide_flags(), ) except (OSError, subprocess.TimeoutExpired) as exc: raise ValueError("Workspace is not inside a git repository") from exc diff --git a/static/boot.js b/static/boot.js index afa2e1b0..7bc20206 100644 --- a/static/boot.js +++ b/static/boot.js @@ -3513,7 +3513,7 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer; const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1'; if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){ const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':''); - api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false})}).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{}); + api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false}),timeoutMs:300000}).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{}); } const _bootActiveProfileUnauthRedirectBudget=(()=>{ const markerKey='hermes-webui-active-profile-bootstrap-401'; diff --git a/static/panels.js b/static/panels.js index 487645f9..b00c8937 100644 --- a/static/panels.js +++ b/static/panels.js @@ -12128,7 +12128,7 @@ async function checkUpdatesNow(channelOverride){ // saved setting. (Fable UX gate.) const _checkBody={force:true}; if(channelOverride==='stable'||channelOverride==='experimental') _checkBody.channel=channelOverride; - const data=await api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:60000}); + const data=await api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:300000}); if(data.disabled){ if(status){status.textContent=t('settings_updates_disabled');status.style.color='var(--muted)';} } else { diff --git a/tests/test_4961_url_query_prefill.py b/tests/test_4961_url_query_prefill.py index e4a22005..796b9264 100644 --- a/tests/test_4961_url_query_prefill.py +++ b/tests/test_4961_url_query_prefill.py @@ -344,7 +344,12 @@ console.log(JSON.stringify({ prefill_pos = BOOT_JS.find( "const prefillIntent=(typeof _composerPrefillIntentFromLocation==='function')?_composerPrefillIntentFromLocation():null;" ) - first_await_pos = BOOT_JS.find("const s=await api('/api/settings');", prefill_pos) + first_await_match = re.search( + r"const s=await api\('/api/settings'(?:,\{[^)]*\})?\);", + BOOT_JS[prefill_pos:], + ) + assert first_await_match, "settings fetch with optional options not found after prefill intent" + first_await_pos = prefill_pos + first_await_match.start() active_profile_pos = BOOT_JS.find( "const activeProfileState = await _resolveActiveProfileBootstrapState();", first_await_pos, diff --git a/tests/test_5167_busy_input_mode_boot.py b/tests/test_5167_busy_input_mode_boot.py index 20ae2cc6..8a07ab3d 100644 --- a/tests/test_5167_busy_input_mode_boot.py +++ b/tests/test_5167_busy_input_mode_boot.py @@ -19,6 +19,7 @@ flipped the default to 'steer'; this file was updated for the rename while preserving the persistence-mirror guarantees (the load-failure path must still honor the saved preference, not clobber it with a hardcoded default). """ +import re from pathlib import Path ROOT = Path(__file__).parent.parent @@ -47,8 +48,12 @@ class TestEagerDefault: eager_idx = BOOT_JS.find("window._defaultMessageMode=_readPersistedDefaultMessageMode()") assert eager_idx >= 0, "eager default assignment not found" # The async IIFE awaits /api/settings; the success-path assignment lives inside it. - await_idx = BOOT_JS.find("const s=await api('/api/settings')") - assert await_idx >= 0, "async settings fetch not found" + await_match = re.search( + r"const s=await api\('/api/settings'(?:,\{[^)]*\})?\)", + BOOT_JS, + ) + assert await_match, "async settings fetch with optional options not found" + await_idx = await_match.start() assert eager_idx < await_idx, ( "the eager window._defaultMessageMode default must be set BEFORE the async " "/api/settings fetch — otherwise the boot-window race (#5167) persists" diff --git a/tests/test_api_timeout.py b/tests/test_api_timeout.py index ce3b1c85..81e8d1e5 100644 --- a/tests/test_api_timeout.py +++ b/tests/test_api_timeout.py @@ -9,6 +9,7 @@ import textwrap from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +BOOT_JS = ROOT / "static" / "boot.js" WORKSPACE_JS = ROOT / "static" / "workspace.js" SESSIONS_JS = ROOT / "static" / "sessions.js" UI_JS = ROOT / "static" / "ui.js" @@ -208,12 +209,15 @@ def test_api_has_default_timeout_and_per_call_override_contract(): def test_update_flows_keep_explicit_longer_timeouts(): - """Legitimately long update flows should not inherit the generic 30s guard.""" + """Network-bound update checks need a single-attempt 300s latency budget.""" + boot = _source(BOOT_JS) src = _source(UI_JS) panels = _source(PANELS_JS) # /api/updates/check builds its body in a _checkBody var (to optionally add - # an explicit channel), but must still carry the 60s timeout override. - assert "api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:60000})" in panels + # an explicit channel). Both boot and manual callers need enough time for + # the endpoint's bounded fetches; api() does not retry ordinary timeouts. + assert "api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false}),timeoutMs:300000})" in boot + assert "api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:300000})" in panels assert "api('/api/updates/summary',{method:'POST',body:JSON.stringify({updates:scopedUpdates,target:target||null}),timeoutMs:60000})" in src # apply/force now build their body inline to optionally carry the offered # channel (Codex debounce-race fix), but MUST still carry the 120s override. diff --git a/tests/test_git_subprocess_windows_flags.py b/tests/test_git_subprocess_windows_flags.py new file mode 100644 index 00000000..dd8adcc2 --- /dev/null +++ b/tests/test_git_subprocess_windows_flags.py @@ -0,0 +1,132 @@ +"""Inventory coverage for windowless production Git subprocesses on Windows.""" + +from __future__ import annotations + +import ast +from collections import Counter +from pathlib import Path +from types import SimpleNamespace + + +ROOT = Path(__file__).resolve().parents[1] +EXPECTED_GIT_RUNS = Counter( + { + "api/agent_runtime.py": 3, + "api/rollback.py": 4, + "api/updates.py": 1, + "api/workspace.py": 1, + "api/workspace_git.py": 2, + "api/worktrees.py": 2, + } +) + + +def _is_subprocess_run(node: ast.Call) -> bool: + """Return whether ``node`` is a direct ``subprocess.run(...)`` boundary.""" + return ( + isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "subprocess" + and node.func.attr == "run" + ) + + +def _git_argv_kind(node: ast.AST) -> str | None: + """Recognize the production argv forms used by direct Git subprocess calls.""" + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + return _git_argv_kind(node.left) + if isinstance(node, (ast.List, ast.Tuple)) and node.elts: + executable = node.elts[0] + if isinstance(executable, ast.Constant) and executable.value == "git": + return "git" + if isinstance(executable, ast.Name) and executable.id in {"git", "git_executable"}: + return executable.id + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "_hardened_git_argv" + ): + return node.func.id + return None + + +def _production_git_runs() -> list[tuple[str, ast.Call]]: + """Inventory every recognizable direct Git ``subprocess.run`` in production code.""" + calls: list[tuple[str, ast.Call]] = [] + production_files = [*sorted((ROOT / "api").glob("*.py")), ROOT / "bootstrap.py", ROOT / "server.py"] + for path in production_files: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + relative_path = path.relative_to(ROOT).as_posix() + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and _is_subprocess_run(node) + and node.args + and _git_argv_kind(node.args[0]) is not None + ): + calls.append((relative_path, node)) + return calls + + +def test_git_subprocess_inventory_uses_shared_windows_flags() -> None: + """All production Git boundaries must use the one dependency-light helper.""" + calls = _production_git_runs() + assert Counter(path for path, _node in calls) == EXPECTED_GIT_RUNS + + missing_shared_helper: list[str] = [] + for path, node in calls: + creationflags = next( + (keyword.value for keyword in node.keywords if keyword.arg == "creationflags"), + None, + ) + if not ( + isinstance(creationflags, ast.Call) + and isinstance(creationflags.func, ast.Name) + and creationflags.func.id == "windows_hide_flags" + ): + missing_shared_helper.append(f"{path}:{node.lineno}") + + assert not missing_shared_helper, ( + "Git subprocess.run calls must pass creationflags=windows_hide_flags(): " + + ", ".join(missing_shared_helper) + ) + + helper_definitions: list[str] = [] + for path in sorted((ROOT / "api").glob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + if any( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name in {"windows_hide_flags", "_windows_hide_flags"} + for node in ast.walk(tree) + ): + helper_definitions.append(path.relative_to(ROOT).as_posix()) + assert helper_definitions == ["api/subprocess_utils.py"] + + +def test_windows_hide_flags_returns_win32_sentinel(monkeypatch) -> None: + """The helper must expose the platform's CREATE_NO_WINDOW value on Win32.""" + from api import subprocess_utils + + sentinel = 0x08000000 + monkeypatch.setattr(subprocess_utils, "sys", SimpleNamespace(platform="win32")) + monkeypatch.setattr( + subprocess_utils, + "subprocess", + SimpleNamespace(CREATE_NO_WINDOW=sentinel), + ) + + assert subprocess_utils.windows_hide_flags() == sentinel + + +def test_windows_hide_flags_is_zero_on_posix(monkeypatch) -> None: + """Passing creationflags=0 must remain a genuine no-op off Windows.""" + from api import subprocess_utils + + monkeypatch.setattr(subprocess_utils, "sys", SimpleNamespace(platform="linux")) + monkeypatch.setattr( + subprocess_utils, + "subprocess", + SimpleNamespace(CREATE_NO_WINDOW=0x08000000), + ) + + assert subprocess_utils.windows_hide_flags() == 0 diff --git a/tests/test_workspace_blank_page_fix.py b/tests/test_workspace_blank_page_fix.py index 05f77f17..6418e50b 100644 --- a/tests/test_workspace_blank_page_fix.py +++ b/tests/test_workspace_blank_page_fix.py @@ -61,8 +61,12 @@ class TestBootJsProfileDefaultWorkspace: src = read('static/boot.js') # Find the settings fetch and the _profileDefaultWorkspace ASSIGNMENT # (the if(s.default_workspace) line, not usages elsewhere in the file) - settings_idx = src.find("await api('/api/settings')") - assert settings_idx != -1, "await api('/api/settings') not found in boot.js" + settings_match = re.search( + r"await api\('/api/settings'(?:,\{[^)]*\})?\)", + src, + ) + assert settings_match, "await api('/api/settings', optionalOptions) not found in boot.js" + settings_idx = settings_match.start() # Find the assignment specifically — it uses 's.default_workspace' ws_assign_idx = src.find('S._profileDefaultWorkspace=s.default_workspace') assert ws_assign_idx != -1, "S._profileDefaultWorkspace assignment not found in boot.js" diff --git a/tests/test_workspace_git.py b/tests/test_workspace_git.py index 98e0838d..9647abf7 100644 --- a/tests/test_workspace_git.py +++ b/tests/test_workspace_git.py @@ -2518,12 +2518,12 @@ def test_dirty_worktree_uses_filter_neutralization(tmp_path): def test_run_git_passes_windows_hide_flags(monkeypatch, tmp_path): - """_run_git must pass creationflags=_windows_hide_flags() so git child + """_run_git must pass creationflags=windows_hide_flags() so git child processes don't accumulate visible console windows on Windows (#5692). windows_hide_flags() is 0 on non-Windows, so this is a safe no-op there; the test asserts the kwarg is wired regardless of platform.""" import api.workspace_git as wg - from api.workspace_git import _windows_hide_flags + from api.subprocess_utils import windows_hide_flags repo = _init_repo(tmp_path / "repo") (repo / "f.txt").write_text("x\n", encoding="utf-8") @@ -2539,17 +2539,17 @@ def test_run_git_passes_windows_hide_flags(monkeypatch, tmp_path): monkeypatch.setattr(wg.subprocess, "run", fake_run) wg._run_git(repo, ["rev-parse", "HEAD"]) - assert captured.get("creationflags") == _windows_hide_flags(), ( - "_run_git must pass creationflags=_windows_hide_flags() to subprocess.run" + assert captured.get("creationflags") == windows_hide_flags(), ( + "_run_git must pass creationflags=windows_hide_flags() to subprocess.run" ) def test_config_names_for_scope_passes_windows_hide_flags(monkeypatch, tmp_path): - """_config_names_for_scope must also pass creationflags=_windows_hide_flags() + """_config_names_for_scope must also pass creationflags=windows_hide_flags() (#5692) — the git-config probe spawns a console window on Windows too.""" import re as _re import api.workspace_git as wg - from api.workspace_git import _windows_hide_flags + from api.subprocess_utils import windows_hide_flags repo = _init_repo(tmp_path / "repo") @@ -2570,6 +2570,6 @@ def test_config_names_for_scope_passes_windows_hide_flags(monkeypatch, tmp_path) ignore_unsupported=True, ) - assert captured.get("creationflags") == _windows_hide_flags(), ( - "_config_names_for_scope must pass creationflags=_windows_hide_flags()" + assert captured.get("creationflags") == windows_hide_flags(), ( + "_config_names_for_scope must pass creationflags=windows_hide_flags()" )