diff --git a/src/pkgmgr/actions/branch/gc.py b/src/pkgmgr/actions/branch/gc.py index 05a23ba..1d51eff 100644 --- a/src/pkgmgr/actions/branch/gc.py +++ b/src/pkgmgr/actions/branch/gc.py @@ -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}", "--", diff --git a/src/pkgmgr/actions/release/workflow.py b/src/pkgmgr/actions/release/workflow.py index b801177..2c301ea 100644 --- a/src/pkgmgr/actions/release/workflow.py +++ b/src/pkgmgr/actions/release/workflow.py @@ -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) diff --git a/src/pkgmgr/actions/repository/push.py b/src/pkgmgr/actions/repository/push.py index 97e56f1..5ceed26 100644 --- a/src/pkgmgr/actions/repository/push.py +++ b/src/pkgmgr/actions/repository/push.py @@ -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, diff --git a/src/pkgmgr/core/git/commands/delete_remote_branch.py b/src/pkgmgr/core/git/commands/delete_remote_branch.py index 6a1142b..81c2324 100644 --- a/src/pkgmgr/core/git/commands/delete_remote_branch.py +++ b/src/pkgmgr/core/git/commands/delete_remote_branch.py @@ -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}.", diff --git a/src/pkgmgr/core/git/commands/push.py b/src/pkgmgr/core/git/commands/push.py index 50ff326..300edf6 100644 --- a/src/pkgmgr/core/git/commands/push.py +++ b/src/pkgmgr/core/git/commands/push.py @@ -20,11 +20,9 @@ def push( Push a ref to a remote, optionally forced. Equivalent to: - git push [--force] + git push [--force] -- """ - args = ["push", remote, ref] - if force: - args.append("--force") + args = ["push", *(["--force"] if force else []), "--", remote, ref] try: run(args, cwd=cwd, preview=preview) diff --git a/src/pkgmgr/core/git/commands/push_upstream.py b/src/pkgmgr/core/git/commands/push_upstream.py index 45719bc..2c7ef18 100644 --- a/src/pkgmgr/core/git/commands/push_upstream.py +++ b/src/pkgmgr/core/git/commands/push_upstream.py @@ -19,10 +19,10 @@ def push_upstream( Push a branch and set upstream tracking. Equivalent to: - git push -u + git push -u -- """ 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.", diff --git a/src/pkgmgr/core/git/queries/__init__.py b/src/pkgmgr/core/git/queries/__init__.py index 4fccb42..17ffbb0 100644 --- a/src/pkgmgr/core/git/queries/__init__.py +++ b/src/pkgmgr/core/git/queries/__init__.py @@ -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", diff --git a/src/pkgmgr/core/git/queries/local_config.py b/src/pkgmgr/core/git/queries/local_config.py index 120bc03..42976bb 100644 --- a/src/pkgmgr/core/git/queries/local_config.py +++ b/src/pkgmgr/core/git/queries/local_config.py @@ -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") diff --git a/src/pkgmgr/core/git/run.py b/src/pkgmgr/core/git/run.py index 1a26280..06ee94d 100644 --- a/src/pkgmgr/core/git/run.py +++ b/src/pkgmgr/core/git/run.py @@ -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..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", diff --git a/tests/integration/test_branch_gc.py b/tests/integration/test_branch_gc.py index 9d1e5dc..1748d88 100644 --- a/tests/integration/test_branch_gc.py +++ b/tests/integration/test_branch_gc.py @@ -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 ( diff --git a/tests/integration/test_git_push_commands.py b/tests/integration/test_git_push_commands.py new file mode 100644 index 0000000..810c090 --- /dev/null +++ b/tests/integration/test_git_push_commands.py @@ -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() diff --git a/tests/integration/test_push_mirror_leases.py b/tests/integration/test_push_mirror_leases.py index 206079e..6f12e42 100644 --- a/tests/integration/test_push_mirror_leases.py +++ b/tests/integration/test_push_mirror_leases.py @@ -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") diff --git a/tests/integration/test_worktree.py b/tests/integration/test_worktree.py index 67ed563..7fa80f9 100644 --- a/tests/integration/test_worktree.py +++ b/tests/integration/test_worktree.py @@ -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")