fix(git): refuse repository config that runs a program before push, release and gc
An agent that can write a repository's .git/config could make the operator's pkgmgr push, release or branch gc run a program or reroute the push. UNSAFE_CONFIG lists the local keys that do (core.sshCommand, gitProxy, askPass, fsmonitor, alternateRefsCommand, credential helpers, gpg programs, protocol.*, config hooks, gc.recentObjectsHook, http proxy/TLS/cookie keys, remote pack programs and proxy, url rewrites). push and release refuse up front; gc skips its fetch and keeps remote branches. Filter drivers, hooksPath, gpg.format and include stay allowed for git-lfs, husky and per-repo signing. SCAN_FLAGS add --no-pager and switch off config-defined hooks (hook.<event>.enabled=false), which core.hooksPath does not cover. The signing amend, gc's fetch, delete push and branch -D run under them. Remote and ref names sit behind -- in every push, fetch, ls-remote and remote get-url, fetches and pushes no longer recurse into submodules, and a staged gitlink is no longer folded into the signed HEAD. gc skips a compose project that also has containers outside the worktree. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 91 % ⏱️ Time: 89 % 🛡️ Security: 100 % 🏆 Score: 100 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fb6183838b
commit
4c8b137449
13 files changed
+467
-22
No files matched your search
@@ -10,15 +10,15 @@ from collections.abc import Callable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
|
||||
from pkgmgr.actions.worktree.hooks import FINI_HOOK, hook_command, run_hook
|
||||
from pkgmgr.core.git.commands import delete_local_branch
|
||||
from pkgmgr.core.git.errors import GitRunError
|
||||
from pkgmgr.core.git.queries import (
|
||||
FILTER_CONFIG,
|
||||
UNSAFE_CONFIG,
|
||||
list_remotes,
|
||||
local_config,
|
||||
resolve_base_branch,
|
||||
)
|
||||
from pkgmgr.core.git.run import END_OF_OPTIONS, run
|
||||
from pkgmgr.core.git.run import END_OF_OPTIONS, SCAN_FLAGS, run
|
||||
|
||||
from .gc_forge import ForgeFacts, fetch_forge_facts
|
||||
from .nofollow import canonical, remove_tree
|
||||
@@ -135,6 +135,7 @@ class Repo:
|
||||
hooks: dict[str, list[str]]
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
items: list[Item] = field(default_factory=list)
|
||||
unsafe: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def host_pid_namespace() -> bool:
|
||||
@@ -434,12 +435,31 @@ def _skip(
|
||||
return "worktree checkout missing"
|
||||
if repo.containers is None:
|
||||
return "docker unavailable"
|
||||
shared = sorted(
|
||||
{
|
||||
c.project
|
||||
for c in repo.containers
|
||||
if c.project in item.compose_projects
|
||||
and not _inside(c.working_dir, tree.path)
|
||||
}
|
||||
)
|
||||
if shared:
|
||||
return (
|
||||
f"compose project {', '.join(shared)} also has containers "
|
||||
"outside the worktree"
|
||||
)
|
||||
lock = _lock_skip(tree.locked, repo.host_ns)
|
||||
if lock:
|
||||
return lock
|
||||
return _busy(cwds, tree.path)
|
||||
|
||||
|
||||
def _remote_refusal(unsafe: list[str], item: Item) -> str | None:
|
||||
if unsafe and any(a.startswith("remote-branch:") for a in item.actions):
|
||||
return f"repository config sets {', '.join(unsafe)}"
|
||||
return None
|
||||
|
||||
|
||||
def _busy(cwds: dict[int, str], path: str) -> str | None:
|
||||
busy = sorted(pid for pid, cwd in cwds.items() if _inside(cwd, path))
|
||||
return f"process: pid {busy[0]} ({_comm(busy[0])})" if busy else None
|
||||
@@ -608,6 +628,8 @@ def _build(
|
||||
):
|
||||
item.skip = f"younger than {repo.min_age:g} h"
|
||||
item.actions = _actions(repo, item, tree, checkout, dirty, registrations)
|
||||
if repo.unsafe:
|
||||
item.actions = [a for a in item.actions if not a.startswith("remote-branch:")]
|
||||
return item
|
||||
|
||||
|
||||
@@ -774,12 +796,26 @@ def plan_repo(
|
||||
if scope not in SCOPES:
|
||||
raise ValueError(f"unknown scope {scope!r}")
|
||||
warnings: list[str] = []
|
||||
if fetch:
|
||||
unsafe = local_config(repo_root, UNSAFE_CONFIG)
|
||||
if unsafe:
|
||||
warnings.append(
|
||||
f"fetch and remote branch deletes skipped: the repository config sets "
|
||||
f"{', '.join(unsafe)}"
|
||||
)
|
||||
if fetch and not unsafe:
|
||||
try:
|
||||
# Auto-maintenance runs worktree-prune, which deletes the metadata of
|
||||
# sandboxed worktrees whose gitdir stub is missing.
|
||||
run(
|
||||
["fetch", "--all", "--prune", "--quiet", "--no-auto-maintenance"],
|
||||
[
|
||||
*SCAN_FLAGS,
|
||||
"fetch",
|
||||
"--all",
|
||||
"--prune",
|
||||
"--quiet",
|
||||
"--no-auto-maintenance",
|
||||
"--no-recurse-submodules",
|
||||
],
|
||||
cwd=repo_root,
|
||||
)
|
||||
except GitRunError as exc:
|
||||
@@ -836,6 +872,7 @@ def plan_repo(
|
||||
activity=activity,
|
||||
hooks=_pins(hooks, repo_root, warnings),
|
||||
warnings=warnings,
|
||||
unsafe=unsafe,
|
||||
)
|
||||
repo.items = _items(repo)
|
||||
return repo
|
||||
@@ -953,6 +990,10 @@ def execute(repo: Repo, item: Item) -> None:
|
||||
if _fingerprint(fresh) != _fingerprint(item):
|
||||
item.result = f"skipped: changed since plan ({fresh.skip or fresh.reason})"
|
||||
return
|
||||
refusal = _remote_refusal(local_config(repo.repo, UNSAFE_CONFIG), item)
|
||||
if refusal:
|
||||
item.result = f"skipped: {refusal}"
|
||||
return
|
||||
|
||||
notes: list[str] = []
|
||||
removes_tree = "worktree" in item.actions
|
||||
@@ -976,12 +1017,13 @@ def execute(repo: Repo, item: Item) -> None:
|
||||
if removes_tree:
|
||||
_remove_worktree(repo.repo, item.worktree)
|
||||
if "local-branch" in item.actions:
|
||||
delete_local_branch(item.branch, cwd=repo.repo, force=True)
|
||||
_git(["branch", "-D", "--", item.branch], repo.repo)
|
||||
for remote, sha in item.remotes.items():
|
||||
if f"remote-branch:{remote}" not in item.actions:
|
||||
continue
|
||||
run(
|
||||
[
|
||||
*SCAN_FLAGS,
|
||||
"push",
|
||||
f"--force-with-lease=refs/heads/{item.branch}:{sha}",
|
||||
"--",
|
||||
|
||||
@@ -4,7 +4,11 @@ import os
|
||||
import sys
|
||||
|
||||
from pkgmgr.actions.branch import close_branch
|
||||
from pkgmgr.actions.repository.push import push_branch, sign_unpushed
|
||||
from pkgmgr.actions.repository.push import (
|
||||
push_branch,
|
||||
refuse_unsafe_config,
|
||||
sign_unpushed,
|
||||
)
|
||||
from pkgmgr.core.git import GitRunError
|
||||
from pkgmgr.core.git.commands import add, commit, tag_annotated
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
@@ -47,6 +51,7 @@ def _release_impl(
|
||||
branch = "main"
|
||||
print(f"Releasing on branch: {branch}")
|
||||
|
||||
refuse_unsafe_config(".")
|
||||
# Pull BEFORE making any modifications
|
||||
ensure_clean_and_synced(preview=preview)
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from pkgmgr.actions.repository._parallel import (
|
||||
run_on_repos,
|
||||
)
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
from pkgmgr.core.git.queries import UNSAFE_CONFIG, local_config
|
||||
from pkgmgr.core.git.run import SCAN_FLAGS
|
||||
|
||||
Repository = dict[str, Any]
|
||||
|
||||
@@ -28,7 +30,28 @@ def _try_git(repo_dir: str, *args: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def refuse_unsafe_config(repo_dir: str) -> None:
|
||||
"""
|
||||
Args:
|
||||
repo_dir: checkout about to be signed or pushed.
|
||||
|
||||
Raises:
|
||||
PushRefusedError: the repository's own config, which an agent may have
|
||||
written, sets a key that makes git run a program or reroute the push.
|
||||
Filter drivers stay allowed on purpose: git-lfs and git-crypt need
|
||||
them locally, so a clean filter can still run in the amend's index
|
||||
refresh, as it did before.
|
||||
"""
|
||||
unsafe = local_config(repo_dir, UNSAFE_CONFIG)
|
||||
if unsafe:
|
||||
raise PushRefusedError(
|
||||
f"{repo_dir}: the repository config sets {', '.join(unsafe)}, which can "
|
||||
"run a program or reroute the push; unset it or move it to the global config"
|
||||
)
|
||||
|
||||
|
||||
def _push_target(repo_dir: str) -> tuple[str, str]:
|
||||
refuse_unsafe_config(repo_dir)
|
||||
branch = run(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_dir)
|
||||
if branch == "HEAD":
|
||||
raise PushRefusedError(f"{repo_dir}: detached HEAD, nothing to push")
|
||||
@@ -69,7 +92,11 @@ def sign_unpushed(repo_dir: str, *, preview: bool = False) -> None:
|
||||
preview: print the fetch and the amend instead of running them.
|
||||
"""
|
||||
branch, remote = _push_target(repo_dir)
|
||||
run(["fetch", "--quiet", remote], cwd=repo_dir, preview=preview)
|
||||
run(
|
||||
["fetch", "--quiet", "--no-recurse-submodules", "--", remote],
|
||||
cwd=repo_dir,
|
||||
preview=preview,
|
||||
)
|
||||
base, signatures = _unpushed_signatures(repo_dir, remote, branch)
|
||||
head = run(["rev-parse", "HEAD"], cwd=repo_dir)
|
||||
older = sum(1 for sha, s in signatures.items() if sha != head and s != "G")
|
||||
@@ -80,11 +107,23 @@ def sign_unpushed(repo_dir: str, *, preview: bool = False) -> None:
|
||||
)
|
||||
if signatures.get(head, "G") == "G":
|
||||
return
|
||||
if run(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir):
|
||||
raise PushRefusedError(f"{repo_dir}: uncommitted changes, cannot sign HEAD")
|
||||
if run(
|
||||
[
|
||||
*SCAN_FLAGS,
|
||||
"diff",
|
||||
"--cached",
|
||||
"--name-only",
|
||||
"--no-ext-diff",
|
||||
"--ignore-submodules=none",
|
||||
"HEAD",
|
||||
],
|
||||
cwd=repo_dir,
|
||||
):
|
||||
raise PushRefusedError(f"{repo_dir}: staged changes, cannot sign HEAD")
|
||||
|
||||
cmd = [
|
||||
"git",
|
||||
*SCAN_FLAGS,
|
||||
"commit",
|
||||
"--amend",
|
||||
"--no-edit",
|
||||
@@ -113,12 +152,23 @@ def _lease(
|
||||
if tracking is not None:
|
||||
sha = _try_git(repo_dir, "rev-parse", "--verify", "--quiet", tracking) or ""
|
||||
else:
|
||||
listed = run(["ls-remote", url, branch_ref], cwd=repo_dir).splitlines()
|
||||
listed = run(["ls-remote", "--", url, branch_ref], cwd=repo_dir).splitlines()
|
||||
sha = next(
|
||||
(ln.split()[0] for ln in listed if ln.split()[1:] == [branch_ref]), ""
|
||||
)
|
||||
if sha and not preview:
|
||||
run(["fetch", "--quiet", "--no-tags", url, branch_ref], cwd=repo_dir)
|
||||
run(
|
||||
[
|
||||
"fetch",
|
||||
"--quiet",
|
||||
"--no-tags",
|
||||
"--no-recurse-submodules",
|
||||
"--",
|
||||
url,
|
||||
branch_ref,
|
||||
],
|
||||
cwd=repo_dir,
|
||||
)
|
||||
if not sha or preview:
|
||||
return sha
|
||||
lost: set[str] | None = None
|
||||
@@ -173,9 +223,9 @@ def push_branch(
|
||||
f"{repo_dir}: HEAD {head[:10]} is unsigned; run `pkgmgr push` to sign it"
|
||||
)
|
||||
|
||||
fetch_url = run(["remote", "get-url", remote], cwd=repo_dir)
|
||||
fetch_url = run(["remote", "get-url", "--", remote], cwd=repo_dir)
|
||||
push_urls = run(
|
||||
["remote", "get-url", "--push", "--all", remote], cwd=repo_dir
|
||||
["remote", "get-url", "--push", "--all", "--", remote], cwd=repo_dir
|
||||
).splitlines()
|
||||
branch_ref = f"refs/heads/{branch}"
|
||||
tracking = f"refs/remotes/{remote}/{branch}"
|
||||
@@ -205,8 +255,10 @@ def push_branch(
|
||||
[
|
||||
"push",
|
||||
"--follow-tags",
|
||||
"--no-recurse-submodules",
|
||||
f"--force-with-lease={branch_ref}:{expect}",
|
||||
*extra_args,
|
||||
"--",
|
||||
url,
|
||||
branch_ref,
|
||||
*refs,
|
||||
|
||||
@@ -10,7 +10,7 @@ class GitDeleteRemoteBranchError(GitCommandError):
|
||||
|
||||
def delete_remote_branch(remote: str, branch: str, cwd: str = ".") -> None:
|
||||
try:
|
||||
run(["push", remote, "--delete", branch], cwd=cwd)
|
||||
run(["push", "--delete", "--", remote, branch], cwd=cwd)
|
||||
except GitRunError as exc:
|
||||
raise GitDeleteRemoteBranchError(
|
||||
f"Failed to delete remote branch {branch!r} on {remote!r}.",
|
||||
|
||||
@@ -20,11 +20,9 @@ def push(
|
||||
Push a ref to a remote, optionally forced.
|
||||
|
||||
Equivalent to:
|
||||
git push <remote> <ref> [--force]
|
||||
git push [--force] -- <remote> <ref>
|
||||
"""
|
||||
args = ["push", remote, ref]
|
||||
if force:
|
||||
args.append("--force")
|
||||
args = ["push", *(["--force"] if force else []), "--", remote, ref]
|
||||
|
||||
try:
|
||||
run(args, cwd=cwd, preview=preview)
|
||||
|
||||
@@ -19,10 +19,10 @@ def push_upstream(
|
||||
Push a branch and set upstream tracking.
|
||||
|
||||
Equivalent to:
|
||||
git push -u <remote> <branch>
|
||||
git push -u -- <remote> <branch>
|
||||
"""
|
||||
try:
|
||||
run(["push", "-u", remote, branch], cwd=cwd, preview=preview)
|
||||
run(["push", "-u", "--", remote, branch], cwd=cwd, preview=preview)
|
||||
except GitRunError as exc:
|
||||
raise GitPushUpstreamError(
|
||||
f"Failed to push branch {branch!r} to {remote!r} with upstream tracking.",
|
||||
|
||||
@@ -20,7 +20,7 @@ from .get_tags_at_ref import GitTagsAtRefQueryError, get_tags_at_ref
|
||||
from .get_upstream_ref import get_upstream_ref
|
||||
from .list_remotes import list_remotes
|
||||
from .list_tags import list_tags
|
||||
from .local_config import FILTER_CONFIG, local_config
|
||||
from .local_config import FILTER_CONFIG, UNSAFE_CONFIG, local_config
|
||||
from .probe_remote_reachable import (
|
||||
probe_remote_reachable,
|
||||
probe_remote_reachable_detail,
|
||||
@@ -29,6 +29,7 @@ from .resolve_base_branch import GitBaseBranchNotFoundError, resolve_base_branch
|
||||
|
||||
__all__ = [
|
||||
"FILTER_CONFIG",
|
||||
"UNSAFE_CONFIG",
|
||||
"GitBaseBranchNotFoundError",
|
||||
"GitChangelogQueryError",
|
||||
"GitLatestSigningKeyQueryError",
|
||||
|
||||
@@ -4,6 +4,14 @@ from ..errors import GitRunError
|
||||
from ..run import run
|
||||
|
||||
FILTER_CONFIG = r"^filter\."
|
||||
UNSAFE_CONFIG = (
|
||||
r"^(core\.(sshcommand|gitproxy|askpass|fsmonitor|alternaterefscommand)$"
|
||||
r"|credential\.(.+\.)?helper$|gpg\.(.+\.)?program$|gpg\.ssh\.defaultkeycommand$"
|
||||
r"|protocol\.|hook\.|gc\.recentobjectshook$"
|
||||
r"|http\.(.+\.)?(proxy|sslverify|sslcainfo|sslcapath|cookiefile|savecookies)$"
|
||||
r"|remote\..+\.(receivepack|uploadpack|vcs|proxy)$"
|
||||
r"|url\..+\.(insteadof|pushinsteadof)$)"
|
||||
)
|
||||
LOCAL_SCOPES = ("local", "worktree")
|
||||
|
||||
|
||||
|
||||
@@ -6,10 +6,26 @@ import subprocess
|
||||
from .errors import GitNotRepositoryError, GitRunError
|
||||
|
||||
SCAN_FLAGS = (
|
||||
"--no-pager",
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.hooksPath=/dev/null",
|
||||
# hook.<name>.command hooks from the config ignore core.hooksPath
|
||||
"-c",
|
||||
"hook.reference-transaction.enabled=false",
|
||||
"-c",
|
||||
"hook.prepare-commit-msg.enabled=false",
|
||||
"-c",
|
||||
"hook.post-commit.enabled=false",
|
||||
"-c",
|
||||
"hook.post-rewrite.enabled=false",
|
||||
"-c",
|
||||
"hook.post-index-change.enabled=false",
|
||||
"-c",
|
||||
"hook.pre-push.enabled=false",
|
||||
"-c",
|
||||
"hook.post-checkout.enabled=false",
|
||||
"-c",
|
||||
"core.pager=cat",
|
||||
"-c",
|
||||
|
||||
@@ -109,6 +109,7 @@ class GcFixture:
|
||||
scope: str = "all",
|
||||
activity: bool = False,
|
||||
hooks: dict | None = None,
|
||||
containers: list[gc.Container] | None = None,
|
||||
) -> gc.Repo:
|
||||
with (
|
||||
patch.object(gc, "fetch_forge_facts", return_value=self.forge),
|
||||
@@ -116,7 +117,7 @@ class GcFixture:
|
||||
):
|
||||
return gc.plan_repo(
|
||||
self.repo,
|
||||
[],
|
||||
containers or [],
|
||||
host_ns,
|
||||
merged_into=merged_into or [],
|
||||
sizes=sizes,
|
||||
@@ -388,6 +389,97 @@ class TestBranchGc(GcFixture, unittest.TestCase):
|
||||
self.assertEqual(git(self.repo, "for-each-ref", "refs/heads/-evil"), "")
|
||||
self.assertEqual(git(other, "for-each-ref", "refs/heads/-evil"), "")
|
||||
|
||||
def _fetches(self) -> int:
|
||||
with patch.object(gc, "run", wraps=gc.run) as spy:
|
||||
self.plan = self._plan(host_ns=True, cwds={})
|
||||
return sum("fetch" in call.args[0] for call in spy.call_args_list)
|
||||
|
||||
def test_unsafe_repository_config_skips_fetch_and_remote_deletes(self) -> None:
|
||||
self.assertEqual(self._fetches(), 1)
|
||||
git(self.repo, "config", "core.sshCommand", "false")
|
||||
|
||||
self.assertEqual(self._fetches(), 0)
|
||||
plan = self.plan
|
||||
item = self._items(plan)["merged"]
|
||||
|
||||
self.assertIn(
|
||||
"fetch and remote branch deletes skipped: the repository config sets "
|
||||
"core.sshcommand",
|
||||
plan.warnings,
|
||||
)
|
||||
self.assertIsNone(item.skip)
|
||||
self.assertEqual(item.actions, ["worktree", "local-branch"])
|
||||
|
||||
with patch.object(gc, "_process_cwds", return_value={}):
|
||||
gc.execute(plan, item)
|
||||
|
||||
self.assertEqual(item.result, "done")
|
||||
self.assertFalse(os.path.exists(self.merged_wt))
|
||||
self.assertEqual(git(self.origin, "branch", "--list", "merged"), "merged")
|
||||
|
||||
def test_config_set_after_plan_stops_execute(self) -> None:
|
||||
plan = self._plan(host_ns=True, cwds={})
|
||||
item = self._items(plan)["merged"]
|
||||
git(self.repo, "config", "credential.helper", "false")
|
||||
|
||||
with patch.object(gc, "_process_cwds", return_value={}):
|
||||
gc.execute(plan, item)
|
||||
|
||||
self.assertEqual(
|
||||
item.result, "skipped: repository config sets credential.helper"
|
||||
)
|
||||
self.assertTrue(os.path.isdir(self.merged_wt))
|
||||
self.assertEqual(git(self.origin, "branch", "--list", "merged"), "merged")
|
||||
|
||||
def test_compose_project_with_containers_elsewhere_is_skipped(self) -> None:
|
||||
inside = gc.Container("proj", os.path.join(self.merged_wt, "app"), "img")
|
||||
elsewhere = gc.Container("proj", os.path.join(self.root, "other"), "img")
|
||||
|
||||
alone = self._items(self._plan(host_ns=True, cwds={}, containers=[inside]))
|
||||
shared = self._items(
|
||||
self._plan(host_ns=True, cwds={}, containers=[inside, elsewhere])
|
||||
)
|
||||
|
||||
self.assertIsNone(alone["merged"].skip)
|
||||
self.assertIn("compose-down:proj", alone["merged"].actions)
|
||||
self.assertEqual(
|
||||
shared["merged"].skip,
|
||||
"compose project proj also has containers outside the worktree",
|
||||
)
|
||||
self.assertFalse(shared["merged"].eligible)
|
||||
|
||||
def test_execute_runs_no_repository_hook(self) -> None:
|
||||
marker = os.path.join(self.root, "hooked")
|
||||
hook = os.path.join(self.repo, ".git", "hooks", "reference-transaction")
|
||||
with open(hook, "w") as fh:
|
||||
fh.write(f"#!/bin/sh\ntouch {marker}\n")
|
||||
os.chmod(hook, 0o755)
|
||||
plan = self._plan(host_ns=True, cwds={})
|
||||
item = self._items(plan)["merged"]
|
||||
|
||||
with patch.object(gc, "_process_cwds", return_value={}):
|
||||
gc.execute(plan, item)
|
||||
|
||||
self.assertEqual(item.result, "done")
|
||||
self.assertEqual(git(self.origin, "branch", "--list", "merged"), "")
|
||||
self.assertFalse(os.path.exists(marker))
|
||||
|
||||
def test_local_branch_delete_runs_no_config_hook(self) -> None:
|
||||
marker = os.path.join(self.root, "hooked")
|
||||
git(self.repo, "branch", "local-only", "merged")
|
||||
git(self.repo, "config", "hook.probe.command", f"touch {marker}")
|
||||
git(self.repo, "config", "hook.probe.event", "reference-transaction")
|
||||
plan = self._plan(host_ns=True, cwds={})
|
||||
item = self._items(plan)["local-only"]
|
||||
self.assertEqual(item.actions, ["local-branch"])
|
||||
|
||||
with patch.object(gc, "_process_cwds", return_value={}):
|
||||
gc.execute(plan, item)
|
||||
|
||||
self.assertEqual(item.result, "done")
|
||||
self.assertEqual(git(self.repo, "branch", "--list", "local-only"), "")
|
||||
self.assertFalse(os.path.exists(marker))
|
||||
|
||||
def test_cleanup_json_and_refusal(self) -> None:
|
||||
out = io.StringIO()
|
||||
with (
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from pkgmgr.core.git.commands import delete_remote_branch, push, push_upstream
|
||||
|
||||
REMOTE = "-r"
|
||||
|
||||
|
||||
def git(cwd: str, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@example.org",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
*args,
|
||||
],
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
class TestPushCommandsEndOptions(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
root = os.path.realpath(self._tmp.name)
|
||||
self.origin = os.path.join(root, "origin.git")
|
||||
self.repo = os.path.join(root, "repo")
|
||||
git(root, "init", "-q", "--bare", "-b", "main", self.origin)
|
||||
git(root, "init", "-q", "-b", "main", self.repo)
|
||||
git(self.repo, "config", f"remote.{REMOTE}.url", self.origin)
|
||||
git(
|
||||
self.repo,
|
||||
"config",
|
||||
f"remote.{REMOTE}.fetch",
|
||||
f"+refs/heads/*:refs/remotes/{REMOTE}/*",
|
||||
)
|
||||
git(self.repo, "commit", "-q", "--allow-empty", "-m", "init")
|
||||
|
||||
def _branches(self) -> str:
|
||||
return git(self.origin, "for-each-ref", "--format=%(refname:short)")
|
||||
|
||||
def test_remote_named_like_an_option(self) -> None:
|
||||
push_upstream(REMOTE, "main", cwd=self.repo)
|
||||
self.assertEqual(self._branches(), "main")
|
||||
self.assertEqual(
|
||||
git(self.repo, "rev-parse", "--abbrev-ref", "@{u}"), f"{REMOTE}/main"
|
||||
)
|
||||
|
||||
git(self.repo, "commit", "-q", "--amend", "--allow-empty", "-m", "rewritten")
|
||||
push(REMOTE, "main:feature", cwd=self.repo)
|
||||
push(REMOTE, "main", force=True, cwd=self.repo)
|
||||
self.assertEqual(
|
||||
git(self.origin, "rev-parse", "main"), git(self.repo, "rev-parse", "HEAD")
|
||||
)
|
||||
|
||||
delete_remote_branch(REMOTE, "feature", cwd=self.repo)
|
||||
self.assertEqual(self._branches(), "main")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.release.retry import retry_release
|
||||
from pkgmgr.actions.release.workflow import _release_impl
|
||||
from pkgmgr.actions.repository.push import (
|
||||
PushRefusedError,
|
||||
push_branch,
|
||||
@@ -240,6 +241,154 @@ class TestPushMirrorLeases(unittest.TestCase):
|
||||
self.assertNotIn("WARN", out)
|
||||
self.assertEqual(self._tip(self.canonical, "refs/heads/feature"), head)
|
||||
|
||||
def test_repository_config_that_runs_a_program_is_refused(self) -> None:
|
||||
self._commit("feature")
|
||||
for key in (
|
||||
"core.sshCommand",
|
||||
"core.gitProxy",
|
||||
"core.askPass",
|
||||
"core.fsmonitor",
|
||||
"core.alternateRefsCommand",
|
||||
"credential.helper",
|
||||
"credential.https://example.org.helper",
|
||||
"gpg.program",
|
||||
"gpg.ssh.program",
|
||||
"gpg.ssh.defaultKeyCommand",
|
||||
"protocol.ext.allow",
|
||||
"hook.probe.command",
|
||||
"gc.recentObjectsHook",
|
||||
"http.proxy",
|
||||
"http.sslVerify",
|
||||
"http.sslCAInfo",
|
||||
"http.sslCAPath",
|
||||
"http.cookieFile",
|
||||
"http.saveCookies",
|
||||
"remote.origin.receivepack",
|
||||
"remote.origin.uploadpack",
|
||||
"remote.origin.vcs",
|
||||
"remote.origin.proxy",
|
||||
"url.x.insteadOf",
|
||||
"url.x.pushInsteadOf",
|
||||
):
|
||||
for push in (self._push, lambda: push_branch(self.repo, [], sign=False)):
|
||||
with self.subTest(key=key, push=push):
|
||||
git(self.repo, "config", key, "false")
|
||||
|
||||
with self.assertRaises(PushRefusedError) as cm:
|
||||
push()
|
||||
|
||||
git(self.repo, "config", "--unset", key)
|
||||
self.assertIn(key.lower(), str(cm.exception))
|
||||
self.assertEqual(self._tip(self.canonical), self.init)
|
||||
self.assertEqual(self._tip(self.mirror), self.init)
|
||||
|
||||
def test_ordinary_repository_config_is_pushed(self) -> None:
|
||||
for key, value in (
|
||||
("core.hooksPath", ".husky/_"),
|
||||
("filter.lfs.process", "git-lfs filter-process"),
|
||||
("gpg.format", "ssh"),
|
||||
("credential.useHttpPath", "true"),
|
||||
("include.path", "../shared.gitconfig"),
|
||||
):
|
||||
git(self.repo, "config", key, value)
|
||||
self._commit("feature")
|
||||
|
||||
self._push()
|
||||
|
||||
self._assert_shipped_signed()
|
||||
|
||||
def test_staged_gitlink_is_not_folded_into_the_signed_head(self) -> None:
|
||||
self._commit("feature")
|
||||
head = git(self.repo, "rev-parse", "HEAD")
|
||||
git(self.repo, "config", "diff.ignoreSubmodules", "all")
|
||||
git(self.repo, "update-index", "--add", "--cacheinfo", f"160000,{head},sub")
|
||||
|
||||
with self.assertRaises(PushRefusedError) as cm:
|
||||
self._push()
|
||||
|
||||
self.assertIn("staged changes", str(cm.exception))
|
||||
self.assertEqual(git(self.repo, "rev-parse", "HEAD"), head)
|
||||
|
||||
def test_unstaged_edit_stays_out_of_the_signed_head(self) -> None:
|
||||
self._commit("feature")
|
||||
Path(self.repo, "feature.txt").write_text("unstaged")
|
||||
|
||||
self._push()
|
||||
|
||||
self._assert_shipped_signed()
|
||||
self.assertEqual(git(self.repo, "status", "--porcelain"), "M feature.txt")
|
||||
|
||||
def test_signing_amend_runs_no_repository_hook(self) -> None:
|
||||
marker = Path(self.root, "hooked")
|
||||
hook = Path(self.repo, ".git", "hooks", "post-commit")
|
||||
hook.write_text(f"#!/bin/sh\ntouch {marker}\n")
|
||||
hook.chmod(0o755)
|
||||
self._commit("feature")
|
||||
marker.unlink()
|
||||
|
||||
self._push()
|
||||
|
||||
self._assert_shipped_signed()
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_signing_amend_runs_no_config_hook(self) -> None:
|
||||
marker = Path(self.root, "hooked")
|
||||
git(self.repo, "config", "hook.probe.command", f"touch {marker}")
|
||||
git(self.repo, "config", "hook.probe.event", "post-commit")
|
||||
self._commit("feature")
|
||||
hooks_fire = marker.exists()
|
||||
marker.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
self._push()
|
||||
except PushRefusedError:
|
||||
pass
|
||||
|
||||
self.assertFalse(marker.exists(), f"config hooks fire on commit: {hooks_fire}")
|
||||
|
||||
def test_mirror_lease_runs_no_textconv(self) -> None:
|
||||
marker = Path(self.root, "textconv")
|
||||
Path(self.repo, ".gitattributes").write_text("* diff=probe\n")
|
||||
git(self.repo, "add", ".gitattributes")
|
||||
git(self.repo, "commit", "-q", "-S", "-m", "attributes")
|
||||
git(self.repo, "push", "-q", self.mirror, "main")
|
||||
git(self.repo, "config", "diff.probe.textconv", f"touch {marker};cat")
|
||||
self._commit("feature")
|
||||
git(self.repo, "diff", "HEAD~1", "HEAD")
|
||||
self.assertTrue(marker.exists())
|
||||
marker.unlink()
|
||||
|
||||
self._push()
|
||||
|
||||
self._assert_shipped_signed()
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_remote_named_like_an_option_is_pushed(self) -> None:
|
||||
git(self.repo, "config", "remote.-r.url", self.canonical)
|
||||
git(self.repo, "config", "remote.-r.fetch", "+refs/heads/*:refs/remotes/-r/*")
|
||||
git(self.repo, "config", "branch.main.pushRemote", "-r")
|
||||
git(self.repo, "fetch", "-q", "--", "-r")
|
||||
self._commit("feature")
|
||||
|
||||
self._push()
|
||||
|
||||
self.assertEqual(self._tip(self.canonical), git(self.repo, "rev-parse", "HEAD"))
|
||||
|
||||
def test_release_refuses_unsafe_config_before_any_change(self) -> None:
|
||||
head = git(self.repo, "rev-parse", "HEAD")
|
||||
git(self.repo, "config", "core.sshCommand", "false")
|
||||
cwd = os.getcwd()
|
||||
os.chdir(self.repo)
|
||||
self.addCleanup(os.chdir, cwd)
|
||||
|
||||
with redirect_stdout(io.StringIO()), self.assertRaises(PushRefusedError):
|
||||
_release_impl(
|
||||
pyproject_path=os.path.join(self.repo, "pyproject.toml"), force=True
|
||||
)
|
||||
|
||||
self.assertEqual(git(self.repo, "rev-parse", "HEAD"), head)
|
||||
self.assertEqual(git(self.repo, "status", "--porcelain"), "")
|
||||
|
||||
def test_release_retry_refuses_unsigned_range(self) -> None:
|
||||
self._commit("release")
|
||||
git(self.repo, "tag", "-a", "v1.0.0", "-m", "v1.0.0")
|
||||
|
||||
@@ -448,6 +448,16 @@ class TestWorktreeUp(WorktreeFixture, unittest.TestCase):
|
||||
self.assertFalse(os.path.exists(parsed))
|
||||
self.assertFalse(os.path.exists(ran))
|
||||
|
||||
def test_checkout_runs_no_config_hook(self) -> None:
|
||||
marker = os.path.join(self.root, "hooked")
|
||||
git(self.repo, "config", "hook.probe.command", f"touch {marker}")
|
||||
git(self.repo, "config", "hook.probe.event", "post-checkout")
|
||||
|
||||
code, _ = self._up("feature/h", None)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertFalse(os.path.exists(marker))
|
||||
|
||||
def test_origin_url_cannot_escape_the_worktrees_dir(self) -> None:
|
||||
git(self.repo, "remote", "set-url", "origin", "https://host/../../escape")
|
||||
|
||||
|
||||
Reference in new issue
Block a user