diff --git a/pygittools/hook_samples/README.md b/pygittools/hook_samples/README.md
index 403e138..001a538 100644
--- a/pygittools/hook_samples/README.md
+++ b/pygittools/hook_samples/README.md
@@ -29,3 +29,16 @@ Environment variables:
 - `PYGITWEB_PROTECTED_REF_PATTERN` — refs to protect (default `^refs/heads/(main|master)$`)
 - `PYGITWEB_COMMIT_MSG_PATTERN` — message regex (default `^(\S+): (.+)`)
 - `PYGITWEB_REJECT_MERGE_COMMITS` — set to `1`/`true`/`yes` to reject merge commits
+
+## post-commit.push-remotes
+
+After each local commit, force-with-lease push the current branch to every configured
+remote when the branch name matches a pattern. Uses the git CLI (`git push
+--force-with-lease`), so SSH remotes honor `~/.ssh/config` like a normal push. Push
+failures are logged on stderr but do not affect the commit (post-commit hooks cannot undo
+a commit).
+
+Environment variables:
+
+- `PYGITWEB_PUSH_BRANCH_PATTERN` — branch name regex (default `^wip/`)
+- `GIT` — git executable (default: `git` on `PATH`)
diff --git a/pygittools/hook_samples/post-commit.push-remotes b/pygittools/hook_samples/post-commit.push-remotes
new file mode 100644
index 0000000..260aab8
--- /dev/null
+++ b/pygittools/hook_samples/post-commit.push-remotes
@@ -0,0 +1,23 @@
+#!/usr/bin/env -S uv run python
+
+from __future__ import annotations
+
+__VERSION__ = "1"
+
+import os
+
+import pygit2
+
+from pygittools.hooks_push import PostCommitPushRemotes
+
+BRANCH_PATTERN = os.environ.get("PYGITWEB_PUSH_BRANCH_PATTERN", r"^wip/")
+
+
+def main() -> int:
+	repo = pygit2.Repository(".")
+	result = PostCommitPushRemotes(repo, BRANCH_PATTERN).run()
+	return int(result.value)
+
+
+if __name__ == "__main__":
+	raise SystemExit(main())
diff --git a/pygittools/hooks_push.py b/pygittools/hooks_push.py
new file mode 100644
index 0000000..23e91c7
--- /dev/null
+++ b/pygittools/hooks_push.py
@@ -0,0 +1,77 @@
+"""
+Post-commit hook helpers: force-with-lease push to remotes via the git CLI.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import shutil
+import subprocess
+from sys import stderr
+
+import pygit2
+
+from pygittools.hooks import HookResult, PostCommit
+
+
+def git_executable() -> str:
+	"""Return the git binary to invoke (``GIT`` env, then ``PATH``, else ``git``)."""
+	return os.environ.get("GIT") or shutil.which("git") or "git"
+
+
+def force_with_lease_push(
+	repo: pygit2.Repository,
+	remote_name: str,
+	branch: str,
+	*,
+	git: str | None = None,
+) -> str | None:
+	"""Force-with-lease push ``branch`` to ``remote_name``. Returns an error string or None."""
+	workdir: str | None = repo.workdir
+	if workdir is None:
+		return "bare repository has no working tree"
+	if branch not in repo.branches.local:
+		return f"branch {branch!r} does not exist locally"
+	if remote_name not in repo.remotes.names():
+		return f"remote {remote_name!r} not configured"
+	binary: str = git or git_executable()
+	proc: subprocess.CompletedProcess[str] = subprocess.run(
+		[binary, "-C", workdir, "push", "--force-with-lease", remote_name, branch],
+		capture_output=True,
+		text=True,
+	)
+	if proc.returncode != 0:
+		return (proc.stderr or proc.stdout or "push failed").strip()
+	return None
+
+
+class PostCommitPushRemotes(PostCommit):
+	"""After each commit, force-with-lease push the current branch to every remote if it matches a pattern."""
+
+	def __init__(self, repo: pygit2.Repository, branch_pattern: str) -> None:
+		super().__init__(repo)
+		self._branch_exp: re.Pattern[str] = re.compile(branch_pattern)
+
+	def run(self) -> HookResult:
+		if self.repo.head_is_detached:
+			return HookResult.SUCCESS
+		branch: str = self.repo.head.shorthand
+		if not self._branch_exp.match(branch):
+			return HookResult.SUCCESS
+		if self.repo.workdir is None:
+			stderr.write("PostCommitPushRemotes: bare repository; skipping push.\n")
+			return HookResult.SUCCESS
+		remote_names: list[str] = list(self.repo.remotes.names())
+		if not remote_names:
+			return HookResult.SUCCESS
+		failures: list[str] = []
+		for remote_name in remote_names:
+			error: str | None = force_with_lease_push(self.repo, remote_name, branch)
+			if error is not None:
+				failures.append(f"{remote_name}: {error}")
+		if failures:
+			stderr.write(f"PostCommitPushRemotes ({branch}): push failed:\n")
+			for line in failures:
+				stderr.write(f"  {line}\n")
+		return HookResult.SUCCESS
diff --git a/pygittools/hooks_push_test.py b/pygittools/hooks_push_test.py
new file mode 100644
index 0000000..2bba199
--- /dev/null
+++ b/pygittools/hooks_push_test.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pygit2
+from pygit2 import Repository, Signature, init_repository
+
+from pygittools.hooks import HookResult
+from pygittools.hooks_push import PostCommitPushRemotes, force_with_lease_push
+
+SIG = Signature("test", "test@example.com")
+
+
+def _init_client_with_remote(tmp_path: Path) -> tuple[Repository, Path, Path, Path]:
+	bare_path: Path = tmp_path / "remote.git"
+	bare_path.mkdir()
+	init_repository(str(bare_path), bare=True)
+	backup_path: Path = tmp_path / "backup.git"
+	backup_path.mkdir()
+	init_repository(str(backup_path), bare=True)
+	client_path: Path = tmp_path / "client"
+	client: Repository = init_repository(str(client_path), bare=False)
+	client.remotes.create("origin", str(bare_path))
+	client.remotes.create("backup", str(backup_path))
+	(client_path / "f").write_text("a\n", encoding="utf-8")
+	index = client.index
+	index.add("f")
+	index.write()
+	tree = index.write_tree()
+	client.create_commit("HEAD", SIG, SIG, "init", tree, [])
+	client.references.create("refs/heads/wip/topic", client.head.target, force=True)
+	client.checkout(f"refs/heads/wip/topic")
+	return client, client_path, bare_path, backup_path
+
+
+def test_skips_non_matching_branch(tmp_path: Path) -> None:
+	client, client_path, _bare, _backup = _init_client_with_remote(tmp_path)
+	client.references.create("refs/heads/main", client.head.target, force=True)
+	client.checkout("refs/heads/main")
+	repo = pygit2.Repository(str(client_path))
+	assert PostCommitPushRemotes(repo, r"^wip/").run() == HookResult.SUCCESS
+	assert repo.references.get("refs/remotes/origin/main") is None
+
+
+def test_pushes_matching_branch_to_all_remotes(tmp_path: Path) -> None:
+	client, client_path, bare_path, backup_path = _init_client_with_remote(tmp_path)
+	repo = pygit2.Repository(str(client_path))
+	assert PostCommitPushRemotes(repo, r"^wip/").run() == HookResult.SUCCESS
+	local_target = client.references["refs/heads/wip/topic"].target
+	assert pygit2.Repository(str(bare_path)).references["refs/heads/wip/topic"].target == local_target
+	assert pygit2.Repository(str(backup_path)).references["refs/heads/wip/topic"].target == local_target
+
+
+def test_force_with_lease_rejects_stale_tracking_ref(tmp_path: Path) -> None:
+	client, client_path, bare_path, _backup = _init_client_with_remote(tmp_path)
+	repo = pygit2.Repository(str(client_path))
+	assert force_with_lease_push(repo, "origin", "wip/topic") is None
+	(client_path / "f").write_text("b\n", encoding="utf-8")
+	index = repo.index
+	index.add("f")
+	index.write()
+	tree = index.write_tree()
+	parent = repo.head.target
+	repo.create_commit("refs/heads/wip/topic", SIG, SIG, "second", tree, [parent])
+	repo.checkout("refs/heads/wip/topic")
+	bare = pygit2.Repository(str(bare_path))
+	tb = bare.TreeBuilder()
+	tree = tb.write()
+	current = bare.references["refs/heads/wip/topic"].target
+	other = bare.create_commit("refs/heads/wip/topic", SIG, SIG, "other", tree, [current])
+	assert other != repo.references["refs/heads/wip/topic"].target
+	error = force_with_lease_push(repo, "origin", "wip/topic")
+	assert error is not None
+	assert "rejected" in error.lower() or "failed" in error.lower()
+
+
+def test_skips_detached_head(tmp_path: Path) -> None:
+	client, client_path, _bare, _backup = _init_client_with_remote(tmp_path)
+	client.set_head(client.head.target)
+	repo = pygit2.Repository(str(client_path))
+	assert repo.head_is_detached is True
+	assert PostCommitPushRemotes(repo, r".*").run() == HookResult.SUCCESS
diff --git a/pygitweb/hooks_install.py b/pygitweb/hooks_install.py
index 7593640..e4ca029 100644
--- a/pygitweb/hooks_install.py
+++ b/pygitweb/hooks_install.py
@@ -32,6 +32,7 @@ _SAMPLE_LABELS: dict[str, str] = {
 	"pre-commit.ruff": "Ruff Pre-commit",
 	"commit-msg.pattern": "Commit Message Pattern",
 	"pre-receive.protected-pattern": "Protected Branch Commit Messages",
+	"post-commit.push-remotes": "Post-commit Push Remotes",
 }
 
 
