diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e44f6f6..05b81fd3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,25 @@
## [Unreleased]
+## [v0.51.101] — 2026-05-20 — Release BY (stage-394 — 2-PR deep-review batch — workspace Git backend + sidebar tab visibility toggle)
+
+### Added
+
+- **PR #2625** by @stocky789 — Add backend Git operations for the workspace panel. New `api/workspace_git.py` module exposes read-only ops (`/api/git/status`, `/api/git/branches`, `/api/git/diff`, `/api/git/commit-message[-selected]`) unconditionally and mutating ops (`stage`, `unstage`, `discard`, `commit`, `commit-selected`, `checkout`, `stash-checkout`, `pull`, `push`) only when `HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1` is set in the environment — default OFF so existing deployments are unaffected. All subprocess calls use `["git", *args]` with `shell=False`, all branch/ref names go through `git check-ref-format --branch` validation before flowing to `git switch -c`, and `subprocess.env` is scrubbed of `GIT_DIR`/`GIT_WORK_TREE`/`GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM`/`GIT_CONFIG_COUNT`/`GIT_CONFIG_PARAMETERS` plus the full `GIT_CONFIG_KEY_*`/`GIT_CONFIG_VALUE_*` namespace before every invocation. `GIT_INDEX_FILE` is intentionally preserved to drive selected-file commits through a private temporary index. Paths are bound to the workspace root via `safe_resolve_ws()` + `Path.relative_to()` enforcement (rejects `..` traversal and symlinked escapes); active-stream gate prevents mutations during a running agent turn. Documented in `docs/workspace-git.md` with the full trust model (hooks-as-RCE warning, default-allowed vs gated lists, env-scrub enumeration). Frontend UI ships in a follow-up PR.
+- **PR #2636** by @FrancescoFarinola — Per-tab sidebar visibility toggle in Settings → Appearance. Power users can hide unused rail tabs (Tasks, Kanban, Skills, Memory, Spaces, Profiles, Todos, Insights, Logs) while keeping Chat and Settings always reachable. Settings is per-profile so each profile can have its own hidden-tabs preference; an inline `
@@ -971,6 +976,11 @@
When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.
+
+
+
+
Choose which tabs appear in the sidebar and rail. Chat and Settings are always visible.
+
diff --git a/static/panels.js b/static/panels.js
index 472a6002..14bdab9c 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -4508,6 +4508,17 @@ function _refreshProfileSwitchBackground(gen){
if (gen !== _profileSwitchGeneration) return;
if (S.session && typeof syncTopbar === 'function') syncTopbar();
}).catch(()=>{});
+ // Reconcile per-profile sidebar tab visibility. hidden_tabs is a per-profile
+ // appearance setting; without this fetch, Profile A's hidden-tabs choice
+ // would remain in effect under Profile B until the user opens Settings.
+ // Stage-394 follow-up to #2636 deep review.
+ Promise.resolve(api('/api/settings')).then(function(s){
+ if (gen !== _profileSwitchGeneration) return;
+ var hidden = (s && Array.isArray(s.hidden_tabs)) ? s.hidden_tabs : [];
+ hidden = hidden.filter(function(x){ return typeof x === 'string' && x.trim(); });
+ if (typeof _setHiddenTabs === 'function') _setHiddenTabs(hidden);
+ if (typeof _applyTabVisibility === 'function') _applyTabVisibility(hidden);
+ }).catch(function(){});
}
async function loadProfilesPanel() {
@@ -5113,6 +5124,86 @@ let _settingsAppearanceAutosaveRetryPayload = null;
let _settingsPreferencesAutosaveTimer = null;
let _settingsPreferencesAutosaveRetryPayload = null;
+// ── Sidebar tab visibility ─────────────────────────────────────────────────
+const _ALWAYS_VISIBLE_TABS = new Set(['chat','settings']);
+const _HIDDEN_TABS_LS_KEY = 'hermes-webui-hidden-tabs';
+
+function _getHiddenTabs(){
+ try{var h=localStorage.getItem(_HIDDEN_TABS_LS_KEY);if(h){var p=JSON.parse(h);if(Array.isArray(p))return p;}}catch(e){}
+ return[];
+}
+
+function _setHiddenTabs(panels){
+ try{localStorage.setItem(_HIDDEN_TABS_LS_KEY,JSON.stringify(panels));}catch(e){}
+}
+
+function _applyTabVisibility(hidden){
+ if(!Array.isArray(hidden)) hidden=[];
+ // Hide/unhide all [data-panel] elements (sidebar-nav buttons + rail buttons)
+ document.querySelectorAll('[data-panel]').forEach(function(el){
+ var panel=el.dataset.panel;
+ if(!panel)return;
+ var shouldHide=hidden.indexOf(panel)!==-1;
+ // Never hide always-visible panels (chat, settings) even if present in hidden_tabs
+ if(_ALWAYS_VISIBLE_TABS.has(panel)) shouldHide=false;
+ el.classList.toggle('nav-tab-hidden',shouldHide);
+ });
+ // If the currently active tab is hidden, switch to chat
+ var activeRail=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]');
+ var activeSidebar=document.querySelector('.sidebar-nav .nav-tab.active[data-panel]');
+ var activeEl=activeRail||activeSidebar;
+ if(activeEl&&activeEl.classList.contains('nav-tab-hidden')){
+ if(typeof switchPanel==='function') switchPanel('chat');
+ }
+}
+
+function _renderTabVisibilityChips(){
+ var container=$('tabVisibilityChips');
+ if(!container)return;
+ var hidden=_getHiddenTabs();
+ // Scan rail buttons to discover all available panels (skip always-visible + dashboard-link)
+ var tabs=document.querySelectorAll('.rail .rail-btn.nav-tab[data-panel]');
+ container.innerHTML='';
+ tabs.forEach(function(tab){
+ var panel=tab.dataset.panel;
+ if(!panel||_ALWAYS_VISIBLE_TABS.has(panel))return;
+ if(tab.classList.contains('dashboard-link'))return;
+ var label=tab.dataset.tooltip||tab.dataset.label||panel;
+ // Capitalize first letter
+ label=label.charAt(0).toUpperCase()+label.slice(1);
+ var chip=document.createElement('button');
+ chip.type='button';
+ chip.className='tab-visibility-chip';
+ var isOff=hidden.indexOf(panel)!==-1;
+ if(isOff)chip.classList.add('chip-off');
+ chip.textContent=label;
+ chip.setAttribute('data-tab-panel',panel);
+ // Use role="switch" + aria-checked instead of aria-pressed so screen
+ // readers narrate "Tasks switch on/off" (matches user mental model) rather
+ // than "Tasks toggle button pressed/not-pressed" (where the polarity is
+ // confusing because chip-off looks like the "off" state).
+ chip.setAttribute('role','switch');
+ chip.setAttribute('aria-checked',isOff?'false':'true');
+ chip.onclick=function(){_toggleTabVisibilityChip(panel);};
+ container.appendChild(chip);
+ });
+}
+
+function _toggleTabVisibilityChip(panel){
+ if(_ALWAYS_VISIBLE_TABS.has(panel))return;
+ var hidden=_getHiddenTabs();
+ var idx=hidden.indexOf(panel);
+ if(idx!==-1){
+ hidden.splice(idx,1);
+ }else{
+ hidden.push(panel);
+ }
+ _setHiddenTabs(hidden);
+ _applyTabVisibility(hidden);
+ _renderTabVisibilityChips();
+ _scheduleAppearanceAutosave();
+}
+
function switchSettingsSection(name){
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='plugins'||name==='system')?name:'conversation';
_settingsSection=section;
@@ -5240,6 +5331,7 @@ function _appearancePayloadFromUi(){
font_size: ($('settingsFontSize')||{}).value || localStorage.getItem('hermes-font-size') || 'default',
session_jump_buttons: !!($('settingsSessionJumpButtons')||{}).checked,
session_endless_scroll: !!($('settingsSessionEndlessScroll')||{}).checked,
+ hidden_tabs: _getHiddenTabs(),
};
}
@@ -5495,6 +5587,18 @@ async function loadSettingsPanel(){
_scheduleAppearanceAutosave();
};
}
+ // Tab visibility chips (dynamically populated from DOM)
+ var hiddenTabs=[];
+ if(Array.isArray(settings.hidden_tabs)){
+ // Server value takes priority — even an empty array means "no tabs hidden"
+ hiddenTabs=settings.hidden_tabs.filter(function(s){return typeof s==='string'&&s.trim();});
+ }else{
+ // Server has no hidden_tabs key — fall back to localStorage
+ hiddenTabs=_getHiddenTabs();
+ }
+ _setHiddenTabs(hiddenTabs);
+ _applyTabVisibility(hiddenTabs);
+ _renderTabVisibilityChips();
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
diff --git a/static/style.css b/static/style.css
index 063cf730..9ddc7cc0 100644
--- a/static/style.css
+++ b/static/style.css
@@ -2774,6 +2774,17 @@ main.main.showing-logs > #mainLogs{display:flex;}
#mainSettings .settings-field > div[style*="color:var(--muted)"],
#mainSettings .settings-field > div[style*="color: var(--muted)"]{font-size:12px;color:var(--muted);line-height:1.5;}
+/* Sidebar tab visibility — hide nav-tab and matching rail-btn when toggled off by user */
+.nav-tab-hidden{display:none!important;}
+
+/* Tab visibility chips in Settings > Appearance */
+.tab-visibility-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px;}
+.tab-visibility-chip{font-size:12px;line-height:1;padding:5px 10px;border-radius:var(--radius-pill,999px);border:1px solid var(--accent,var(--link));background:var(--accent,var(--link));color:#1a1a1a;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s,opacity .15s;white-space:nowrap;user-select:none;}
+.tab-visibility-chip:hover{filter:brightness(0.92);}
+.tab-visibility-chip:focus-visible{outline:2px solid var(--link,var(--accent));outline-offset:2px;}
+.tab-visibility-chip.chip-off{background:transparent;color:var(--muted);border-color:var(--border);font-weight:400;}
+.tab-visibility-chip.chip-off:hover{border-color:var(--muted);color:var(--text);}
+
/* Selects & text/password inputs — uniform. Uses !important to win
over legacy inline styles that were written for the modal era. */
#mainSettings select,
diff --git a/tests/conftest.py b/tests/conftest.py
index 66cf0102..39ab0c1d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -536,6 +536,7 @@ def test_server():
# pytest-side block can't see.
env["HERMES_WEBUI_TEST_NETWORK_BLOCK"] = "1"
env.update({
+ "HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE": "1",
"HERMES_WEBUI_PORT": str(TEST_PORT),
"HERMES_WEBUI_HOST": "127.0.0.1",
"HERMES_WEBUI_STATE_DIR": str(TEST_STATE_DIR),
diff --git a/tests/test_sidebar_tab_visibility.py b/tests/test_sidebar_tab_visibility.py
new file mode 100644
index 00000000..43ce2f66
--- /dev/null
+++ b/tests/test_sidebar_tab_visibility.py
@@ -0,0 +1,163 @@
+"""Regression tests for sidebar tab visibility feature.
+
+Covers backend validation round-trip, frontend static contracts,
+i18n coverage, and the key integration points that have broken before.
+"""
+import json
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
+PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
+BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
+INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
+STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
+I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
+
+
+def test_backend_round_trip_and_validation(monkeypatch, tmp_path):
+ """hidden_tabs defaults to [], saves/reloads, rejects non-list, filters empty strings."""
+ import api.config as config
+ settings_path = tmp_path / "settings.json"
+ monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
+
+ loaded = config.load_settings()
+ assert loaded["hidden_tabs"] == [], "default must be empty list"
+
+ saved = config.save_settings({"hidden_tabs": ["kanban", "insights"]})
+ assert saved["hidden_tabs"] == ["kanban", "insights"]
+ assert config.load_settings()["hidden_tabs"] == ["kanban", "insights"]
+
+ # Non-list is rejected, default preserved
+ bad = config.save_settings({"hidden_tabs": "not-a-list"})
+ assert bad["hidden_tabs"] == ["kanban", "insights"]
+
+ # Empty strings filtered, empty list clears
+ saved = config.save_settings({"hidden_tabs": ["kanban", "", " ", "logs"]})
+ assert saved["hidden_tabs"] == ["kanban", "logs"]
+ cleared = config.save_settings({"hidden_tabs": []})
+ assert cleared["hidden_tabs"] == []
+
+ # Must NOT be in bool keys (would corrupt the list)
+ assert "hidden_tabs" not in config._SETTINGS_BOOL_KEYS
+ assert "hidden_tabs" in config._SETTINGS_ALLOWED_KEYS
+
+
+def test_frontend_static_contracts():
+ """All required HTML, JS, CSS, and boot elements exist with correct wiring."""
+ # HTML: container in Appearance pane
+ assert 'id="tabVisibilityChips"' in INDEX_HTML
+ assert 'data-i18n="settings_label_tab_visibility"' in INDEX_HTML
+ assert 'data-i18n="settings_desc_tab_visibility"' in INDEX_HTML
+ appearance_start = INDEX_HTML.find('id="settingsPaneAppearance"')
+ prefs_start = INDEX_HTML.find('id="settingsPanePreferences"', appearance_start + 1)
+ chips_pos = INDEX_HTML.find('id="tabVisibilityChips"')
+ assert appearance_start < chips_pos < prefs_start, \
+ "tabVisibilityChips must be inside Appearance pane"
+
+ # JS: constants, functions, and wiring
+ assert "_ALWAYS_VISIBLE_TABS" in PANELS_JS
+ assert "'chat'" in PANELS_JS.split("_ALWAYS_VISIBLE_TABS")[1][:80]
+ assert "'settings'" in PANELS_JS.split("_ALWAYS_VISIBLE_TABS")[1][:80]
+ assert "_HIDDEN_TABS_LS_KEY" in PANELS_JS
+ assert "hermes-webui-hidden-tabs" in PANELS_JS
+ for fn in ("_getHiddenTabs", "_setHiddenTabs", "_applyTabVisibility",
+ "_renderTabVisibilityChips", "_toggleTabVisibilityChip"):
+ assert f"function {fn}(" in PANELS_JS, f"panels.js must define {fn}()"
+
+ # Toggle must autosave and respect always-visible tabs
+ toggle_block = PANELS_JS[PANELS_JS.find("function _toggleTabVisibilityChip"):]
+ toggle_body = toggle_block[:toggle_block.find("\nfunction ", 1) or 2000]
+ assert "_scheduleAppearanceAutosave" in toggle_body
+ assert "_ALWAYS_VISIBLE_TABS" in toggle_body
+
+ # Appearance payload must include hidden_tabs
+ payload_block = PANELS_JS[PANELS_JS.find("function _appearancePayloadFromUi"):]
+ payload_body = payload_block[:payload_block.find("\nfunction ", 1) or 2000]
+ assert "hidden_tabs" in payload_body
+ assert "_getHiddenTabs" in payload_body
+
+ # CSS: hidden class and chip styles
+ assert ".nav-tab-hidden" in STYLE_CSS
+ assert "display:none" in STYLE_CSS.split(".nav-tab-hidden")[1][:80].replace(" ", "")
+ assert ".tab-visibility-chip" in STYLE_CSS
+
+ # No flash-prevention script in (DOM elements don't exist at that point)
+ head_end = INDEX_HTML.find("")
+ assert "hermes-webui-hidden-tabs" not in INDEX_HTML[:head_end]
+
+
+def test_boot_restores_visibility_from_localstorage():
+ """boot.js must call _applyTabVisibility at boot time so hidden tabs take effect."""
+ assert "_restoreTabVisibility" in BOOT_JS
+ block = BOOT_JS[BOOT_JS.find("_restoreTabVisibility"):][:1500]
+ assert "_applyTabVisibility" in block, \
+ "boot.js must call _applyTabVisibility so tabs are hidden before first paint"
+
+
+def test_i18n_coverage():
+ """Label and description keys must exist in all locales with matching counts."""
+ label_count = I18N_JS.count("settings_label_tab_visibility")
+ desc_count = I18N_JS.count("settings_desc_tab_visibility")
+ assert label_count >= 11, f"Expected ≥11 locales, found {label_count}"
+ assert desc_count >= 11, f"Expected ≥11 locales, found {desc_count}"
+ assert label_count == desc_count, \
+ f"Label ({label_count}) and desc ({desc_count}) counts must match"
+
+
+def test_backend_rejects_chat_and_settings_in_hidden_tabs(monkeypatch, tmp_path):
+ """Server-side belt-and-suspenders: a malicious POST that tries to hide
+ `chat` or `settings` (the always-visible nav tabs) must be filtered out
+ server-side, not just client-side. The client already applies the same
+ filter at apply time, but the server should not let a tampered payload
+ persist the forbidden values."""
+ import api.config as config
+ settings_path = tmp_path / "settings.json"
+ monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
+
+ saved = config.save_settings({"hidden_tabs": ["chat", "kanban", "settings", "logs"]})
+ assert saved["hidden_tabs"] == ["kanban", "logs"], \
+ "chat and settings must be stripped server-side"
+
+ # Even an all-forbidden payload reduces to empty (not rejected — empty is fine)
+ saved = config.save_settings({"hidden_tabs": ["chat", "settings"]})
+ assert saved["hidden_tabs"] == []
+
+
+def test_profile_switch_reconciles_hidden_tabs():
+ """When a user switches profiles, the new profile's hidden_tabs value
+ must be applied — the per-profile settings.json is the source of truth,
+ not the previous profile's localStorage value. Stage-394 added a
+ /api/settings refetch in _refreshProfileSwitchBackground; verify it stays
+ wired (the API call + the _applyTabVisibility call)."""
+ bg_start = PANELS_JS.find("function _refreshProfileSwitchBackground")
+ assert bg_start >= 0, "_refreshProfileSwitchBackground not found"
+ bg_end = PANELS_JS.find("\nfunction ", bg_start + 1)
+ if bg_end < 0:
+ bg_end = bg_start + 4000
+ bg_body = PANELS_JS[bg_start:bg_end]
+ assert "/api/settings" in bg_body, \
+ "profile-switch background refresh must re-fetch settings for the new profile"
+ assert "_applyTabVisibility" in bg_body, \
+ "profile-switch background refresh must re-apply tab visibility"
+ assert "hidden_tabs" in bg_body, \
+ "profile-switch background refresh must read hidden_tabs from server response"
+
+
+def test_chip_a11y_uses_switch_role_with_aria_checked():
+ """Chips should use role=switch + aria-checked instead of plain
+ aria-pressed. The pressed/not-pressed wording is confusing for a toggle
+ that visually represents an on/off switch; role=switch + aria-checked
+ matches user mental model."""
+ render_block = PANELS_JS[PANELS_JS.find("function _renderTabVisibilityChips"):]
+ body = render_block[:render_block.find("\nfunction ", 1) or 3000]
+ assert "role" in body and "'switch'" in body, \
+ "chip should declare role='switch' for clearer screen-reader narration"
+ assert "aria-checked" in body, "chip should use aria-checked to match role=switch"
+ # Group container also has role=group + aria-labelledby
+ assert 'role="group"' in INDEX_HTML, "chip container needs role=group"
+ assert 'aria-labelledby="tabVisibilityLabel"' in INDEX_HTML, \
+ "chip container needs aria-labelledby pointing at the label"
+ # Focus-visible style exists
+ assert ".tab-visibility-chip:focus-visible" in STYLE_CSS, \
+ "chip needs a :focus-visible style for keyboard nav"
\ No newline at end of file
diff --git a/tests/test_workspace_dir_signature.py b/tests/test_workspace_dir_signature.py
new file mode 100644
index 00000000..696acf75
--- /dev/null
+++ b/tests/test_workspace_dir_signature.py
@@ -0,0 +1,26 @@
+from api.workspace import dir_signature, list_dir
+
+
+def test_directory_signature_is_metadata_only_and_changes_with_entries(tmp_path):
+ (tmp_path / "alpha.txt").write_text("one", encoding="utf-8")
+
+ entries = list_dir(tmp_path, ".")
+ sig1 = dir_signature(tmp_path, ".", entries)
+
+ assert isinstance(sig1, str)
+ assert len(sig1) == 64
+ assert all("mtime_ns" in entry for entry in entries)
+
+ (tmp_path / "beta.txt").write_text("two", encoding="utf-8")
+ entries2 = list_dir(tmp_path, ".")
+ sig2 = dir_signature(tmp_path, ".", entries2)
+
+ assert sig2 != sig1
+
+
+def test_directory_signature_can_be_computed_from_supplied_entries(tmp_path):
+ (tmp_path / "alpha.txt").write_text("one", encoding="utf-8")
+
+ entries = list_dir(tmp_path, ".")
+
+ assert dir_signature(tmp_path, ".", entries) == dir_signature(tmp_path, ".", entries)
diff --git a/tests/test_workspace_git.py b/tests/test_workspace_git.py
new file mode 100644
index 00000000..659a914a
--- /dev/null
+++ b/tests/test_workspace_git.py
@@ -0,0 +1,928 @@
+import json
+import pathlib
+import subprocess
+import types
+import uuid
+import urllib.error
+import urllib.parse
+import urllib.request
+from io import BytesIO
+
+import pytest
+
+from tests._pytest_port import BASE
+
+
+ROOT = pathlib.Path(__file__).parent.parent
+
+
+def _git(cwd, *args):
+ result = subprocess.run(
+ ["git", *args],
+ cwd=str(cwd),
+ shell=False,
+ text=True,
+ capture_output=True,
+ timeout=20,
+ )
+ assert result.returncode == 0, result.stderr or result.stdout
+ return result.stdout
+
+
+def _init_repo(path):
+ path.mkdir(parents=True, exist_ok=True)
+ _git(path, "init")
+ _git(path, "config", "user.email", "hermes-tests@example.invalid")
+ _git(path, "config", "user.name", "Hermes Tests")
+ return path
+
+
+def _commit_all(path, message="initial"):
+ _git(path, "add", ".")
+ _git(path, "commit", "-m", message)
+
+
+def _get(path):
+ try:
+ with urllib.request.urlopen(BASE + path, timeout=10) as r:
+ return json.loads(r.read()), r.status
+ except urllib.error.HTTPError as e:
+ return json.loads(e.read()), e.code
+
+
+def _post(path, body=None):
+ data = json.dumps(body or {}).encode()
+ req = urllib.request.Request(
+ BASE + path,
+ data=data,
+ headers={"Content-Type": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=10) as r:
+ return json.loads(r.read()), r.status
+ except urllib.error.HTTPError as e:
+ return json.loads(e.read()), e.code
+
+
+def _make_session(created_list, ws=None):
+ body = {}
+ if ws:
+ body["workspace"] = str(ws)
+ data, status = _post("/api/session/new", body)
+ assert status == 200
+ sid = data["session"]["session_id"]
+ created_list.append(sid)
+ return sid, pathlib.Path(data["session"]["workspace"])
+
+
+class _CaptureHandler:
+ def __init__(self):
+ self.status = None
+ self.headers = {}
+ self.response_headers = []
+ self.wfile = BytesIO()
+
+ def send_response(self, status):
+ self.status = status
+
+ def send_header(self, key, value):
+ self.response_headers.append((key, value))
+
+ def end_headers(self):
+ pass
+
+ def payload(self):
+ return json.loads(self.wfile.getvalue().decode("utf-8"))
+
+
+def test_git_status_non_git_workspace(tmp_path):
+ from api.workspace_git import git_status
+
+ ws = tmp_path / "plain"
+ ws.mkdir()
+ assert git_status(ws) == {"is_git": False}
+
+
+def test_git_status_handles_staged_unstaged_untracked_deleted_and_renamed(tmp_path):
+ from api.workspace_git import git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ (repo / "delete-me.txt").write_text("bye\n", encoding="utf-8")
+ (repo / "old name.txt").write_text("move\n", encoding="utf-8")
+ _commit_all(repo)
+
+ (repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
+ (repo / "staged.txt").write_text("staged\n", encoding="utf-8")
+ _git(repo, "add", "staged.txt")
+ (repo / "delete-me.txt").unlink()
+ _git(repo, "mv", "old name.txt", "new name.txt")
+ (repo / "untracked space.txt").write_text("new\nfile\n", encoding="utf-8")
+
+ status = git_status(repo)
+ by_path = {item["path"]: item for item in status["files"]}
+
+ assert status["is_git"] is True
+ assert by_path["tracked.txt"]["unstaged"] is True
+ assert by_path["staged.txt"]["staged"] is True
+ assert by_path["delete-me.txt"]["status"] == "D"
+ assert by_path["new name.txt"]["old_path"] == "old name.txt"
+ assert by_path["untracked space.txt"]["untracked"] is True
+ assert by_path["untracked space.txt"]["additions"] == 2
+ assert status["totals"]["changed"] >= 5
+
+
+def test_git_status_reports_ignored_files_without_counting_them_as_changes(tmp_path):
+ from api.workspace_git import git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / ".gitignore").write_text("*.log\nbuild/\n", encoding="utf-8")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+
+ (repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
+ (repo / "debug.log").write_text("ignored log\n", encoding="utf-8")
+ build = repo / "build"
+ build.mkdir()
+ (build / "artifact.txt").write_text("ignored artifact\n", encoding="utf-8")
+
+ status = git_status(repo)
+ by_path = {item["path"]: item for item in status["files"]}
+
+ assert by_path["tracked.txt"]["unstaged"] is True
+ assert by_path["debug.log"]["ignored"] is True
+ assert by_path["debug.log"]["status"] == "Ignored"
+ assert by_path["build/"]["ignored"] is True
+ assert by_path["build/"]["staged"] is False
+ assert by_path["build/"]["untracked"] is False
+ assert status["totals"]["changed"] == 1
+ assert status["totals"]["untracked"] == 0
+
+
+def test_git_status_ignores_crlf_only_worktree_noise(tmp_path):
+ from api.workspace_git import git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8", newline="\n")
+ _commit_all(repo)
+
+ (repo / "tracked.txt").write_text("one\r\ntwo\r\n", encoding="utf-8", newline="")
+
+ raw = _git(repo, "status", "--porcelain", "--", "tracked.txt")
+ assert raw.startswith(" M")
+
+ status = git_status(repo)
+ assert status["totals"]["changed"] == 0
+ assert status["files"] == []
+ assert status["noise_filtering"]["active"] is True
+ assert status["noise_filtering"]["crlf_only"] == 1
+
+
+def test_git_status_keeps_real_edit_with_crlf_endings(tmp_path):
+ from api.workspace_git import git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8", newline="\n")
+ _commit_all(repo)
+
+ (repo / "tracked.txt").write_text("one\r\ntwo\r\nthree\r\n", encoding="utf-8", newline="")
+
+ status = git_status(repo)
+ by_path = {item["path"]: item for item in status["files"]}
+ assert status["totals"]["changed"] == 1
+ assert by_path["tracked.txt"]["unstaged"] is True
+ assert by_path["tracked.txt"]["additions"] == 1
+ assert by_path["tracked.txt"]["deletions"] == 0
+
+
+def test_git_status_ignores_filemode_only_noise(tmp_path):
+ from api.workspace_git import git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ script = repo / "script.sh"
+ script.write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
+ _commit_all(repo)
+
+ _git(repo, "update-index", "--chmod=+x", "script.sh")
+
+ raw = _git(repo, "status", "--porcelain", "--", "script.sh")
+ assert "script.sh" in raw
+
+ status = git_status(repo)
+ assert status["totals"]["changed"] == 0
+ assert status["files"] == []
+ assert status["noise_filtering"]["active"] is True
+
+
+def test_git_status_scopes_nested_workspace_to_that_directory(tmp_path):
+ from api.workspace_git import git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ nested = repo / "app"
+ nested.mkdir()
+ (nested / "inside.txt").write_text("inside\n", encoding="utf-8")
+ (repo / "outside.txt").write_text("outside\n", encoding="utf-8")
+ _commit_all(repo)
+
+ (nested / "inside.txt").write_text("inside\nchanged\n", encoding="utf-8")
+ (repo / "outside.txt").write_text("outside\nchanged\n", encoding="utf-8")
+
+ status = git_status(nested)
+ paths = {item["path"] for item in status["files"]}
+ assert paths == {"inside.txt"}
+
+
+def test_git_diff_generates_untracked_text_diff_and_blocks_escape(tmp_path):
+ from api.workspace_git import GitWorkspaceError, git_diff
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+ (repo / "new file.txt").write_text("hello\nworld\n", encoding="utf-8")
+
+ diff = git_diff(repo, "new file.txt", "unstaged")
+ assert diff["binary"] is False
+ assert "+++ b/new file.txt" in diff["diff"]
+ assert "+hello" in diff["diff"]
+
+ with pytest.raises(GitWorkspaceError):
+ git_diff(repo, "../outside.txt", "unstaged")
+
+
+def test_git_status_reports_untracked_files_inside_directories(tmp_path):
+ from api.workspace_git import git_discard, git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+ nested = repo / "newdir"
+ nested.mkdir()
+ (nested / "a.txt").write_text("hello\n", encoding="utf-8")
+
+ status = git_status(repo)
+ paths = {item["path"] for item in status["files"]}
+ assert "newdir/a.txt" in paths
+ assert "newdir/" not in paths
+
+ git_discard(repo, ["newdir/a.txt"], delete_untracked=True)
+ assert not (nested / "a.txt").exists()
+
+
+def test_git_status_reports_ignored_files_without_counting_them_as_changed(tmp_path):
+ from api.workspace_git import git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / ".gitignore").write_text("*.log\nbuild/\n", encoding="utf-8")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+
+ (repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
+ (repo / "debug.log").write_text("ignored log\n", encoding="utf-8")
+ build = repo / "build"
+ build.mkdir()
+ (build / "artifact.txt").write_text("ignored artifact\n", encoding="utf-8")
+
+ status = git_status(repo)
+ by_path = {item["path"]: item for item in status["files"]}
+
+ assert by_path["tracked.txt"]["unstaged"] is True
+ assert by_path["debug.log"]["ignored"] is True
+ assert by_path["debug.log"]["status"] == "Ignored"
+ assert by_path["debug.log"]["staged"] is False
+ assert by_path["debug.log"]["unstaged"] is False
+ assert by_path["debug.log"]["untracked"] is False
+ assert any(item["ignored"] and item["path"].startswith("build") for item in status["files"])
+ assert status["totals"]["changed"] == 1
+ assert status["totals"]["untracked"] == 0
+
+
+def test_git_diff_large_untracked_file_is_bounded(tmp_path):
+ from api.workspace_git import DIFF_SIZE_LIMIT, git_diff, git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+ large = repo / "large.txt"
+ large.write_text("x" * (DIFF_SIZE_LIMIT + 1), encoding="utf-8")
+
+ status = git_status(repo)
+ by_path = {item["path"]: item for item in status["files"]}
+ assert by_path["large.txt"]["untracked"] is True
+ assert by_path["large.txt"]["additions"] == 0
+
+ diff = git_diff(repo, "large.txt", "unstaged")
+ assert diff["too_large"] is True
+ assert diff["diff"] == ""
+
+
+def test_git_stage_unstage_discard_and_commit(tmp_path):
+ from api.workspace_git import git_commit, git_discard, git_stage, git_status, git_unstage
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+
+ (repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
+ staged = git_stage(repo, ["tracked.txt"])
+ assert staged["totals"]["staged"] == 1
+
+ unstaged = git_unstage(repo, ["tracked.txt"])
+ assert unstaged["totals"]["staged"] == 0
+ assert unstaged["totals"]["unstaged"] == 1
+
+ git_discard(repo, ["tracked.txt"])
+ assert git_status(repo)["totals"]["changed"] == 0
+
+ (repo / "tracked.txt").write_text("one\nthree\n", encoding="utf-8")
+ git_stage(repo, ["tracked.txt"])
+ committed = git_commit(repo, "Update tracked file")
+ assert committed["ok"] is True
+ assert committed["commit"]
+ assert committed["status"]["totals"]["changed"] == 0
+
+
+def test_git_commit_selected_ignores_unrelated_real_index(tmp_path):
+ from api.workspace_git import git_commit_selected, git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "selected.txt").write_text("one\n", encoding="utf-8")
+ (repo / "staged.txt").write_text("alpha\n", encoding="utf-8")
+ _commit_all(repo)
+
+ (repo / "selected.txt").write_text("one\ntwo\n", encoding="utf-8")
+ (repo / "staged.txt").write_text("alpha\nbeta\n", encoding="utf-8")
+ _git(repo, "add", "staged.txt")
+
+ committed = git_commit_selected(repo, "Commit selected only", ["selected.txt"])
+ assert committed["ok"] is True
+ assert committed["paths"] == ["selected.txt"]
+ assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["selected.txt"]
+
+ by_path = {item["path"]: item for item in git_status(repo)["files"]}
+ assert "selected.txt" not in by_path
+ assert by_path["staged.txt"]["staged"] is True
+
+
+def test_git_commit_selected_supports_initial_commit(tmp_path):
+ from api.workspace_git import git_commit_selected, git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "first.txt").write_text("first\n", encoding="utf-8")
+
+ committed = git_commit_selected(repo, "Initial selected commit", ["first.txt"])
+ assert committed["ok"] is True
+ assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["first.txt"]
+ assert git_status(repo)["totals"]["changed"] == 0
+
+
+def test_git_commit_selected_preserves_rename_semantics(tmp_path):
+ from api.workspace_git import git_commit_selected, git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "old.txt").write_text("old\n", encoding="utf-8")
+ _commit_all(repo)
+
+ _git(repo, "mv", "old.txt", "new.txt")
+
+ committed = git_commit_selected(repo, "Rename selected file", ["new.txt"])
+ assert committed["ok"] is True
+ assert _git(repo, "ls-tree", "--name-only", "HEAD").splitlines() == ["new.txt"]
+ assert "old.txt" not in _git(repo, "status", "--porcelain=v2")
+ assert git_status(repo)["totals"]["changed"] == 0
+
+
+def test_git_commit_selected_handles_untracked_and_mixed_paths(tmp_path):
+ from api.workspace_git import git_commit_selected
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+
+ (repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
+ (repo / "new.txt").write_text("new\n", encoding="utf-8")
+
+ committed = git_commit_selected(repo, "Commit mixed selected files", ["tracked.txt", "new.txt"])
+ assert committed["ok"] is True
+ assert set(_git(repo, "show", "--name-only", "--format=", "HEAD").splitlines()) == {
+ "tracked.txt",
+ "new.txt",
+ }
+
+
+def test_git_commit_selected_respects_nested_workspace_scope(tmp_path):
+ from api.workspace_git import GitWorkspaceError, git_commit_selected
+
+ repo = _init_repo(tmp_path / "repo")
+ nested = repo / "app"
+ nested.mkdir()
+ (nested / "inside.txt").write_text("inside\n", encoding="utf-8")
+ (repo / "outside.txt").write_text("outside\n", encoding="utf-8")
+ _commit_all(repo)
+
+ (nested / "inside.txt").write_text("inside\nchanged\n", encoding="utf-8")
+ (repo / "outside.txt").write_text("outside\nchanged\n", encoding="utf-8")
+
+ committed = git_commit_selected(nested, "Nested selected commit", ["inside.txt"])
+ assert committed["paths"] == ["inside.txt"]
+ assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["app/inside.txt"]
+
+ with pytest.raises(GitWorkspaceError) as outside:
+ git_commit_selected(nested, "Outside", ["../outside.txt"])
+ assert outside.value.code == "path_outside_workspace"
+
+
+def test_git_commit_selected_rejects_conflicts_and_path_traversal(tmp_path):
+ from api.workspace_git import GitWorkspaceError, git_commit_selected
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "conflict.txt").write_text("base\n", encoding="utf-8")
+ _commit_all(repo)
+ _git(repo, "checkout", "-b", "side")
+ (repo / "conflict.txt").write_text("side\n", encoding="utf-8")
+ _commit_all(repo, "side")
+ _git(repo, "checkout", "master")
+ (repo / "conflict.txt").write_text("main\n", encoding="utf-8")
+ _commit_all(repo, "main")
+ subprocess.run(["git", "merge", "side"], cwd=repo, shell=False, text=True, capture_output=True, timeout=20)
+
+ with pytest.raises(GitWorkspaceError) as conflict:
+ git_commit_selected(repo, "Nope", ["conflict.txt"])
+ assert conflict.value.code == "conflict"
+
+ with pytest.raises(GitWorkspaceError) as traversal:
+ git_commit_selected(repo, "Nope", ["../outside.txt"])
+ assert traversal.value.code == "path_outside_workspace"
+
+
+def test_selected_commit_message_prompt_uses_selected_diff(tmp_path):
+ from api.workspace_git import selected_commit_message_prompt
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "selected.txt").write_text("one\n", encoding="utf-8")
+ (repo / "other.txt").write_text("alpha\n", encoding="utf-8")
+ _commit_all(repo)
+ (repo / "selected.txt").write_text("one\ntwo\n", encoding="utf-8")
+ (repo / "other.txt").write_text("alpha\nbeta\n", encoding="utf-8")
+
+ prompt = selected_commit_message_prompt(repo, ["selected.txt"])
+ assert "selected.txt" in prompt["user_prompt"]
+ assert "+two" in prompt["user_prompt"]
+ assert "other.txt" not in prompt["user_prompt"]
+ assert "beta" not in prompt["user_prompt"]
+
+
+def test_staged_commit_message_prompt_uses_only_staged_diff(tmp_path):
+ from api.workspace_git import (
+ GitWorkspaceError,
+ clean_generated_commit_message,
+ staged_commit_message_prompt,
+ )
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+
+ (repo / "tracked.txt").write_text("one\nstaged\n", encoding="utf-8")
+ _git(repo, "add", "tracked.txt")
+ (repo / "tracked.txt").write_text("one\nstaged\nunstaged\n", encoding="utf-8")
+
+ prompt = staged_commit_message_prompt(repo)
+ assert prompt["truncated"] is False
+ assert "tracked.txt" in prompt["user_prompt"]
+ assert "+staged" in prompt["user_prompt"]
+ assert "unstaged" not in prompt["user_prompt"]
+ assert "Never mention AI, Cursor, Zed, agents" in prompt["system_prompt"]
+
+ _git(repo, "restore", "--staged", "tracked.txt")
+ with pytest.raises(GitWorkspaceError):
+ staged_commit_message_prompt(repo)
+
+ assert clean_generated_commit_message("```text\nSubject\n\n- Body\n```") == "Subject\n\n- Body"
+
+
+def test_git_fetch_pull_and_push_with_upstream(tmp_path):
+ from api.workspace_git import git_fetch, git_pull, git_push, git_status
+
+ remote = tmp_path / "remote.git"
+ _git(tmp_path, "init", "--bare", str(remote))
+
+ origin = _init_repo(tmp_path / "origin")
+ (origin / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(origin)
+ _git(origin, "remote", "add", "origin", str(remote))
+ _git(origin, "push", "-u", "origin", "HEAD")
+
+ clone = tmp_path / "clone"
+ _git(tmp_path, "clone", str(remote), str(clone))
+ _git(clone, "config", "user.email", "hermes-tests@example.invalid")
+ _git(clone, "config", "user.name", "Hermes Tests")
+
+ (origin / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
+ _commit_all(origin, "Remote update")
+ _git(origin, "push")
+
+ fetched = git_fetch(clone)
+ assert fetched["status"]["behind"] == 1
+
+ pulled = git_pull(clone)
+ assert pulled["status"]["behind"] == 0
+ assert (clone / "tracked.txt").read_text(encoding="utf-8") == "one\ntwo\n"
+
+ (clone / "tracked.txt").write_text("one\ntwo\nthree\n", encoding="utf-8")
+ _git(clone, "add", "tracked.txt")
+ _git(clone, "commit", "-m", "Local update")
+ assert git_status(clone)["ahead"] == 1
+
+ pushed = git_push(clone)
+ assert pushed["status"]["ahead"] == 0
+
+
+def test_git_branches_lists_local_remote_and_upstream(tmp_path):
+ from api.workspace_git import git_branches
+
+ remote = tmp_path / "remote.git"
+ _git(tmp_path, "init", "--bare", str(remote))
+ origin = _init_repo(tmp_path / "origin")
+ (origin / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(origin)
+ _git(origin, "branch", "-M", "main")
+ _git(origin, "remote", "add", "origin", str(remote))
+ _git(origin, "push", "-u", "origin", "main")
+ _git(remote, "symbolic-ref", "HEAD", "refs/heads/main")
+
+ clone = tmp_path / "clone"
+ _git(tmp_path, "clone", str(remote), str(clone))
+ branches = git_branches(clone)
+ assert branches["current"] == "main"
+ assert branches["detached"] is False
+ assert any(item["name"] == "main" and item["upstream"] == "origin/main" for item in branches["local"])
+ main = next(item for item in branches["local"] if item["name"] == "main")
+ assert "updated_relative" in main and "author" in main and "subject" in main
+ assert any(item["name"] == "origin/main" for item in branches["remote"])
+ assert not any(item["name"] == "origin" for item in branches["remote"])
+
+
+def test_git_checkout_local_new_remote_dirty_and_invalid_refs(tmp_path):
+ from api.workspace_git import GitWorkspaceError, git_branches, git_checkout
+
+ remote = tmp_path / "remote.git"
+ _git(tmp_path, "init", "--bare", str(remote))
+ origin = _init_repo(tmp_path / "origin")
+ (origin / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(origin)
+ _git(origin, "branch", "-M", "main")
+ _git(origin, "remote", "add", "origin", str(remote))
+ _git(origin, "push", "-u", "origin", "main")
+ _git(remote, "symbolic-ref", "HEAD", "refs/heads/main")
+ _git(origin, "checkout", "-b", "remote-feature")
+ (origin / "remote.txt").write_text("remote\n", encoding="utf-8")
+ _commit_all(origin, "remote feature")
+ _git(origin, "push", "-u", "origin", "remote-feature")
+
+ clone = tmp_path / "clone"
+ _git(tmp_path, "clone", str(remote), str(clone))
+ _git(clone, "config", "user.email", "hermes-tests@example.invalid")
+ _git(clone, "config", "user.name", "Hermes Tests")
+
+ created = git_checkout(clone, "main", "new", new_branch="local-work")
+ assert created["current_branch"] == "local-work"
+ assert git_branches(clone)["current"] == "local-work"
+
+ switched = git_checkout(clone, "main", "local")
+ assert switched["current_branch"] == "main"
+
+ tracked = git_checkout(clone, "origin/remote-feature", "remote", new_branch="remote-feature", track=True)
+ assert tracked["current_branch"] == "remote-feature"
+ assert git_branches(clone)["upstream"] == "origin/remote-feature"
+
+ (clone / "tracked.txt").write_text("dirty\n", encoding="utf-8")
+ with pytest.raises(GitWorkspaceError) as dirty:
+ git_checkout(clone, "main", "local")
+ assert dirty.value.code == "dirty_worktree"
+ _git(clone, "restore", "tracked.txt")
+
+ with pytest.raises(GitWorkspaceError) as invalid:
+ git_checkout(clone, "does-not-exist", "local")
+ assert invalid.value.code in {"invalid_ref", "git_failed"}
+
+
+def test_git_checkout_detached_requires_explicit_mode(tmp_path):
+ from api.workspace_git import git_branches, git_checkout
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+ sha = _git(repo, "rev-parse", "--short", "HEAD").strip()
+
+ result = git_checkout(repo, sha, "detached")
+ assert result["ok"] is True
+ branches = git_branches(repo)
+ assert branches["detached"] is True
+ assert branches["current"] == sha
+
+
+def test_git_stash_and_checkout_is_explicit(tmp_path):
+ from api.workspace_git import git_stash_and_checkout, git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+ _git(repo, "checkout", "-b", "target")
+ _git(repo, "checkout", "master")
+ (repo / "tracked.txt").write_text("dirty\n", encoding="utf-8")
+
+ result = git_stash_and_checkout(repo, "target", "local")
+ assert result["ok"] is True
+ assert result["stashed"] is True
+ assert result["stash_name"].startswith("hermes-webui branch switch")
+ assert result["current_branch"] == "target"
+ assert git_status(repo)["totals"]["changed"] == 0
+ assert "hermes-webui branch switch to target" in _git(repo, "stash", "list")
+
+
+def test_git_stash_and_checkout_restores_branch_changes_when_returning(tmp_path):
+ from api.workspace_git import git_stash_and_checkout, git_status
+
+ repo = _init_repo(tmp_path / "repo")
+ _git(repo, "branch", "-M", "main")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+ _git(repo, "checkout", "-b", "feature")
+ _git(repo, "checkout", "main")
+
+ (repo / "tracked.txt").write_text("main dirty\n", encoding="utf-8")
+ (repo / "main-only.txt").write_text("untracked on main\n", encoding="utf-8")
+
+ to_feature = git_stash_and_checkout(repo, "feature", "local")
+ assert to_feature["ok"] is True
+ assert to_feature["stashed"] is True
+ assert to_feature["current_branch"] == "feature"
+ assert git_status(repo)["totals"]["changed"] == 0
+ assert not (repo / "main-only.txt").exists()
+
+ (repo / "feature-only.txt").write_text("untracked on feature\n", encoding="utf-8")
+ to_main = git_stash_and_checkout(repo, "main", "local")
+
+ assert to_main["ok"] is True
+ assert to_main["stashed"] is True
+ assert to_main["current_branch"] == "main"
+ assert to_main["restored_stash"]["branch"] == "main"
+ assert (repo / "tracked.txt").read_text(encoding="utf-8") == "main dirty\n"
+ assert (repo / "main-only.txt").read_text(encoding="utf-8") == "untracked on main\n"
+ assert not (repo / "feature-only.txt").exists()
+ stash_list = _git(repo, "stash", "list")
+ assert "On main: hermes-webui branch switch" not in stash_list
+ assert "On feature: hermes-webui branch switch" in stash_list
+
+
+def test_git_stash_and_checkout_reports_restore_conflicts_without_dropping_stash(tmp_path):
+ from api.workspace_git import git_stash_and_checkout
+
+ repo = _init_repo(tmp_path / "repo")
+ _git(repo, "branch", "-M", "main")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+ _git(repo, "checkout", "-b", "feature")
+ _git(repo, "checkout", "main")
+ (repo / "tracked.txt").write_text("main dirty\n", encoding="utf-8")
+
+ git_stash_and_checkout(repo, "feature", "local")
+ _git(repo, "checkout", "main")
+ (repo / "tracked.txt").write_text("main changed while parked\n", encoding="utf-8")
+ _commit_all(repo, "advance main")
+ _git(repo, "checkout", "feature")
+
+ result = git_stash_and_checkout(repo, "main", "local")
+
+ assert result["ok"] is True
+ assert result["current_branch"] == "main"
+ assert result["restore_failed"] is True
+ assert result["restore_stash"]["branch"] == "main"
+ assert "On main: hermes-webui branch switch" in _git(repo, "stash", "list")
+
+
+def test_git_stash_checkout_validates_before_stashing(tmp_path):
+ from api.workspace_git import GitWorkspaceError, git_stash_and_checkout
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+ (repo / "tracked.txt").write_text("dirty\n", encoding="utf-8")
+
+ with pytest.raises(GitWorkspaceError) as invalid:
+ git_stash_and_checkout(repo, "missing-branch", "local")
+
+ assert invalid.value.code == "invalid_ref"
+ assert "M tracked.txt" in _git(repo, "status", "--porcelain")
+ assert _git(repo, "stash", "list") == ""
+
+
+def test_git_routes_status_diff_stage_unstage_discard_commit(cleanup_test_sessions):
+ sid, base_ws = _make_session(cleanup_test_sessions)
+ repo = base_ws / f"git-route-{uuid.uuid4().hex[:8]}"
+ _init_repo(repo)
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+
+ _post("/api/session/update", {"session_id": sid, "workspace": str(repo), "model": "openai/gpt-5.4-mini"})
+ (repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
+
+ status, code = _get(f"/api/git/status?session_id={sid}")
+ assert code == 200
+ assert status["git"]["totals"]["unstaged"] == 1
+
+ diff, code = _get(
+ f"/api/git/diff?session_id={sid}&path={urllib.parse.quote('tracked.txt')}&kind=unstaged"
+ )
+ assert code == 200
+ assert "+two" in diff["diff"]["diff"]
+
+ staged, code = _post("/api/git/stage", {"session_id": sid, "paths": ["tracked.txt"]})
+ assert code == 200 and staged["git"]["totals"]["staged"] == 1
+
+ unstaged, code = _post("/api/git/unstage", {"session_id": sid, "paths": ["tracked.txt"]})
+ assert code == 200 and unstaged["git"]["totals"]["unstaged"] == 1
+
+ discarded, code = _post("/api/git/discard", {"session_id": sid, "paths": ["tracked.txt"]})
+ assert code == 200 and discarded["git"]["totals"]["changed"] == 0
+
+ (repo / "tracked.txt").write_text("one\nthree\n", encoding="utf-8")
+ _post("/api/git/stage", {"session_id": sid, "paths": ["tracked.txt"]})
+ committed, code = _post("/api/git/commit", {"session_id": sid, "message": "Route commit"})
+ assert code == 200
+ assert committed["ok"] is True
+ assert committed["status"]["totals"]["changed"] == 0
+
+
+def test_git_routes_branches_and_checkout(cleanup_test_sessions):
+ sid, base_ws = _make_session(cleanup_test_sessions)
+ repo = base_ws / f"git-branch-route-{uuid.uuid4().hex[:8]}"
+ _init_repo(repo)
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+ _git(repo, "branch", "-M", "main")
+ _git(repo, "checkout", "-b", "feature")
+ _git(repo, "checkout", "main")
+
+ _post("/api/session/update", {"session_id": sid, "workspace": str(repo), "model": "openai/gpt-5.4-mini"})
+ branches, code = _get(f"/api/git/branches?session_id={sid}")
+ assert code == 200
+ assert branches["branches"]["current"] == "main"
+ assert any(item["name"] == "feature" for item in branches["branches"]["local"])
+
+ checked, code = _post(
+ "/api/git/checkout",
+ {"session_id": sid, "ref": "feature", "mode": "local", "dirty_mode": "block"},
+ )
+ assert code == 200
+ assert checked["ok"] is True
+ assert checked["current_branch"] == "feature"
+ assert checked["git"]["branch"] == "feature"
+
+
+def test_git_routes_selected_commit_and_structured_error(cleanup_test_sessions):
+ sid, base_ws = _make_session(cleanup_test_sessions)
+ repo = base_ws / f"git-selected-route-{uuid.uuid4().hex[:8]}"
+ _init_repo(repo)
+ (repo / "selected.txt").write_text("one\n", encoding="utf-8")
+ (repo / "other.txt").write_text("alpha\n", encoding="utf-8")
+ _commit_all(repo)
+
+ _post("/api/session/update", {"session_id": sid, "workspace": str(repo), "model": "openai/gpt-5.4-mini"})
+ (repo / "selected.txt").write_text("one\ntwo\n", encoding="utf-8")
+ (repo / "other.txt").write_text("alpha\nbeta\n", encoding="utf-8")
+ _git(repo, "add", "other.txt")
+
+ bad, code = _post("/api/git/commit-selected", {"session_id": sid, "message": "Bad", "paths": ["../x"]})
+ assert code == 400
+ assert bad["code"] == "path_outside_workspace"
+
+ committed, code = _post(
+ "/api/git/commit-selected",
+ {"session_id": sid, "message": "Selected route commit", "paths": ["selected.txt"]},
+ )
+ assert code == 200
+ assert committed["ok"] is True
+ assert committed["paths"] == ["selected.txt"]
+ assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["selected.txt"]
+
+
+def test_git_env_scrub_removes_redirecting_vars_and_preserves_temp_index(monkeypatch):
+ from api.workspace_git import _clean_git_env
+
+ monkeypatch.setenv("GIT_DIR", "/tmp/evil-git-dir")
+ monkeypatch.setenv("GIT_WORK_TREE", "/tmp/evil-work-tree")
+ monkeypatch.setenv("GIT_CONFIG_GLOBAL", "/tmp/evil-config")
+ monkeypatch.setenv("GIT_CONFIG_SYSTEM", "/tmp/evil-system-config")
+ monkeypatch.setenv("GIT_CONFIG_COUNT", "1")
+ monkeypatch.setenv("GIT_CONFIG_KEY_0", "core.sshCommand")
+ monkeypatch.setenv("GIT_CONFIG_VALUE_0", "ssh -i /tmp/evil-key")
+ monkeypatch.setenv("GIT_CONFIG_PARAMETERS", "'core.sshCommand=ssh -i /tmp/evil-key'")
+
+ env = _clean_git_env({"GIT_INDEX_FILE": "/tmp/hermes-index"})
+
+ assert "GIT_DIR" not in env
+ assert "GIT_WORK_TREE" not in env
+ assert "GIT_CONFIG_GLOBAL" not in env
+ assert "GIT_CONFIG_SYSTEM" not in env
+ assert "GIT_CONFIG_COUNT" not in env
+ assert "GIT_CONFIG_KEY_0" not in env
+ assert "GIT_CONFIG_VALUE_0" not in env
+ assert "GIT_CONFIG_PARAMETERS" not in env
+ assert env["GIT_INDEX_FILE"] == "/tmp/hermes-index"
+
+
+def test_git_error_classifier_identifies_non_fast_forward_push():
+ from api.workspace_git import _classify_git_error
+
+ assert _classify_git_error("Updates were rejected", ["push"]) == "non_fast_forward"
+ assert _classify_git_error("non-fast-forward", ["push"]) == "non_fast_forward"
+ assert _classify_git_error("fetch first", ["push"]) == "non_fast_forward"
+
+
+def test_git_commit_hook_failure_returns_hook_failed_code(tmp_path):
+ from api.workspace_git import GitWorkspaceError, git_commit, git_stage
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+ hook = repo / ".git" / "hooks" / "pre-commit"
+ hook.write_text("#!/bin/sh\necho hook blocked >&2\nexit 1\n", encoding="utf-8")
+ hook.chmod(0o755)
+
+ (repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
+ git_stage(repo, ["tracked.txt"])
+
+ with pytest.raises(GitWorkspaceError) as exc:
+ git_commit(repo, "Hook should fail")
+ assert exc.value.code == "hook_failed"
+
+
+def test_destructive_workspace_git_flag_defaults_off_and_accepts_truthy(monkeypatch):
+ from api.workspace_git import WORKSPACE_GIT_DESTRUCTIVE_ENV, workspace_git_destructive_enabled
+
+ monkeypatch.delenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, raising=False)
+ assert workspace_git_destructive_enabled() is False
+
+ monkeypatch.setenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, "1")
+ assert workspace_git_destructive_enabled() is True
+
+ monkeypatch.setenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, "true")
+ assert workspace_git_destructive_enabled() is True
+
+
+def test_git_active_stream_lock_detection(monkeypatch):
+ from api import routes
+ from api.config import STREAMS, STREAMS_LOCK
+
+ session = types.SimpleNamespace(active_stream_id="stream-git-lock-test")
+ with STREAMS_LOCK:
+ STREAMS[session.active_stream_id] = object()
+ try:
+ assert routes._git_locked_by_active_stream(session) is True
+ finally:
+ with STREAMS_LOCK:
+ STREAMS.pop(session.active_stream_id, None)
+
+ assert routes._git_locked_by_active_stream(session) is False
+
+
+def test_git_commit_route_rejects_active_stream(monkeypatch, tmp_path):
+ from api import routes
+ from api.config import STREAMS, STREAMS_LOCK
+ from api.workspace_git import WORKSPACE_GIT_DESTRUCTIVE_ENV
+
+ # Enable destructive ops for this in-process test — conftest.py sets the env
+ # var on the test_server subprocess env block, but this test calls
+ # _handle_git_commit() directly in the pytest process, which inherits
+ # the default-OFF setting. Without this monkeypatch, the destructive-mode
+ # gate fires first (403) before the active-stream check (409) can run.
+ monkeypatch.setenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, "1")
+
+ repo = _init_repo(tmp_path / "repo")
+ (repo / "tracked.txt").write_text("one\n", encoding="utf-8")
+ _commit_all(repo)
+ _git(repo, "add", "tracked.txt")
+ session = types.SimpleNamespace(
+ session_id="sid-active-git",
+ workspace=str(repo),
+ active_stream_id="stream-active-git",
+ )
+
+ monkeypatch.setattr(routes, "get_session", lambda sid: session)
+ handler = _CaptureHandler()
+ with STREAMS_LOCK:
+ STREAMS[session.active_stream_id] = object()
+ try:
+ assert routes._handle_git_commit(
+ handler,
+ {"session_id": session.session_id, "message": "Should be blocked"},
+ ) is True
+ finally:
+ with STREAMS_LOCK:
+ STREAMS.pop(session.active_stream_id, None)
+
+ assert handler.status == 409
+ payload = handler.payload()
+ assert payload["code"] == "active_stream"
+ assert "active" in payload["error"].lower()