6-round RCE-class security hardening, converged. Codex SAFE (live exploit probes) + Opus SAFE (flag-off-reachability enumeration) + full suite 9508 on the byte-identical gated head. Nathan signed off. Co-authored-by: Rod Boev <rod.boev@gmail.com> Closes #3879 Closes #3777
This commit is contained in:
1 parent
9e79fc3697
commit
fa92baa63d
4 files changed
+1816
-32
No files matched your search
@@ -3,6 +3,12 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [v0.51.506] — 2026-06-18 — Release RQ (harden destructive workspace Git paths against repo-local execution)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Opening or operating on an untrusted Git repository in the workspace can no longer execute arbitrary commands the repo planted in its own config or hooks (#3777).** Workspace Git operations now run with a hardened configuration that neutralizes repo-local execution vectors — `core.fsmonitor`, `core.sshCommand`, `core.gitProxy`, `credential.helper`, `core.askPass`, `protocol.ext.allow`, GPG/signing programs, `core.alternateRefsCommand`, submodule recursion, and `filter`/`merge`/remote-helper programs — and redirects Git hooks to an empty directory on every ref-mutating and destructive operation (including Fetch, which fires the `reference-transaction` hook). Repo-derived config overrides are delivered exclusively via the `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` environment form so an attacker-controlled filter/section name containing `=` cannot inject configuration. When a repository defines local content filters, the content-rewriting actions (stage, discard, pull, checkout/switch, stash restore, selected-commit staging) fail closed with a clear message rather than silently writing unfiltered bytes. Thanks @rodboev.
|
||||||
|
|
||||||
## [v0.51.505] — 2026-06-18 — Release RP (allow macOS Mail `message:` links in chat markdown)
|
## [v0.51.505] — 2026-06-18 — Release RP (allow macOS Mail `message:` links in chat markdown)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+480
-32
@@ -8,15 +8,20 @@ pathspecs, and keeps all Git subprocess calls shell-free and bounded.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import difflib
|
import difflib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import re
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from api.workspace import rmtree_anchored, safe_resolve_ws, unlink_anchored
|
from api.workspace import rmtree_anchored, safe_resolve_ws, unlink_anchored
|
||||||
|
|
||||||
|
|
||||||
@@ -55,13 +60,41 @@ _GIT_HARDENED_CONFIG = (
|
|||||||
# Neutralize repo-local core.gitProxy, which specifies an external proxy
|
# Neutralize repo-local core.gitProxy, which specifies an external proxy
|
||||||
# command reachable on `git fetch` against a git:// remote.
|
# command reachable on `git fetch` against a git:// remote.
|
||||||
("core.gitProxy", ""),
|
("core.gitProxy", ""),
|
||||||
|
# Prevent submodule operations from recursing into nested repos, which
|
||||||
|
# could trigger hooks or fetch from attacker-controlled submodule URLs.
|
||||||
|
("submodule.recurse", "false"),
|
||||||
|
("fetch.recurseSubmodules", "false"),
|
||||||
|
)
|
||||||
|
_GIT_DESTRUCTIVE_HARDENED_CONFIG = (
|
||||||
|
# Disable signing helper command resolution while performing destructive
|
||||||
|
# Git operations. Hooks are redirected to a temporary empty directory in
|
||||||
|
# _run_git() so Git never falls back to .git/hooks.
|
||||||
|
("commit.gpgSign", "false"),
|
||||||
|
("push.gpgSign", "false"),
|
||||||
|
("gpg.program", ""),
|
||||||
|
("gpg.ssh.program", ""),
|
||||||
|
("gpg.x509.program", ""),
|
||||||
|
("core.alternateRefsCommand", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _hardened_git_argv(args: list[str]) -> list[str]:
|
def _hardened_git_argv(
|
||||||
|
args: list[str],
|
||||||
|
*,
|
||||||
|
destructive: bool = False,
|
||||||
|
attributes_file: str | None = None,
|
||||||
|
hooks_path: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
argv = ["git"]
|
argv = ["git"]
|
||||||
for key, value in _GIT_HARDENED_CONFIG:
|
for key, value in _GIT_HARDENED_CONFIG:
|
||||||
argv.extend(["-c", f"{key}={value}"])
|
argv.extend(["-c", f"{key}={value}"])
|
||||||
|
if destructive:
|
||||||
|
for key, value in _GIT_DESTRUCTIVE_HARDENED_CONFIG:
|
||||||
|
argv.extend(["-c", f"{key}={value}"])
|
||||||
|
if hooks_path:
|
||||||
|
argv.extend(["-c", f"core.hooksPath={hooks_path}"])
|
||||||
|
if attributes_file:
|
||||||
|
argv.extend(["-c", f"core.attributesFile={attributes_file}"])
|
||||||
argv.extend(args)
|
argv.extend(args)
|
||||||
return argv
|
return argv
|
||||||
|
|
||||||
@@ -164,12 +197,51 @@ def _run_git(
|
|||||||
timeout: int = GIT_TIMEOUT,
|
timeout: int = GIT_TIMEOUT,
|
||||||
check: bool = False,
|
check: bool = False,
|
||||||
env: dict[str, str] | None = None,
|
env: dict[str, str] | None = None,
|
||||||
|
destructive: bool = False,
|
||||||
|
force_destructive_hardening: bool = False,
|
||||||
|
disable_filter_attributes: bool = False,
|
||||||
|
neutralize_filter_programs: bool = False,
|
||||||
|
neutralize_remote_helpers: bool = False,
|
||||||
) -> subprocess.CompletedProcess[str]:
|
) -> subprocess.CompletedProcess[str]:
|
||||||
cwd = ctx_or_cwd.repo_root if isinstance(ctx_or_cwd, GitContext) else ctx_or_cwd
|
cwd = ctx_or_cwd.repo_root if isinstance(ctx_or_cwd, GitContext) else ctx_or_cwd
|
||||||
run_env = _clean_git_env(env)
|
run_env = _clean_git_env(env)
|
||||||
|
effective_destructive = destructive and workspace_git_destructive_enabled()
|
||||||
|
hardened_destructive_path = effective_destructive or force_destructive_hardening
|
||||||
|
attributes_file = None
|
||||||
|
hooks_path = None
|
||||||
|
extra_configs: list[tuple[str, str]] = []
|
||||||
|
temporary_attributes: list[str] = []
|
||||||
|
temporary_dirs: list[str] = []
|
||||||
try:
|
try:
|
||||||
|
if disable_filter_attributes:
|
||||||
|
fd, attributes_path = tempfile.mkstemp(prefix="hermes-webui-git-attrs-")
|
||||||
|
os.close(fd)
|
||||||
|
attributes_file = attributes_path
|
||||||
|
temporary_attributes = [attributes_path]
|
||||||
|
if disable_filter_attributes or neutralize_filter_programs:
|
||||||
|
# Read/status/fetch paths treat repo-local filter programs as
|
||||||
|
# untrusted code. Prefer raw-byte visibility over executing them.
|
||||||
|
extra_configs.extend(_destructive_filter_overrides(cwd, run_env))
|
||||||
|
if effective_destructive:
|
||||||
|
extra_configs.extend(_destructive_merge_driver_overrides(cwd, run_env))
|
||||||
|
if effective_destructive or neutralize_remote_helpers:
|
||||||
|
extra_configs.extend(_destructive_remote_helper_overrides(cwd, run_env))
|
||||||
|
args = _destructive_remote_command_args(args, cwd, run_env)
|
||||||
|
if hardened_destructive_path:
|
||||||
|
hooks_path = tempfile.mkdtemp(prefix="hermes-webui-git-hooks-")
|
||||||
|
temporary_dirs = [hooks_path]
|
||||||
|
if extra_configs:
|
||||||
|
run_env["GIT_CONFIG_COUNT"] = str(len(extra_configs))
|
||||||
|
for i, (key, value) in enumerate(extra_configs):
|
||||||
|
run_env[f"GIT_CONFIG_KEY_{i}"] = key
|
||||||
|
run_env[f"GIT_CONFIG_VALUE_{i}"] = value
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
_hardened_git_argv(args),
|
_hardened_git_argv(
|
||||||
|
args,
|
||||||
|
destructive=hardened_destructive_path,
|
||||||
|
attributes_file=attributes_file,
|
||||||
|
hooks_path=hooks_path,
|
||||||
|
),
|
||||||
cwd=str(cwd),
|
cwd=str(cwd),
|
||||||
shell=False,
|
shell=False,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -183,12 +255,202 @@ def _run_git(
|
|||||||
raise GitWorkspaceError("Git is not installed or not available on PATH", "missing_git") from exc
|
raise GitWorkspaceError("Git is not installed or not available on PATH", "missing_git") from exc
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise GitWorkspaceError(str(exc), _classify_git_error(str(exc), args)) from exc
|
raise GitWorkspaceError(str(exc), _classify_git_error(str(exc), args)) from exc
|
||||||
|
finally:
|
||||||
|
for path in temporary_attributes:
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
|
for path in temporary_dirs:
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
if check and result.returncode != 0:
|
if check and result.returncode != 0:
|
||||||
message = (result.stderr or result.stdout or "Git command failed").strip()
|
message = (result.stderr or result.stdout or "Git command failed").strip()
|
||||||
raise GitWorkspaceError(message, _classify_git_error(message, args))
|
raise GitWorkspaceError(message, _classify_git_error(message, args))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_FILTER_CONFIG_RE = re.compile(r"^filter\.(.+)\.(clean|smudge|process|required)$")
|
||||||
|
|
||||||
|
|
||||||
|
_MERGE_DRIVER_CONFIG_RE = re.compile(r"^merge\.(.+)\.driver$")
|
||||||
|
_REMOTE_HELPER_CONFIG_RE = re.compile(r"^remote\.(.+)\.(uploadpack|receivepack)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _config_names_for_scope(
|
||||||
|
scope: str,
|
||||||
|
cwd: Path,
|
||||||
|
env: dict[str, str],
|
||||||
|
config_pattern: str,
|
||||||
|
name_re: re.Pattern[str],
|
||||||
|
*,
|
||||||
|
ignore_unsupported: bool = False,
|
||||||
|
) -> set[str]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "config", "--includes", scope, "--name-only", "--get-regexp", config_pattern],
|
||||||
|
cwd=str(cwd),
|
||||||
|
shell=False,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=GIT_TIMEOUT,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if result.returncode not in {0, 1}:
|
||||||
|
if ignore_unsupported:
|
||||||
|
return set()
|
||||||
|
message = (result.stderr or result.stdout or "Git command failed").strip()
|
||||||
|
raise GitWorkspaceError(message, _classify_git_error(message, ["config"]))
|
||||||
|
names: set[str] = set()
|
||||||
|
for line in (result.stdout or "").splitlines():
|
||||||
|
match = name_re.match(line.strip())
|
||||||
|
if match:
|
||||||
|
names.add(match.group(1))
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_names_for_scope(
|
||||||
|
scope: str,
|
||||||
|
cwd: Path,
|
||||||
|
env: dict[str, str],
|
||||||
|
*,
|
||||||
|
ignore_unsupported: bool = False,
|
||||||
|
) -> set[str]:
|
||||||
|
return _config_names_for_scope(
|
||||||
|
scope,
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
r"^filter\..*\.(clean|smudge|process|required)$",
|
||||||
|
_FILTER_CONFIG_RE,
|
||||||
|
ignore_unsupported=ignore_unsupported,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_driver_names_for_scope(
|
||||||
|
scope: str,
|
||||||
|
cwd: Path,
|
||||||
|
env: dict[str, str],
|
||||||
|
*,
|
||||||
|
ignore_unsupported: bool = False,
|
||||||
|
) -> set[str]:
|
||||||
|
return _config_names_for_scope(
|
||||||
|
scope,
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
r"^merge\..*\.driver$",
|
||||||
|
_MERGE_DRIVER_CONFIG_RE,
|
||||||
|
ignore_unsupported=ignore_unsupported,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_helper_names_for_scope(
|
||||||
|
scope: str,
|
||||||
|
cwd: Path,
|
||||||
|
env: dict[str, str],
|
||||||
|
*,
|
||||||
|
ignore_unsupported: bool = False,
|
||||||
|
) -> set[str]:
|
||||||
|
return _config_names_for_scope(
|
||||||
|
scope,
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
r"^remote\..*\.(uploadpack|receivepack)$",
|
||||||
|
_REMOTE_HELPER_CONFIG_RE,
|
||||||
|
ignore_unsupported=ignore_unsupported,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _destructive_filter_overrides(cwd: Path, env: dict[str, str]) -> list[tuple[str, str]]:
|
||||||
|
names = _filter_names_for_scope("--local", cwd, env)
|
||||||
|
names |= _filter_names_for_scope(
|
||||||
|
"--worktree",
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
ignore_unsupported=True,
|
||||||
|
)
|
||||||
|
overrides: list[tuple[str, str]] = []
|
||||||
|
for name in sorted(names):
|
||||||
|
if "\n" in name or "\0" in name:
|
||||||
|
logger.warning("Skipping filter name with illegal characters: %r", name)
|
||||||
|
continue
|
||||||
|
overrides.extend(
|
||||||
|
[
|
||||||
|
(f"filter.{name}.clean", "cat"),
|
||||||
|
(f"filter.{name}.smudge", "cat"),
|
||||||
|
(f"filter.{name}.process", ""),
|
||||||
|
(f"filter.{name}.required", "false"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
|
def _destructive_merge_driver_overrides(cwd: Path, env: dict[str, str]) -> list[tuple[str, str]]:
|
||||||
|
names = _merge_driver_names_for_scope("--local", cwd, env)
|
||||||
|
names |= _merge_driver_names_for_scope(
|
||||||
|
"--worktree",
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
ignore_unsupported=True,
|
||||||
|
)
|
||||||
|
# Replace repo-defined merge drivers with Git's trusted three-way merge
|
||||||
|
# binary so stash restores cannot invoke workspace-controlled helpers.
|
||||||
|
overrides: list[tuple[str, str]] = []
|
||||||
|
for name in sorted(names):
|
||||||
|
if "\n" in name or "\0" in name:
|
||||||
|
logger.warning("Skipping merge driver name with illegal characters: %r", name)
|
||||||
|
continue
|
||||||
|
overrides.append((f"merge.{name}.driver", 'git merge-file "%A" "%O" "%B"'))
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
|
def _destructive_remote_helper_overrides(cwd: Path, env: dict[str, str]) -> list[tuple[str, str]]:
|
||||||
|
names = _remote_helper_names_for_scope("--local", cwd, env)
|
||||||
|
names |= _remote_helper_names_for_scope(
|
||||||
|
"--worktree",
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
ignore_unsupported=True,
|
||||||
|
)
|
||||||
|
overrides: list[tuple[str, str]] = []
|
||||||
|
for name in sorted(names):
|
||||||
|
if "\n" in name or "\0" in name:
|
||||||
|
logger.warning("Skipping remote helper name with illegal characters: %r", name)
|
||||||
|
continue
|
||||||
|
overrides.extend(
|
||||||
|
[
|
||||||
|
(f"remote.{name}.uploadpack", "git-upload-pack"),
|
||||||
|
(f"remote.{name}.receivepack", "git-receive-pack"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
|
def _destructive_remote_command_args(args: list[str], cwd: Path, env: dict[str, str]) -> list[str]:
|
||||||
|
if not args:
|
||||||
|
return args
|
||||||
|
names = _remote_helper_names_for_scope("--local", cwd, env)
|
||||||
|
names |= _remote_helper_names_for_scope(
|
||||||
|
"--worktree",
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
ignore_unsupported=True,
|
||||||
|
)
|
||||||
|
if not names:
|
||||||
|
return args
|
||||||
|
command = args[0]
|
||||||
|
if command in {"fetch", "pull"}:
|
||||||
|
return [command, "--upload-pack=git-upload-pack", *args[1:]]
|
||||||
|
if command == "push":
|
||||||
|
return [command, "--receive-pack=git-receive-pack", *args[1:]]
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def _has_repo_local_filters(cwd: Path, env: dict[str, str]) -> bool:
|
||||||
|
names = _filter_names_for_scope("--local", cwd, env)
|
||||||
|
names |= _filter_names_for_scope("--worktree", cwd, env, ignore_unsupported=True)
|
||||||
|
return bool(names)
|
||||||
|
|
||||||
|
|
||||||
|
def _block_filtered_destructive_write(ctx: GitContext, message: str) -> None:
|
||||||
|
if workspace_git_destructive_enabled() and _has_repo_local_filters(ctx.repo_root, _clean_git_env()):
|
||||||
|
raise GitWorkspaceError(message, "filtered_path")
|
||||||
|
|
||||||
|
|
||||||
def resolve_git_context(workspace: str | Path) -> GitContext | None:
|
def resolve_git_context(workspace: str | Path) -> GitContext | None:
|
||||||
ws = Path(workspace).expanduser().resolve()
|
ws = Path(workspace).expanduser().resolve()
|
||||||
result = _run_git(ws, ["rev-parse", "--show-toplevel"], check=False)
|
result = _run_git(ws, ["rev-parse", "--show-toplevel"], check=False)
|
||||||
@@ -288,12 +550,19 @@ def _parse_path_list(text: str, ctx: GitContext) -> set[str]:
|
|||||||
|
|
||||||
def _collect_diff_paths(ctx: GitContext, cached: bool, *, ignore_cr_at_eol: bool = True) -> set[str] | None:
|
def _collect_diff_paths(ctx: GitContext, cached: bool, *, ignore_cr_at_eol: bool = True) -> set[str] | None:
|
||||||
args = ["diff", "--name-only", "-z"]
|
args = ["diff", "--name-only", "-z"]
|
||||||
|
args.append("--no-textconv")
|
||||||
if ignore_cr_at_eol:
|
if ignore_cr_at_eol:
|
||||||
args.append("--ignore-cr-at-eol")
|
args.append("--ignore-cr-at-eol")
|
||||||
if cached:
|
if cached:
|
||||||
args.append("--cached")
|
args.append("--cached")
|
||||||
args.extend(["--", _workspace_pathspec(ctx)])
|
args.extend(["--", _workspace_pathspec(ctx)])
|
||||||
result = _run_git(ctx, args, check=False)
|
result = _run_git(
|
||||||
|
ctx,
|
||||||
|
args,
|
||||||
|
check=False,
|
||||||
|
disable_filter_attributes=workspace_git_destructive_enabled(),
|
||||||
|
neutralize_filter_programs=True,
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return None
|
return None
|
||||||
return _parse_path_list(result.stdout, ctx)
|
return _parse_path_list(result.stdout, ctx)
|
||||||
@@ -306,12 +575,19 @@ def _collect_numstat(
|
|||||||
ignore_cr_at_eol: bool = True,
|
ignore_cr_at_eol: bool = True,
|
||||||
) -> dict[str, tuple[int, int, bool]]:
|
) -> dict[str, tuple[int, int, bool]]:
|
||||||
args = ["diff", "--numstat"]
|
args = ["diff", "--numstat"]
|
||||||
|
args.append("--no-textconv")
|
||||||
if ignore_cr_at_eol:
|
if ignore_cr_at_eol:
|
||||||
args.append("--ignore-cr-at-eol")
|
args.append("--ignore-cr-at-eol")
|
||||||
if cached:
|
if cached:
|
||||||
args.append("--cached")
|
args.append("--cached")
|
||||||
args.extend(["--", _workspace_pathspec(ctx)])
|
args.extend(["--", _workspace_pathspec(ctx)])
|
||||||
result = _run_git(ctx, args, check=False)
|
result = _run_git(
|
||||||
|
ctx,
|
||||||
|
args,
|
||||||
|
check=False,
|
||||||
|
disable_filter_attributes=workspace_git_destructive_enabled(),
|
||||||
|
neutralize_filter_programs=True,
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return {}
|
return {}
|
||||||
return _parse_numstat(result.stdout, ctx)
|
return _parse_numstat(result.stdout, ctx)
|
||||||
@@ -354,6 +630,8 @@ def git_status(workspace: str | Path) -> dict:
|
|||||||
_workspace_pathspec(ctx),
|
_workspace_pathspec(ctx),
|
||||||
],
|
],
|
||||||
check=True,
|
check=True,
|
||||||
|
disable_filter_attributes=workspace_git_destructive_enabled(),
|
||||||
|
neutralize_filter_programs=True,
|
||||||
)
|
)
|
||||||
staged_stats = _collect_numstat(ctx, cached=True)
|
staged_stats = _collect_numstat(ctx, cached=True)
|
||||||
unstaged_stats = _collect_numstat(ctx, cached=False)
|
unstaged_stats = _collect_numstat(ctx, cached=False)
|
||||||
@@ -661,7 +939,8 @@ def _validate_new_branch_name(ctx: GitContext, name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _dirty_worktree(ctx: GitContext) -> bool:
|
def _dirty_worktree(ctx: GitContext) -> bool:
|
||||||
result = _run_git(ctx, ["status", "--porcelain=v2", "--untracked-files=all"], check=True)
|
result = _run_git(ctx, ["status", "--porcelain=v2", "--untracked-files=all"],
|
||||||
|
check=True, neutralize_filter_programs=True)
|
||||||
return bool(result.stdout.strip())
|
return bool(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
@@ -703,12 +982,23 @@ def _hermes_branch_switch_stashes(ctx: GitContext) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _restore_branch_switch_stash_locked(ctx: GitContext, branch: str) -> dict:
|
def _restore_branch_switch_stash_locked(ctx: GitContext, branch: str) -> dict:
|
||||||
|
if workspace_git_destructive_enabled() and _has_repo_local_filters(ctx.repo_root, _clean_git_env()):
|
||||||
|
return {
|
||||||
|
"restore_blocked": True,
|
||||||
|
"restore_reason": "Repository defines local filter programs",
|
||||||
|
}
|
||||||
if _dirty_worktree(ctx):
|
if _dirty_worktree(ctx):
|
||||||
return {}
|
return {}
|
||||||
for item in _hermes_branch_switch_stashes(ctx):
|
for item in _hermes_branch_switch_stashes(ctx):
|
||||||
if item.get("branch") != branch:
|
if item.get("branch") != branch:
|
||||||
continue
|
continue
|
||||||
result = _run_git(ctx, ["stash", "pop", "--index", item["ref"]], check=False)
|
result = _run_git(
|
||||||
|
ctx,
|
||||||
|
["stash", "pop", "--index", item["ref"]],
|
||||||
|
check=False,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
return {"restored_stash": item}
|
return {"restored_stash": item}
|
||||||
return {
|
return {
|
||||||
@@ -753,31 +1043,66 @@ def _perform_checkout_locked(
|
|||||||
new_branch: str | None,
|
new_branch: str | None,
|
||||||
track: bool,
|
track: bool,
|
||||||
) -> subprocess.CompletedProcess[str]:
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
if workspace_git_destructive_enabled() and _has_repo_local_filters(ctx.repo_root, _clean_git_env()):
|
||||||
|
raise GitWorkspaceError(
|
||||||
|
"Cannot checkout: repository defines local filter programs that would alter file content",
|
||||||
|
"filtered_path",
|
||||||
|
)
|
||||||
if mode == "local":
|
if mode == "local":
|
||||||
target = _validate_local_branch(ctx, ref)
|
target = _validate_local_branch(ctx, ref)
|
||||||
return _run_git(ctx, ["switch", target], check=True)
|
return _run_git(
|
||||||
|
ctx,
|
||||||
|
["switch", "--recurse-submodules=no", target],
|
||||||
|
check=True,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
if mode in {"new", "create"}:
|
if mode in {"new", "create"}:
|
||||||
branch = _validate_new_branch_name(ctx, new_branch or ref)
|
branch = _validate_new_branch_name(ctx, new_branch or ref)
|
||||||
start_ref = _validate_checkout_start(ctx, ref if (new_branch and ref and ref != new_branch) else "HEAD")
|
start_ref = _validate_checkout_start(ctx, ref if (new_branch and ref and ref != new_branch) else "HEAD")
|
||||||
return _run_git(ctx, ["switch", "-c", branch, start_ref], check=True)
|
return _run_git(
|
||||||
|
ctx,
|
||||||
|
["switch", "--recurse-submodules=no", "-c", branch, start_ref],
|
||||||
|
check=True,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
if mode == "remote":
|
if mode == "remote":
|
||||||
remote_ref = _validate_remote_branch(ctx, ref)
|
remote_ref = _validate_remote_branch(ctx, ref)
|
||||||
branch_name = str(new_branch or remote_ref.split("/", 1)[-1]).strip()
|
branch_name = str(new_branch or remote_ref.split("/", 1)[-1]).strip()
|
||||||
exists = _run_git(ctx, ["show-ref", "--verify", f"refs/heads/{branch_name}"], check=False)
|
exists = _run_git(ctx, ["show-ref", "--verify", f"refs/heads/{branch_name}"], check=False)
|
||||||
if exists.returncode == 0:
|
if exists.returncode == 0:
|
||||||
result = _run_git(ctx, ["switch", branch_name], check=True)
|
result = _run_git(
|
||||||
|
ctx,
|
||||||
|
["switch", "--recurse-submodules=no", branch_name],
|
||||||
|
check=True,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
if track:
|
if track:
|
||||||
_run_git(ctx, ["branch", "--set-upstream-to", remote_ref, branch_name], check=False)
|
_run_git(ctx, ["branch", "--set-upstream-to", remote_ref, branch_name], check=False)
|
||||||
return result
|
return result
|
||||||
branch = _validate_new_branch_name(ctx, branch_name)
|
branch = _validate_new_branch_name(ctx, branch_name)
|
||||||
args = ["switch", "-c", branch]
|
args = ["switch", "--recurse-submodules=no", "-c", branch]
|
||||||
if track:
|
if track:
|
||||||
args.append("--track")
|
args.append("--track")
|
||||||
args.append(remote_ref)
|
args.append(remote_ref)
|
||||||
return _run_git(ctx, args, check=True)
|
return _run_git(
|
||||||
|
ctx,
|
||||||
|
args,
|
||||||
|
check=True,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
if mode in {"detached", "detach"}:
|
if mode in {"detached", "detach"}:
|
||||||
target = _validate_checkout_start(ctx, ref)
|
target = _validate_checkout_start(ctx, ref)
|
||||||
return _run_git(ctx, ["switch", "--detach", target], check=True)
|
return _run_git(
|
||||||
|
ctx,
|
||||||
|
["switch", "--recurse-submodules=no", "--detach", target],
|
||||||
|
check=True,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
raise GitWorkspaceError("Unsupported checkout mode", "invalid_ref")
|
raise GitWorkspaceError("Unsupported checkout mode", "invalid_ref")
|
||||||
|
|
||||||
|
|
||||||
@@ -831,16 +1156,33 @@ def git_stash_and_checkout(
|
|||||||
restored: dict = {}
|
restored: dict = {}
|
||||||
with _git_mutation_lock(ctx):
|
with _git_mutation_lock(ctx):
|
||||||
_validate_checkout_request_locked(ctx, ref, mode, new_branch)
|
_validate_checkout_request_locked(ctx, ref, mode, new_branch)
|
||||||
|
if workspace_git_destructive_enabled() and _has_repo_local_filters(ctx.repo_root, _clean_git_env()):
|
||||||
|
raise GitWorkspaceError(
|
||||||
|
"Cannot stash: repository defines local filter programs that would alter file content",
|
||||||
|
"filtered_path",
|
||||||
|
)
|
||||||
stashed = False
|
stashed = False
|
||||||
if _dirty_worktree(ctx):
|
if _dirty_worktree(ctx):
|
||||||
stash_result = _run_git(ctx, ["stash", "push", "-u", "-m", stash_name], check=True)
|
stash_result = _run_git(
|
||||||
|
ctx,
|
||||||
|
["stash", "push", "-u", "-m", stash_name],
|
||||||
|
check=True,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
stash_text = _remote_message(stash_result)
|
stash_text = _remote_message(stash_result)
|
||||||
stashed = "No local changes to save" not in stash_text
|
stashed = "No local changes to save" not in stash_text
|
||||||
try:
|
try:
|
||||||
result = _perform_checkout_locked(ctx, workspace, ref, mode, new_branch, track)
|
result = _perform_checkout_locked(ctx, workspace, ref, mode, new_branch, track)
|
||||||
except Exception:
|
except Exception:
|
||||||
if stashed:
|
if stashed:
|
||||||
_run_git(ctx, ["stash", "pop", "--index", "stash@{0}"], check=False)
|
_run_git(
|
||||||
|
ctx,
|
||||||
|
["stash", "pop", "--index", "stash@{0}"],
|
||||||
|
check=False,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
current_branch = _current_checkout_label(ctx)
|
current_branch = _current_checkout_label(ctx)
|
||||||
restored = _restore_branch_switch_stash_locked(ctx, current_branch)
|
restored = _restore_branch_switch_stash_locked(ctx, current_branch)
|
||||||
@@ -930,11 +1272,11 @@ def git_diff(workspace: str | Path, path: str, kind: str = "unstaged") -> dict:
|
|||||||
payload = _synthetic_untracked_diff(ctx.workspace / workspace_rel, workspace_rel)
|
payload = _synthetic_untracked_diff(ctx.workspace / workspace_rel, workspace_rel)
|
||||||
return {"path": workspace_rel, "kind": kind, **payload}
|
return {"path": workspace_rel, "kind": kind, **payload}
|
||||||
|
|
||||||
args = ["diff", "--no-ext-diff", "--unified=3"]
|
args = ["diff", "--no-ext-diff", "--no-textconv", "--unified=3"]
|
||||||
if kind == "staged":
|
if kind == "staged":
|
||||||
args.append("--cached")
|
args.append("--cached")
|
||||||
args.extend(["--", repo_rel])
|
args.extend(["--", repo_rel])
|
||||||
result = _run_git(ctx, args, check=True)
|
result = _run_git(ctx, args, check=True, neutralize_filter_programs=True)
|
||||||
diff = result.stdout
|
diff = result.stdout
|
||||||
binary = "Binary files " in diff or "GIT binary patch" in diff
|
binary = "Binary files " in diff or "GIT binary patch" in diff
|
||||||
too_large = len(diff.encode("utf-8", errors="replace")) > DIFF_SIZE_LIMIT
|
too_large = len(diff.encode("utf-8", errors="replace")) > DIFF_SIZE_LIMIT
|
||||||
@@ -972,7 +1314,17 @@ def git_stage(workspace: str | Path, paths: Iterable[str]) -> dict:
|
|||||||
if ctx is None:
|
if ctx is None:
|
||||||
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
|
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
|
||||||
with _git_mutation_lock(ctx):
|
with _git_mutation_lock(ctx):
|
||||||
_run_git(ctx, ["add", "--", *_pathspecs(ctx, paths)], check=True)
|
_block_filtered_destructive_write(
|
||||||
|
ctx,
|
||||||
|
"Repository uses local Git filters; stage may corrupt index content. Use the terminal to stage manually.",
|
||||||
|
)
|
||||||
|
_run_git(
|
||||||
|
ctx,
|
||||||
|
["add", "--", *_pathspecs(ctx, paths)],
|
||||||
|
check=True,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
return git_status(workspace)
|
return git_status(workspace)
|
||||||
|
|
||||||
|
|
||||||
@@ -982,9 +1334,14 @@ def git_unstage(workspace: str | Path, paths: Iterable[str]) -> dict:
|
|||||||
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
|
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
|
||||||
specs = _pathspecs(ctx, paths)
|
specs = _pathspecs(ctx, paths)
|
||||||
with _git_mutation_lock(ctx):
|
with _git_mutation_lock(ctx):
|
||||||
result = _run_git(ctx, ["restore", "--staged", "--", *specs], check=False)
|
result = _run_git(
|
||||||
|
ctx,
|
||||||
|
["restore", "--staged", "--", *specs],
|
||||||
|
check=False,
|
||||||
|
destructive=True,
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
_run_git(ctx, ["reset", "HEAD", "--", *specs], check=True)
|
_run_git(ctx, ["reset", "HEAD", "--", *specs], check=True, destructive=True)
|
||||||
return git_status(workspace)
|
return git_status(workspace)
|
||||||
|
|
||||||
|
|
||||||
@@ -993,6 +1350,11 @@ def git_discard(workspace: str | Path, paths: Iterable[str], *, delete_untracked
|
|||||||
if ctx is None:
|
if ctx is None:
|
||||||
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
|
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
|
||||||
with _git_mutation_lock(ctx):
|
with _git_mutation_lock(ctx):
|
||||||
|
_block_filtered_destructive_write(
|
||||||
|
ctx,
|
||||||
|
"Repository uses local Git filters; discard may corrupt working-tree content. "
|
||||||
|
"Use the terminal to discard manually.",
|
||||||
|
)
|
||||||
status = git_status(workspace)
|
status = git_status(workspace)
|
||||||
by_path = {f["path"]: f for f in status.get("files", [])}
|
by_path = {f["path"]: f for f in status.get("files", [])}
|
||||||
for path in _clean_paths(paths):
|
for path in _clean_paths(paths):
|
||||||
@@ -1017,7 +1379,13 @@ def git_discard(workspace: str | Path, paths: Iterable[str], *, delete_untracked
|
|||||||
# reported it but before this discard reaches unlink.
|
# reported it but before this discard reaches unlink.
|
||||||
pass
|
pass
|
||||||
continue
|
continue
|
||||||
_run_git(ctx, ["restore", "--worktree", "--", repo_rel], check=True)
|
_run_git(
|
||||||
|
ctx,
|
||||||
|
["restore", "--worktree", "--", repo_rel],
|
||||||
|
check=True,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
return git_status(workspace)
|
return git_status(workspace)
|
||||||
|
|
||||||
|
|
||||||
@@ -1042,11 +1410,13 @@ def _staged_diff_text(ctx: GitContext) -> tuple[str, bool]:
|
|||||||
"diff",
|
"diff",
|
||||||
"--cached",
|
"--cached",
|
||||||
"--no-ext-diff",
|
"--no-ext-diff",
|
||||||
|
"--no-textconv",
|
||||||
"--unified=3",
|
"--unified=3",
|
||||||
"--",
|
"--",
|
||||||
_workspace_pathspec(ctx),
|
_workspace_pathspec(ctx),
|
||||||
],
|
],
|
||||||
check=True,
|
check=True,
|
||||||
|
neutralize_filter_programs=True,
|
||||||
)
|
)
|
||||||
diff = result.stdout or ""
|
diff = result.stdout or ""
|
||||||
encoded = diff.encode("utf-8", errors="replace")
|
encoded = diff.encode("utf-8", errors="replace")
|
||||||
@@ -1056,17 +1426,35 @@ def _staged_diff_text(ctx: GitContext) -> tuple[str, bool]:
|
|||||||
|
|
||||||
|
|
||||||
def _selected_temp_index_env(ctx: GitContext, specs: list[str]) -> tuple[dict[str, str], str]:
|
def _selected_temp_index_env(ctx: GitContext, specs: list[str]) -> tuple[dict[str, str], str]:
|
||||||
|
_block_filtered_destructive_write(
|
||||||
|
ctx,
|
||||||
|
"Repository uses local Git filters; selected commit staging may corrupt index content. "
|
||||||
|
"Use the terminal to commit manually.",
|
||||||
|
)
|
||||||
fd, index_path = tempfile.mkstemp(prefix="hermes-webui-git-index-")
|
fd, index_path = tempfile.mkstemp(prefix="hermes-webui-git-index-")
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
Path(index_path).unlink(missing_ok=True)
|
Path(index_path).unlink(missing_ok=True)
|
||||||
env = {"GIT_INDEX_FILE": index_path}
|
env = {"GIT_INDEX_FILE": index_path}
|
||||||
try:
|
try:
|
||||||
head = _run_git(ctx, ["rev-parse", "--verify", "HEAD"], check=False, env=env)
|
head = _run_git(
|
||||||
|
ctx,
|
||||||
|
["rev-parse", "--verify", "HEAD"],
|
||||||
|
check=False,
|
||||||
|
env=env,
|
||||||
|
destructive=True,
|
||||||
|
)
|
||||||
if head.returncode == 0:
|
if head.returncode == 0:
|
||||||
_run_git(ctx, ["read-tree", "HEAD"], check=True, env=env)
|
_run_git(ctx, ["read-tree", "HEAD"], check=True, env=env, destructive=True)
|
||||||
else:
|
else:
|
||||||
_run_git(ctx, ["read-tree", "--empty"], check=True, env=env)
|
_run_git(ctx, ["read-tree", "--empty"], check=True, env=env, destructive=True)
|
||||||
_run_git(ctx, ["add", "-A", "--", *specs], check=True, env=env)
|
_run_git(
|
||||||
|
ctx,
|
||||||
|
["add", "-A", "--", *specs],
|
||||||
|
check=True,
|
||||||
|
env=env,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
return env, index_path
|
return env, index_path
|
||||||
except Exception:
|
except Exception:
|
||||||
Path(index_path).unlink(missing_ok=True)
|
Path(index_path).unlink(missing_ok=True)
|
||||||
@@ -1102,9 +1490,11 @@ def _selected_diff_text(ctx: GitContext, specs: list[str]) -> tuple[str, bool]:
|
|||||||
try:
|
try:
|
||||||
result = _run_git(
|
result = _run_git(
|
||||||
ctx,
|
ctx,
|
||||||
["diff", "--cached", "--no-ext-diff", "--unified=3", "--", *specs],
|
["diff", "--cached", "--no-ext-diff", "--no-textconv", "--unified=3", "--", *specs],
|
||||||
check=True,
|
check=True,
|
||||||
env=env,
|
env=env,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
)
|
)
|
||||||
diff = result.stdout or ""
|
diff = result.stdout or ""
|
||||||
encoded = diff.encode("utf-8", errors="replace")
|
encoded = diff.encode("utf-8", errors="replace")
|
||||||
@@ -1223,7 +1613,14 @@ def git_commit(workspace: str | Path, message: str) -> dict:
|
|||||||
if ctx is None:
|
if ctx is None:
|
||||||
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
|
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
|
||||||
with _git_mutation_lock(ctx):
|
with _git_mutation_lock(ctx):
|
||||||
_run_git(ctx, ["commit", "-m", msg], timeout=10, check=True)
|
_run_git(
|
||||||
|
ctx,
|
||||||
|
["commit", "-m", msg],
|
||||||
|
timeout=10,
|
||||||
|
check=True,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
sha = _run_git(ctx, ["rev-parse", "--short", "HEAD"], check=True).stdout.strip()
|
sha = _run_git(ctx, ["rev-parse", "--short", "HEAD"], check=True).stdout.strip()
|
||||||
return {"ok": True, "commit": sha, "status": git_status(workspace)}
|
return {"ok": True, "commit": sha, "status": git_status(workspace)}
|
||||||
|
|
||||||
@@ -1239,11 +1636,31 @@ def git_commit_selected(workspace: str | Path, message: str, paths: Iterable[str
|
|||||||
specs, workspace_paths, _selected_files_list = _selected_files(ctx, paths)
|
specs, workspace_paths, _selected_files_list = _selected_files(ctx, paths)
|
||||||
env, index_path = _selected_temp_index_env(ctx, specs)
|
env, index_path = _selected_temp_index_env(ctx, specs)
|
||||||
try:
|
try:
|
||||||
quiet = _run_git(ctx, ["diff", "--cached", "--quiet", "--", *specs], check=False, env=env)
|
quiet = _run_git(
|
||||||
|
ctx,
|
||||||
|
["diff", "--cached", "--quiet", "--no-textconv", "--", *specs],
|
||||||
|
check=False,
|
||||||
|
env=env,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
if quiet.returncode == 0:
|
if quiet.returncode == 0:
|
||||||
raise GitWorkspaceError("Selected paths have no committable changes")
|
raise GitWorkspaceError("Selected paths have no committable changes")
|
||||||
_run_git(ctx, ["commit", "-m", msg], timeout=10, check=True, env=env)
|
_run_git(
|
||||||
_run_git(ctx, ["reset", "-q", "HEAD", "--", *specs], check=True)
|
ctx,
|
||||||
|
["commit", "-m", msg],
|
||||||
|
timeout=10,
|
||||||
|
check=True,
|
||||||
|
env=env,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
)
|
||||||
|
_run_git(
|
||||||
|
ctx,
|
||||||
|
["reset", "-q", "HEAD", "--", *specs],
|
||||||
|
check=True,
|
||||||
|
destructive=True,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
Path(index_path).unlink(missing_ok=True)
|
Path(index_path).unlink(missing_ok=True)
|
||||||
sha = _run_git(ctx, ["rev-parse", "--short", "HEAD"], check=True).stdout.strip()
|
sha = _run_git(ctx, ["rev-parse", "--short", "HEAD"], check=True).stdout.strip()
|
||||||
@@ -1266,7 +1683,16 @@ def git_fetch(workspace: str | Path) -> dict:
|
|||||||
if ctx is None:
|
if ctx is None:
|
||||||
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
|
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
|
||||||
with _git_mutation_lock(ctx):
|
with _git_mutation_lock(ctx):
|
||||||
result = _run_git(ctx, ["fetch", "--prune"], timeout=GIT_REMOTE_TIMEOUT, check=True)
|
result = _run_git(
|
||||||
|
ctx,
|
||||||
|
["fetch", "--prune", "--no-recurse-submodules"],
|
||||||
|
timeout=GIT_REMOTE_TIMEOUT,
|
||||||
|
check=True,
|
||||||
|
force_destructive_hardening=True,
|
||||||
|
disable_filter_attributes=workspace_git_destructive_enabled(),
|
||||||
|
neutralize_filter_programs=True,
|
||||||
|
neutralize_remote_helpers=True,
|
||||||
|
)
|
||||||
return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)}
|
return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)}
|
||||||
|
|
||||||
|
|
||||||
@@ -1275,7 +1701,20 @@ def git_pull(workspace: str | Path) -> dict:
|
|||||||
if ctx is None:
|
if ctx is None:
|
||||||
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
|
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
|
||||||
with _git_mutation_lock(ctx):
|
with _git_mutation_lock(ctx):
|
||||||
result = _run_git(ctx, ["pull", "--ff-only"], timeout=GIT_REMOTE_TIMEOUT, check=True)
|
_block_filtered_destructive_write(
|
||||||
|
ctx,
|
||||||
|
"Repository uses local Git filters; pull may corrupt working-tree content. Use the terminal to pull manually.",
|
||||||
|
)
|
||||||
|
result = _run_git(
|
||||||
|
ctx,
|
||||||
|
["pull", "--ff-only", "--no-recurse-submodules"],
|
||||||
|
timeout=GIT_REMOTE_TIMEOUT,
|
||||||
|
check=True,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
neutralize_filter_programs=True,
|
||||||
|
neutralize_remote_helpers=True,
|
||||||
|
)
|
||||||
return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)}
|
return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)}
|
||||||
|
|
||||||
|
|
||||||
@@ -1292,5 +1731,14 @@ def git_push(workspace: str | Path) -> dict:
|
|||||||
if "origin" not in remotes:
|
if "origin" not in remotes:
|
||||||
raise GitWorkspaceError("No upstream branch or origin remote is configured", "no_upstream")
|
raise GitWorkspaceError("No upstream branch or origin remote is configured", "no_upstream")
|
||||||
args.extend(["-u", "origin", branch])
|
args.extend(["-u", "origin", branch])
|
||||||
result = _run_git(ctx, args, timeout=GIT_REMOTE_TIMEOUT, check=True)
|
result = _run_git(
|
||||||
|
ctx,
|
||||||
|
args,
|
||||||
|
timeout=GIT_REMOTE_TIMEOUT,
|
||||||
|
check=True,
|
||||||
|
destructive=True,
|
||||||
|
disable_filter_attributes=True,
|
||||||
|
neutralize_filter_programs=True,
|
||||||
|
neutralize_remote_helpers=True,
|
||||||
|
)
|
||||||
return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)}
|
return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)}
|
||||||
@@ -12,10 +12,16 @@ class _ConfigState:
|
|||||||
self.old_cache = config._available_models_cache
|
self.old_cache = config._available_models_cache
|
||||||
self.old_cache_ts = config._available_models_cache_ts
|
self.old_cache_ts = config._available_models_cache_ts
|
||||||
self.old_cache_fp = config._available_models_cache_source_fingerprint
|
self.old_cache_fp = config._available_models_cache_source_fingerprint
|
||||||
|
self.old_live_budget = config._LIVE_REBUILD_BUDGET_SECONDS
|
||||||
config._cfg_mtime = 0.0
|
config._cfg_mtime = 0.0
|
||||||
config._available_models_cache = None
|
config._available_models_cache = None
|
||||||
config._available_models_cache_ts = 0.0
|
config._available_models_cache_ts = 0.0
|
||||||
config._available_models_cache_source_fingerprint = None
|
config._available_models_cache_source_fingerprint = None
|
||||||
|
# This file pins custom-endpoint error shaping, not the separate async
|
||||||
|
# provider-catalog budget fallback. Force the synchronous rebuild path
|
||||||
|
# so slower Python 3.11 shards cannot fall onto the global 4s fallback
|
||||||
|
# and mask the endpoint-specific error contract under test.
|
||||||
|
config._LIVE_REBUILD_BUDGET_SECONDS = 0.0
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc, tb):
|
def __exit__(self, exc_type, exc, tb):
|
||||||
@@ -24,6 +30,7 @@ class _ConfigState:
|
|||||||
config._available_models_cache = self.old_cache
|
config._available_models_cache = self.old_cache
|
||||||
config._available_models_cache_ts = self.old_cache_ts
|
config._available_models_cache_ts = self.old_cache_ts
|
||||||
config._available_models_cache_source_fingerprint = self.old_cache_fp
|
config._available_models_cache_source_fingerprint = self.old_cache_fp
|
||||||
|
config._LIVE_REBUILD_BUDGET_SECONDS = self.old_live_budget
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user