Files
hermes-webui/tests/test_workspace_git.py
nesquena-hermes 938ac9f55b
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.311 — Release KA (brick-wave: workspace Git RCE hardening #3769 + stale-snapshot sidebar visibility #3770) (#3776)
Brick-wave batch. #3769 (@Hinotoi-agent) hardens workspace Git config execution against repo-local RCE; #3770 (@ai-ag2026) keeps fuller pre-compression snapshots visible when _index.json is stale. Full suite 8176 passed, Codex SAFE, Opus SHIP IT. Co-authored-by: Hinotoi-agent; Co-authored-by: ai-ag2026
2026-06-07 08:05:30 -07:00

1195 lines
44 KiB
Python

import json
import pathlib
import subprocess
import threading
import types
import uuid
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
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)
init = subprocess.run(
["git", "init", "-b", "master"],
cwd=str(path),
shell=False,
text=True,
capture_output=True,
timeout=20,
)
if init.returncode != 0:
_git(path, "init")
_git(path, "checkout", "-B", "master")
_git(path, "config", "user.email", "hermes-tests@example.invalid")
_git(path, "config", "user.name", "Hermes Tests")
return path
def _init_bare_repo(path):
init = subprocess.run(
["git", "init", "--bare", "-b", "master", str(path)],
shell=False,
text=True,
capture_output=True,
timeout=20,
)
if init.returncode != 0:
_git(path.parent, "init", "--bare", str(path))
_git(path, "symbolic-ref", "HEAD", "refs/heads/master")
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_discard_untracked_file_tolerates_concurrent_missing_file(tmp_path, monkeypatch):
import api.workspace_git as workspace_git
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
transient = repo / "transient.txt"
transient.write_text("gone soon\n", encoding="utf-8")
original_unlink_anchored = workspace_git.unlink_anchored
raced = {"seen": False}
def remove_before_unlink(root, target):
if target == transient:
raced["seen"] = True
transient.unlink()
return original_unlink_anchored(root, target)
monkeypatch.setattr(workspace_git, "unlink_anchored", remove_before_unlink)
status = workspace_git.git_discard(repo, ["transient.txt"], delete_untracked=True)
assert raced["seen"] is True
assert not transient.exists()
assert status["totals"]["changed"] == 0
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 = _init_bare_repo(tmp_path / "remote.git")
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")
_git(remote, "symbolic-ref", "HEAD", "refs/heads/master")
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 = _init_bare_repo(tmp_path / "remote.git")
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 = _init_bare_repo(tmp_path / "remote.git")
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_discard_untracked_delete_uses_anchored_unlink_after_validation_race(tmp_path, monkeypatch):
import os
import shutil
import api.workspace_git as workspace_git
from api.workspace import safe_resolve_ws as real_safe_resolve_ws
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("tracked\n", encoding="utf-8")
_commit_all(repo)
(repo / "d").mkdir()
(repo / "d" / "f").write_text("workspace untracked\n", encoding="utf-8")
outside = tmp_path / "outside"
outside.mkdir()
victim = outside / "f"
victim.write_text("outside victim\n", encoding="utf-8")
state = {"calls": 0, "swapped": False}
def racing_safe_resolve(root, requested):
target = real_safe_resolve_ws(root, requested)
if requested == "d/f":
state["calls"] += 1
# git_discard validates once for the Git pathspec and once immediately
# before deletion. Race the second validation-to-use window.
if requested == "d/f" and state["calls"] == 2 and not state["swapped"]:
shutil.rmtree(repo / "d")
os.symlink(outside, repo / "d")
state["swapped"] = True
return target
monkeypatch.setattr(workspace_git, "safe_resolve_ws", racing_safe_resolve)
with pytest.raises(ValueError, match="Path traversal blocked"):
workspace_git.git_discard(repo, ["d/f"], delete_untracked=True)
assert state["swapped"] is True
assert victim.exists()
assert victim.read_text(encoding="utf-8") == "outside victim\n"
def test_git_status_ignores_repo_local_fsmonitor_command(tmp_path):
import os
import sys
if os.name == "nt":
pytest.skip("executable fsmonitor helper setup is POSIX-only")
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
marker = tmp_path / "fsmonitor-ran"
helper = tmp_path / "fsmonitor_helper.py"
helper.write_text(
"#!/usr/bin/env python3\n"
"import pathlib, sys\n"
"pathlib.Path(sys.argv[1]).write_text('fsmonitor executed', encoding='utf-8')\n"
"print('')\n",
encoding="utf-8",
)
helper.chmod(0o755)
_git(repo, "config", "core.fsmonitor", f"{sys.executable} {helper} {marker}")
status = git_status(repo)
assert status["is_git"] is True
assert not marker.exists()
def test_git_fetch_blocks_repo_local_ext_transport_execution(tmp_path):
import os
import sys
if os.name == "nt":
pytest.skip("ext transport helper setup is POSIX-only")
from api.workspace_git import GitWorkspaceError, git_fetch
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
marker = tmp_path / "ext-transport-ran"
helper = tmp_path / "ext_helper.py"
helper.write_text(
"#!/usr/bin/env python3\n"
"import pathlib, sys\n"
"pathlib.Path(sys.argv[1]).write_text('ext executed: ' + ' '.join(sys.argv[2:]), encoding='utf-8')\n"
"sys.exit(1)\n",
encoding="utf-8",
)
helper.chmod(0o755)
_git(repo, "config", "protocol.ext.allow", "always")
_git(repo, "remote", "add", "origin", f"ext::{sys.executable} {helper} {marker} %S foo")
with pytest.raises(GitWorkspaceError) as exc:
git_fetch(repo)
assert exc.value.code == "git_failed"
assert not marker.exists()
def test_git_fetch_blocks_repo_local_credential_helper_execution(tmp_path):
import os
import sys
if os.name == "nt":
pytest.skip("executable credential helper setup is POSIX-only")
from api.workspace_git import GitWorkspaceError, git_fetch
class AuthRequiredHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="hermes-test"')
self.end_headers()
def log_message(self, format, *args):
del format, args
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
marker = tmp_path / "credential-helper-ran"
helper = tmp_path / "credential_helper.py"
helper.write_text(
"#!/usr/bin/env python3\n"
"import pathlib, sys\n"
"pathlib.Path(sys.argv[1]).write_text('credential helper executed', encoding='utf-8')\n",
encoding="utf-8",
)
helper.chmod(0o755)
server = ThreadingHTTPServer(("127.0.0.1", 0), AuthRequiredHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
url = f"http://127.0.0.1:{server.server_port}/repo.git"
_git(repo, "remote", "add", "origin", url)
_git(repo, "config", "credential.helper", f"!{sys.executable} {helper} {marker}")
with pytest.raises(GitWorkspaceError) as exc:
git_fetch(repo)
finally:
server.shutdown()
thread.join(timeout=5)
server.server_close()
assert exc.value.code == "auth_failed"
assert not marker.exists()
def test_git_fetch_blocks_repo_local_askpass_execution(tmp_path):
import os
import sys
if os.name == "nt":
pytest.skip("executable askpass helper setup is POSIX-only")
from api.workspace_git import GitWorkspaceError, git_fetch
class AuthRequiredHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="hermes-test"')
self.end_headers()
def log_message(self, format, *args):
del format, args
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
marker = tmp_path / "askpass-ran"
helper = tmp_path / "askpass_helper.py"
helper.write_text(
"#!/usr/bin/env python3\n"
"import pathlib, sys\n"
"pathlib.Path(sys.argv[1]).write_text('askpass executed', encoding='utf-8')\n"
"print('pw')\n",
encoding="utf-8",
)
helper.chmod(0o755)
server = ThreadingHTTPServer(("127.0.0.1", 0), AuthRequiredHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
url = f"http://127.0.0.1:{server.server_port}/repo.git"
_git(repo, "remote", "add", "origin", url)
_git(repo, "config", "core.askPass", f"{sys.executable} {helper} {marker}")
with pytest.raises(GitWorkspaceError) as exc:
git_fetch(repo)
finally:
server.shutdown()
thread.join(timeout=5)
server.server_close()
assert exc.value.code == "auth_failed"
assert not marker.exists()
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'")
monkeypatch.setenv("GIT_ASKPASS", "/tmp/evil-askpass")
monkeypatch.setenv("SSH_ASKPASS", "/tmp/evil-ssh-askpass")
monkeypatch.setenv("GIT_SSH", "/tmp/evil-ssh")
monkeypatch.setenv("GIT_SSH_COMMAND", "ssh -i /tmp/evil-key")
monkeypatch.setenv("GIT_TERMINAL_PROMPT", "1")
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 "GIT_ASKPASS" not in env
assert "SSH_ASKPASS" not in env
assert "GIT_SSH" not in env
assert "GIT_SSH_COMMAND" not in env
assert env["GIT_TERMINAL_PROMPT"] == "0"
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()