* 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 <ya1yi2fa3@users.noreply.github.com> Co-authored-by: n <a@n>
This commit is contained in:
16 files changed
+218
-40
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
+9
-3
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
+5
-19
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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()"
|
||||
)
|
||||
Reference in new issue
Block a user