Merge pull request #2478 from nesquena/stage-378

Release BI (stage-378): v0.51.85 — 3-PR review-bypass batch — workspace-prefix display leakage fix + release-tag update banner + Slice 3a cancel-control gate RFC
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-05-17 13:05:33 -07:00
commit f1d399b437
8 files changed
+428 -33

No files matched your search

+11
View File
@@ -2,6 +2,17 @@
## [Unreleased]
## [v0.51.85] — 2026-05-17 — Release BI (stage-378 — 3-PR batch — workspace-prefix display leakage fix + release-tag update banner + Slice 3a cancel-control gate RFC)
### Fixed
- **PR #2145** by @swftwolfzyq — Prevent internal `[Workspace::v1: …]` metadata from leaking into the visible user transcript when a failed provider/retry path echoes an optimistic draft followed by the workspace prefix and the real prompt. Adds `_looks_like_current_user_turn(msg, msg_text)` to match the current human turn even when the internal tag appears mid-text — only when the text after the sentinel exactly matches the submitted prompt — and routes the merge/dedupe/display-normalization paths in `_merge_display_messages_after_agent_result` and `_find_current_user_turn` through it. Replaces `_has_new_assistant_reply` length-delta gating in `_periodic_checkpoint` with the new `_assistant_reply_added_after_current_turn` helper, which slices result messages from the current-turn position before counting assistant deltas — silent/no-response failures are now detected from the current turn alone instead of being masked by prior assistant content.
- **PR #2146** by @swftwolfzyq — Track WebUI update checks against the latest published release tag instead of every commit on the upstream branch, so operators who only want released versions stop seeing noisy update banners for post-release development commits. Falls back to branch-based detection when no release tags are available. Splits `git describe --dirty` into a fast base describe plus a bounded dirty probe so WSL-mounted workspaces never block version detection on a slow `--dirty` walk, keeping the base version visible even if the dirty probe times out.
### Documentation
- **PR #2469** by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 2 seam shipped by marking Slice 2 complete and defining the first Slice 3a cancel-control gate. The new gate scopes Stop Generation through `RuntimeAdapter.cancel_run(...)` only, pins behavior-preserving cancellation, journal/status coherence, idempotent duplicate cancel, and explicit non-goals for approval/clarify, queue/goal, runner/sidecar, and public chat-start response changes.
## [v0.51.84] — 2026-05-17 — Release BH (stage-377 — 1-PR Docker hygiene — agent-image upgrade docs + read-only WebUI source mount + chown prune widening)
### Changed
+72 -11
View File
@@ -974,6 +974,8 @@ def _message_text(value) -> str:
_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*')
_LEGACY_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace:[^\]]+\]\s*')
_WORKSPACE_PREFIX_ANY_RE = re.compile(r'\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*')
_LEGACY_WORKSPACE_PREFIX_ANY_RE = re.compile(r'\[Workspace:[^\]]+\]\s*')
def _escape_workspace_prefix_path(path: str) -> str:
@@ -993,6 +995,27 @@ def _strip_workspace_prefix(text: str, *, include_legacy: bool = False) -> str:
return stripped.strip()
def _looks_like_current_user_turn(msg, msg_text) -> bool:
"""Match the current human turn even if an internal workspace tag leaked mid-text.
Normal model-facing messages start with the workspace sentinel. A failed
retry/merge path can also return an optimistic draft followed by the
sentinel and the real prompt. Only treat that shape as the current turn
when the text after the sentinel exactly matches the submitted prompt.
"""
if not isinstance(msg, dict) or msg.get('role') != 'user':
return False
needle = " ".join(str(msg_text or '').split())
if not needle:
return False
text = _message_text(msg.get('content', ''))
candidates = [_strip_workspace_prefix(text, include_legacy=True)]
for pattern in (_WORKSPACE_PREFIX_ANY_RE, _LEGACY_WORKSPACE_PREFIX_ANY_RE):
for match in pattern.finditer(text):
candidates.append(text[match.end():])
return any(" ".join(str(candidate or '').split()) == needle for candidate in candidates)
def _first_exchange_snippets(messages):
"""Return (first_user_text, first_assistant_text) snippets for title generation.
@@ -2113,6 +2136,8 @@ def _find_current_user_turn(messages, msg_text):
if not isinstance(msg, dict) or msg.get('role') != 'user':
continue
fallback = idx
if _looks_like_current_user_turn(msg, msg_text):
return idx
text = " ".join(
_strip_workspace_prefix(
_message_text(msg.get('content', '')),
@@ -2258,10 +2283,15 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
seen = {_message_identity(m) for m in merged}
current_user_key = _message_identity({'role': 'user', 'content': msg_text})
current_user_in_candidates = any(
_message_identity(m) == current_user_key for m in candidates
_message_identity(m) == current_user_key or _looks_like_current_user_turn(m, msg_text)
for m in candidates
)
current_user_already_checkpointed = bool(
merged and _message_identity(merged[-1]) == current_user_key
merged
and (
_message_identity(merged[-1]) == current_user_key
or _looks_like_current_user_turn(merged[-1], msg_text)
)
)
if (
current_user_key is not None
@@ -2286,11 +2316,14 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
for msg in candidates:
key = _message_identity(msg)
is_current_user_turn = _looks_like_current_user_turn(msg, msg_text)
if (
key is not None
and key == current_user_key
((key is not None and key == current_user_key) or is_current_user_turn)
and merged
and _message_identity(merged[-1]) == key
and (
_message_identity(merged[-1]) == current_user_key
or _looks_like_current_user_turn(merged[-1], msg_text)
)
):
# Eager session-save mode can checkpoint the current user turn
# before the agent runs. When the agent returns that same user turn
@@ -2312,7 +2345,11 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
if _is_context_compression_marker(msg) and key is not None and key in seen:
continue
display_msg = msg
if key is not None and key == current_user_key and isinstance(msg, dict) and msg.get('role') == 'user':
if (
((key is not None and key == current_user_key) or is_current_user_turn)
and isinstance(msg, dict)
and msg.get('role') == 'user'
):
display_msg = copy.deepcopy(msg)
display_msg['content'] = msg_text
merged.append(copy.deepcopy(display_msg))
@@ -2321,6 +2358,24 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
return merged
def _assistant_reply_added_after_current_turn(result_messages, previous_context, msg_text) -> bool:
"""Return True only when the just-finished turn produced assistant text."""
result_messages = list(result_messages or [])
previous_context = list(previous_context or [])
if _messages_have_prefix(result_messages, previous_context):
candidates = result_messages[len(previous_context):]
else:
current_user_idx = _find_current_user_turn(result_messages, msg_text)
candidates = result_messages[current_user_idx + 1:] if current_user_idx is not None else result_messages
return any(
isinstance(m, dict)
and m.get('role') == 'assistant'
and not m.get('_error')
and str(m.get('content') or '').strip()
for m in candidates
)
_TOOL_RESULT_SNIPPET_MAX = 4000
@@ -3943,13 +3998,19 @@ def _run_agent_streaming(
# an empty final_response without raising — the stream would end with
# a done event containing zero assistant messages, leaving the user with
# no feedback. Emit an apperror so the client shows an inline error.
#
# Only check NEW messages added by this turn — result.get('messages')
# includes the full conversation history, so checking all of them would
# match assistant content from prior turns and mask the real failure.
# Keep the current-turn assistant detection aligned with the
# display-merge logic. A compacted or replayed result payload
# is not always a simple append-only suffix, so use the
# workspace-aware helper from this branch while still
# preserving the pre-turn length for downstream self-heal
# checks introduced on master.
_all_result_messages = result.get('messages') or []
_prev_len = len(_previous_context_messages)
_assistant_added = _has_new_assistant_reply(_all_result_messages, _prev_len)
_assistant_added = _assistant_reply_added_after_current_turn(
_all_result_messages,
_previous_context_messages,
msg_text,
)
# _token_sent tracks whether on_token() was called (any streamed text)
if not _assistant_added and not _token_sent:
if cancel_event.is_set():
+98 -12
View File
@@ -1,8 +1,8 @@
"""
Hermes Web UI -- Self-update checker.
Checks if the webui and hermes-agent git repos are behind their upstream
branches. Results are cached server-side (30-min TTL) so git fetch runs
Checks if the webui and hermes-agent git repos are behind their latest
release tags. Results are cached server-side (30-min TTL) so git fetch runs
at most twice per hour regardless of client count.
Skips repos that are not git checkouts (e.g. Docker baked images where
@@ -86,6 +86,24 @@ def _run_git(args, cwd, timeout=10):
return f'git failed to start: {exc}', False
def _dirty_suffix(path: Path, timeout=1) -> str:
"""Return a best-effort ``-dirty`` suffix without blocking version display."""
out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout)
if ok:
return ""
# diff-index exits 1 with no output for a dirty tree. Timeouts and real git
# failures include a diagnostic; skip the suffix so the base version remains.
return "-dirty" if not out else ""
def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | None:
"""Return a fast git version string for a checkout, if available."""
out, ok = _run_git(['describe', '--tags', '--always'], path, timeout=timeout)
if not (ok and out):
return None
return out + _dirty_suffix(path, timeout=dirty_timeout)
def _detect_webui_version() -> str:
"""Detect the running WebUI version from git or a baked-in fallback file.
@@ -101,8 +119,8 @@ def _detect_webui_version() -> str:
"""
# Timeout capped at 3s: git describe on a healthy local repo is <50ms;
# a 10s stall on import (NFS-mounted .git, broken git binary) is unacceptable.
out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], REPO_ROOT, timeout=3)
if ok and out:
out = _describe_git_version(REPO_ROOT)
if out:
return out
# Docker / baked-image fallback: api/_version.py written by CI at build time.
@@ -145,8 +163,8 @@ def _detect_agent_version() -> str:
# Symmetric with _detect_webui_version() above — `--dirty` flags a
# locally-modified checkout so operators can see when their agent has
# uncommitted changes vs a clean tag. Per Opus advisor on stage-293.
out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], _AGENT_DIR, timeout=3)
if ok and out:
out = _describe_git_version(Path(_AGENT_DIR))
if out:
return out
return 'not detected'
@@ -211,15 +229,65 @@ def _detect_default_branch(path):
return 'master'
def _check_repo(path, name):
"""Check if a git repo is behind its upstream. Returns dict or None."""
if path is None or not (path / '.git').exists():
def _release_tags(path):
"""Return release tags newest-first, using the repo's version-sort order."""
out, ok = _run_git(['tag', '--list', 'v*', '--sort=-v:refname'], path)
if not (ok and out):
return []
return [line.strip() for line in out.splitlines() if line.strip()]
def _current_release_tag(path):
"""Return the latest release tag reachable from HEAD, if one exists."""
out, ok = _run_git(['describe', '--tags', '--abbrev=0'], path)
return out if ok and out else None
def _release_gap(tags, current, latest):
"""Count release tags between current and latest in a newest-first list."""
if not latest or current == latest:
return 0
if current in tags:
return tags.index(current)
return 1
def _check_repo_release(path, name):
"""Check if a git repo is behind its latest published release tag."""
tags = _release_tags(path)
if not tags:
return None
latest_tag = tags[0]
current_tag = _current_release_tag(path)
behind = _release_gap(tags, current_tag, latest_tag)
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
remote_url = _normalize_remote_url(remote_url)
return {
'name': name,
'behind': behind,
# GitHub compare URLs accept tag names, and tag-to-tag links are the
# clearest "what changed in this release?" view for operators.
'current_sha': current_tag,
'latest_sha': latest_tag,
'branch': latest_tag,
'repo_url': remote_url,
'release_based': True,
'current_version': current_tag,
'latest_version': latest_tag,
}
def _check_repo_branch(path, name, *, fetch=True):
"""Fallback: check if a git repo is behind its upstream branch."""
# Fetch latest from origin (network call, cached by TTL)
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
if not fetch_ok:
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
if fetch:
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
if not fetch_ok:
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
# Use the current branch's upstream tracking branch, not the repo default.
# This avoids false "N updates behind" alerts when the user is on a feature
@@ -283,6 +351,24 @@ def _check_repo(path, name):
}
def _check_repo(path, name):
"""Check if a git repo is behind its latest release. Returns dict or None."""
if path is None or not (path / '.git').exists():
return None
# Fetch tags first so update prompts track published releases, not every
# development commit that lands on master/main after the latest release.
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags'], path, timeout=15)
if not fetch_ok:
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
release_info = _check_repo_release(path, name)
if release_info is not None:
return release_info
return _check_repo_branch(path, name, fetch=False)
def check_for_updates(force=False):
"""Return cached update status for webui and agent repos."""
global _check_in_progress
+61 -3
View File
@@ -292,9 +292,11 @@ Success criterion:
### Slice 2: Adapter interface over the journaled legacy path
Status as of 2026-05-16: ready for a planning/adapter-seam PR after the active
Slice 1 validation pass and the #2313 selected-session stream cap. Slice 2 should
still be a reversible boundary change, not a sidecar or execution-ownership move.
Status as of 2026-05-17: shipped. PR #2416 defined the adapter-seam contract,
PR #2424 added the default-off `LegacyJournalRuntimeAdapter` seam, and PR #2438
kept `/api/chat/start` response shape identical between `legacy-direct` and the
flagged `legacy-journal` path. Slice 2 remains a reversible boundary change, not
a sidecar or execution-ownership move.
Scope:
@@ -405,6 +407,13 @@ execution-survives-WebUI-restart gate remains deferred to Slice 4.
### Slice 3: Control migration
Status as of 2026-05-17: not started. Slice 3 should begin with cancel only.
Cancel is the smallest control-plane migration because it already has one clear
browser affordance, one active-run target, and an existing legacy handler to
delegate to. Approval, clarify, queue/continue, and goal are intentionally held
behind a successful cancel-control slice because they carry more callback and
state-lifetime risk.
Scope:
- move cancel first,
@@ -416,6 +425,55 @@ Scope:
Revert path: per-control feature flags or route-level fallback to legacy control
handlers.
#### Slice 3a: Cancel control gate
The first control migration should route Stop Generation through the
`RuntimeAdapter.cancel_run(...)` seam while preserving the current legacy cancel
semantics. It should not introduce a new cancellation registry, worker-owned
signal table, or sidecar boundary. During this slice, `cancel_run` is still a
protocol translator over the existing cancellation path.
Acceptance properties:
1. **Same visible result as legacy Stop Generation.** A cancelled turn still
emits one terminal cancelled/interrupted state and preserves any already
streamed partial assistant content according to the existing cancellation
contract.
2. **Adapter flag is behavior-preserving.** With
`HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal`, Stop Generation uses
`RuntimeAdapter.cancel_run(...)`; with the default `legacy-direct` path, the
current route remains available as fallback.
3. **No new runtime-surrogate state.** The implementation must not add a second
`CANCEL_FLAGS`-like map, cached `AIAgent` table, long-lived queue, or local
callback registry inside the main WebUI process.
4. **Journal/status coherence.** After cancellation, replay and session reload
classify the turn as cancelled/interrupted rather than stale/unknown, and the
terminal state is visible through the same journal/session diagnostic surface
used by Slice 1.
5. **Idempotent duplicate cancel.** Repeating cancel for the same run should be
safe: one terminal result is recorded, later attempts return a bounded
`ControlResult` such as `not-active` rather than creating extra terminal
events or resurrecting stale stream state.
Suggested regression coverage:
- a route/source test proving the flagged cancel path calls the adapter seam and
the default path remains the legacy route;
- an adapter unit test proving `cancel_run` delegates exactly once and returns a
bounded `ControlResult` for unsupported/not-active runs;
- an existing cancellation preservation suite run (for example the partial-output
and cancelled-turn status tests) under the adapter flag or an equivalent
synthetic harness;
- a replay/session-load assertion that a cancelled run's terminal state remains
classifiable from the journal/session surface after reload.
Non-goals for Slice 3a:
- no approval or clarify migration;
- no queue/continue or goal migration;
- no runner process, sidecar, or execution-survives-WebUI-restart claim;
- no public `/api/chat/start` response-shape expansion for adapter-only fields.
### Slice 4: Runner process / sidecar boundary
Explicitly deferred until Slice 1 has worked in production for at least one
+5 -2
View File
@@ -4119,8 +4119,11 @@ async function refreshSession() {
// ── Update banner ──
function _formatUpdateTargetStatus(label,info){
if(!info||!(info.behind>0)) return null;
const branch=info.branch?` (${info.branch})`:'';
return `${label}${branch}: ${info.behind} update${info.behind>1?'s':''}`;
const release=(info.release_based&&info.latest_version)
?` (${info.current_version||'unknown'} -> ${info.latest_version})`
:(info.branch?` (${info.branch})`:'');
const noun=info.release_based?'release':'update';
return `${label}${release}: ${info.behind} ${noun}${info.behind>1?'s':''}`;
}
function _isSafeUpdateCompareUrl(url){
if(!url||!/^https?:\/\//i.test(url)) return false;
@@ -2,6 +2,7 @@ from api.models import Session
import contextlib
from api.streaming import (
_assistant_reply_added_after_current_turn,
_context_messages_for_new_turn,
_merge_display_messages_after_agent_result,
_sanitize_messages_for_api,
@@ -78,6 +79,96 @@ def test_workspace_prefixed_current_user_after_compaction_is_not_duplicated():
assert sum(1 for m in merged if m.get("role") == "user" and "Ok, mache weiter" in m.get("content", "")) == 1
def test_embedded_workspace_prefixed_current_user_delta_is_deduped():
"""A failed provider path can echo draft text before the workspace tag."""
current = "正常来说,chrome for testing 不是有独立的profile嘛,为什么会有 user-data-dir 冲突的问题?"
previous_display = [
{"role": "user", "content": "older prompt"},
{"role": "assistant", "content": "older answer"},
{"role": "user", "content": "正常来说,chrome"},
{"role": "user", "content": current},
]
previous_context = [
{"role": "user", "content": "older prompt"},
{"role": "assistant", "content": "older answer"},
]
result_messages = previous_context + [
{
"role": "user",
"content": (
"正常来说,chrome\n\n"
"[Workspace::v1: /mnt/e/vscode_workspace/hermes_workspace]\n"
f"{current}"
),
},
]
merged = _merge_display_messages_after_agent_result(
previous_display,
previous_context,
result_messages,
current,
)
assert merged == previous_display
assert all("Workspace::v1" not in str(m.get("content") or "") for m in merged)
def test_embedded_workspace_prefixed_current_user_delta_displays_clean_prompt():
current = "正常来说,chrome for testing 不是有独立的profile嘛,为什么会有 user-data-dir 冲突的问题?"
previous_display = [
{"role": "user", "content": "older prompt"},
{"role": "assistant", "content": "older answer"},
]
previous_context = list(previous_display)
result_messages = previous_context + [
{
"role": "user",
"content": (
"正常来说,chrome\n\n"
"[Workspace::v1: /mnt/e/vscode_workspace/hermes_workspace]\n"
f"{current}"
),
},
{"role": "assistant", "content": "Chrome for Testing 本身没有固定独立 profile。"},
]
merged = _merge_display_messages_after_agent_result(
previous_display,
previous_context,
result_messages,
current,
)
assert [m["content"] for m in merged[-2:]] == [
current,
"Chrome for Testing 本身没有固定独立 profile。",
]
assert all("Workspace::v1" not in str(m.get("content") or "") for m in merged)
def test_assistant_added_detection_ignores_prior_history():
previous_context = [
{"role": "user", "content": "older prompt"},
{"role": "assistant", "content": "older answer"},
]
current = "new prompt"
result_messages = previous_context + [
{"role": "user", "content": f"[Workspace::v1: /tmp/project]\n{current}"},
]
assert not _assistant_reply_added_after_current_turn(
result_messages,
previous_context,
current,
)
assert _assistant_reply_added_after_current_turn(
result_messages + [{"role": "assistant", "content": "new answer"}],
previous_context,
current,
)
def test_compacted_agent_result_keeps_old_prompts_and_appends_current_turn():
previous_display = [
{"role": "user", "content": "first prompt that must remain visible"},
+59 -2
View File
@@ -182,6 +182,61 @@ class TestUpdateChecker:
assert result['repo_url'] == 'https://github.com/nesquena/hermes-webui'
def test_release_check_ignores_post_release_branch_commits(self, tmp_path, monkeypatch):
import api.updates as upd
(tmp_path / '.git').mkdir()
def fake_run(args, cwd, timeout=10):
if args[0] == 'fetch':
return '', True
if args[:3] == ['tag', '--list', 'v*']:
return 'v2026.5.7\nv2026.4.30', True
if args[:3] == ['describe', '--tags', '--abbrev=0']:
return 'v2026.5.7', True
if args[:2] == ['remote', 'get-url']:
return 'https://github.com/NousResearch/hermes-agent.git', True
if args[:2] == ['rev-parse', '--abbrev-ref']:
return 'origin/main', True
if args[:2] == ['rev-list', '--count']:
return '16', True
if args[0] == 'merge-base':
return '3800972dd', True
return '', False
monkeypatch.setattr(upd, '_run_git', fake_run)
result = upd._check_repo(tmp_path, 'agent')
assert result['release_based'] is True
assert result['current_version'] == 'v2026.5.7'
assert result['latest_version'] == 'v2026.5.7'
assert result['behind'] == 0
def test_release_check_counts_release_gap(self, tmp_path, monkeypatch):
import api.updates as upd
(tmp_path / '.git').mkdir()
def fake_run(args, cwd, timeout=10):
if args[0] == 'fetch':
return '', True
if args[:3] == ['tag', '--list', 'v*']:
return 'v0.51.35\nv0.51.34\nv0.51.33', True
if args[:3] == ['describe', '--tags', '--abbrev=0']:
return 'v0.51.34', True
if args[:2] == ['remote', 'get-url']:
return 'https://github.com/nesquena/hermes-webui.git', True
return '', False
monkeypatch.setattr(upd, '_run_git', fake_run)
result = upd._check_repo(tmp_path, 'webui')
assert result['release_based'] is True
assert result['current_version'] == 'v0.51.34'
assert result['latest_version'] == 'v0.51.35'
assert result['behind'] == 1
assert result['branch'] == 'v0.51.35'
class TestConflictError:
"""#813 — conflict error must include flag + recovery command."""
@@ -718,10 +773,12 @@ class TestUiJsUpdateBanner:
class TestUpdateBannerUx:
def test_update_banner_includes_repo_branch_labels(self):
def test_update_banner_includes_release_labels(self):
src = read('static/ui.js')
assert 'function _formatUpdateTargetStatus' in src
assert 'info.branch' in src
assert 'info.release_based' in src
assert 'info.current_version' in src
assert 'info.latest_version' in src
assert "_formatUpdateTargetStatus('WebUI',data.webui)" in src
assert "_formatUpdateTargetStatus('Agent',data.agent)" in src
+31 -3
View File
@@ -87,8 +87,8 @@ class TestDetectWebUIVersion:
)
assert result == 'unknown'
def test_git_uses_correct_describe_flags(self, tmp_path):
"""git describe is called with --tags --always --dirty."""
def test_git_uses_fast_describe_flags(self, tmp_path):
"""git describe avoids --dirty so WSL /mnt checkouts do not stall."""
called_args = []
def capture(args, cwd, timeout=10):
@@ -99,7 +99,35 @@ class TestDetectWebUIVersion:
assert called_args, 'git was never called'
assert '--tags' in called_args[0]
assert '--always' in called_args[0]
assert '--dirty' in called_args[0]
assert '--dirty' not in called_args[0]
def test_dirty_check_appends_suffix_when_fast(self, tmp_path):
"""A dirty worktree still gets a suffix when the cheap probe returns quickly."""
calls = []
def fake_run_git(args, cwd, timeout=10):
calls.append((args, timeout))
if args[:3] == ['describe', '--tags', '--always']:
return ('v0.50.123', True)
if args[:2] == ['diff-index', '--quiet']:
return ('', False)
return ('unexpected', False)
result = self._fresh_detect(mock_run_git=fake_run_git, tmp_path=tmp_path)
assert result == 'v0.50.123-dirty'
assert calls[1][0][:2] == ['diff-index', '--quiet']
def test_dirty_check_timeout_does_not_hide_base_version(self, tmp_path):
"""If dirty detection times out, keep the base version instead of unknown."""
def fake_run_git(args, cwd, timeout=10):
if args[:3] == ['describe', '--tags', '--always']:
return ('v0.50.123', True)
if args[:2] == ['diff-index', '--quiet']:
return ('git diff-index --quiet HEAD -- timed out after 1s', False)
return ('unexpected', False)
result = self._fresh_detect(mock_run_git=fake_run_git, tmp_path=tmp_path)
assert result == 'v0.50.123'
# ---------------------------------------------------------------------------