Skip to content

Commit fb8fb20

Browse files
committed
Implement server-side git hooks (pre-receive, update, post-receive)
Fixes #212
1 parent 7a9e41c commit fb8fb20

File tree

5 files changed

+322
-0
lines changed

5 files changed

+322
-0
lines changed

NEWS

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55

66
* Add basic support for subtrees. (Jelmer Vernooij)
77

8+
* Implement server-side Git hooks (pre-receive, update, post-receive) in
9+
``ReceivePackHandler``. Pre-receive hooks can abort entire pushes, update
10+
hooks can decline individual ref updates, and post-receive hooks run after
11+
successful pushes. Hook output is sent to clients via sideband channels.
12+
(Jelmer Vernooij, #212)
13+
814
* Report progress during pack file downloads. Dulwich now displays real-time
915
transfer progress (bytes received, transfer speed) when cloning or fetching
1016
repositories, matching Git's behavior. (Jelmer Vernooij, #1121)

dulwich/hooks.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
"PostCommitShellHook",
2828
"PostReceiveShellHook",
2929
"PreCommitShellHook",
30+
"PreReceiveShellHook",
3031
"ShellHook",
32+
"UpdateShellHook",
3133
]
3234

3335
import os
@@ -244,3 +246,121 @@ def execute(
244246
return out_data
245247
except OSError as err:
246248
raise HookError(repr(err)) from err
249+
250+
251+
class PreReceiveShellHook(ShellHook):
252+
"""pre-receive shell hook."""
253+
254+
def __init__(self, controldir: str) -> None:
255+
"""Initialize pre-receive hook.
256+
257+
Args:
258+
controldir: Path to the git control directory (.git)
259+
"""
260+
self.controldir = controldir
261+
filepath = os.path.join(controldir, "hooks", "pre-receive")
262+
ShellHook.__init__(self, "pre-receive", path=filepath, numparam=0)
263+
264+
def execute(
265+
self, client_refs: Sequence[tuple[bytes, bytes, bytes]]
266+
) -> tuple[bytes, bytes]:
267+
"""Execute the pre-receive hook.
268+
269+
Args:
270+
client_refs: List of tuples containing (old_sha, new_sha, ref_name)
271+
for each reference to be updated
272+
273+
Returns:
274+
Tuple of (stdout, stderr) from hook execution
275+
276+
Raises:
277+
HookError: If hook execution fails (exits with non-zero status)
278+
"""
279+
# do nothing if the script doesn't exist
280+
if not os.path.exists(self.filepath):
281+
return (b"", b"")
282+
283+
try:
284+
env = os.environ.copy()
285+
env["GIT_DIR"] = self.controldir
286+
287+
p = subprocess.Popen(
288+
self.filepath,
289+
stdin=subprocess.PIPE,
290+
stdout=subprocess.PIPE,
291+
stderr=subprocess.PIPE,
292+
env=env,
293+
)
294+
295+
# client_refs is a list of (oldsha, newsha, ref)
296+
in_data = b"\n".join([b" ".join(ref) for ref in client_refs])
297+
298+
out_data, err_data = p.communicate(in_data)
299+
300+
if p.returncode != 0:
301+
raise HookError(
302+
f"pre-receive hook exited with status {p.returncode}: {err_data.decode('utf-8', 'backslashreplace')}"
303+
)
304+
return (out_data, err_data)
305+
except OSError as err:
306+
raise HookError(repr(err)) from err
307+
308+
309+
class UpdateShellHook(ShellHook):
310+
"""update shell hook."""
311+
312+
def __init__(self, controldir: str) -> None:
313+
"""Initialize update hook.
314+
315+
Args:
316+
controldir: Path to the git control directory (.git)
317+
"""
318+
self.controldir = controldir
319+
filepath = os.path.join(controldir, "hooks", "update")
320+
ShellHook.__init__(self, "update", path=filepath, numparam=3)
321+
322+
def execute(
323+
self, ref_name: bytes, old_sha: bytes, new_sha: bytes
324+
) -> tuple[bytes, bytes]:
325+
"""Execute the update hook for a single ref.
326+
327+
Args:
328+
ref_name: Name of the reference being updated
329+
old_sha: Old SHA of the reference
330+
new_sha: New SHA of the reference
331+
332+
Returns:
333+
Tuple of (stdout, stderr) from hook execution
334+
335+
Raises:
336+
HookError: If hook execution fails (exits with non-zero status)
337+
"""
338+
# do nothing if the script doesn't exist
339+
if not os.path.exists(self.filepath):
340+
return (b"", b"")
341+
342+
try:
343+
env = os.environ.copy()
344+
env["GIT_DIR"] = self.controldir
345+
346+
p = subprocess.Popen(
347+
[
348+
self.filepath,
349+
ref_name.decode("utf-8", "backslashreplace"),
350+
old_sha.decode("utf-8", "backslashreplace"),
351+
new_sha.decode("utf-8", "backslashreplace"),
352+
],
353+
stdout=subprocess.PIPE,
354+
stderr=subprocess.PIPE,
355+
env=env,
356+
)
357+
358+
out_data, err_data = p.communicate()
359+
360+
if p.returncode != 0:
361+
raise HookError(
362+
f"update hook exited with status {p.returncode}: {err_data.decode('utf-8', 'backslashreplace')}"
363+
)
364+
return (out_data, err_data)
365+
except OSError as err:
366+
raise HookError(repr(err)) from err

dulwich/repo.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@
107107
PostCommitShellHook,
108108
PostReceiveShellHook,
109109
PreCommitShellHook,
110+
PreReceiveShellHook,
111+
UpdateShellHook,
110112
)
111113
from .object_store import (
112114
DiskObjectStore,
@@ -1530,6 +1532,8 @@ def __init__(
15301532
self.hooks["pre-commit"] = PreCommitShellHook(self.path, self.controldir())
15311533
self.hooks["commit-msg"] = CommitMsgShellHook(self.controldir())
15321534
self.hooks["post-commit"] = PostCommitShellHook(self.controldir())
1535+
self.hooks["pre-receive"] = PreReceiveShellHook(self.controldir())
1536+
self.hooks["update"] = UpdateShellHook(self.controldir())
15331537
self.hooks["post-receive"] = PostReceiveShellHook(self.controldir())
15341538

15351539
# Initialize filter context as None, will be created lazily

dulwich/server.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1461,6 +1461,15 @@ def _apply_pack(
14611461

14621462
for oldsha, sha, ref in refs:
14631463
ref_status = b"ok"
1464+
1465+
# Run update hook for this ref
1466+
hook_error = self._on_update(ref, oldsha, sha)
1467+
if hook_error:
1468+
# Update hook declined this ref
1469+
ref_status = hook_error
1470+
yield (ref, ref_status)
1471+
continue
1472+
14641473
try:
14651474
if sha == zero_sha:
14661475
if CAPABILITY_DELETE_REFS not in self.capabilities():
@@ -1512,6 +1521,68 @@ def flush() -> None:
15121521
write(None) # type: ignore
15131522
flush()
15141523

1524+
def _on_pre_receive(
1525+
self, client_refs: list[tuple[ObjectID, ObjectID, Ref]]
1526+
) -> None:
1527+
"""Run pre-receive hook.
1528+
1529+
Args:
1530+
client_refs: List of (old_sha, new_sha, ref_name) tuples
1531+
1532+
Raises:
1533+
HookError: If hook fails, preventing the push
1534+
"""
1535+
hook = self.repo.hooks.get("pre-receive", None) # type: ignore[attr-defined]
1536+
if not hook:
1537+
return
1538+
try:
1539+
stdout, stderr = hook.execute(client_refs)
1540+
# Only send output via sideband if capabilities are set
1541+
if self._client_capabilities is not None:
1542+
if stdout and self.has_capability(CAPABILITY_SIDE_BAND_64K):
1543+
self.proto.write_sideband(SIDE_BAND_CHANNEL_PROGRESS, stdout)
1544+
if stderr and self.has_capability(CAPABILITY_SIDE_BAND_64K):
1545+
self.proto.write_sideband(SIDE_BAND_CHANNEL_PROGRESS, stderr)
1546+
except HookError as err:
1547+
# Send error to client via sideband if available
1548+
if self._client_capabilities is not None and self.has_capability(
1549+
CAPABILITY_SIDE_BAND_64K
1550+
):
1551+
self.proto.write_sideband(
1552+
SIDE_BAND_CHANNEL_FATAL, str(err).encode("utf-8")
1553+
)
1554+
# Re-raise to abort the push
1555+
raise
1556+
1557+
def _on_update(
1558+
self, ref_name: Ref, old_sha: ObjectID, new_sha: ObjectID
1559+
) -> bytes | None:
1560+
"""Run update hook for a single ref.
1561+
1562+
Args:
1563+
ref_name: Name of the reference
1564+
old_sha: Old SHA of the reference
1565+
new_sha: New SHA of the reference
1566+
1567+
Returns:
1568+
Error message if hook fails, None otherwise
1569+
"""
1570+
hook = self.repo.hooks.get("update", None) # type: ignore[attr-defined]
1571+
if not hook:
1572+
return None
1573+
try:
1574+
stdout, stderr = hook.execute(ref_name, old_sha, new_sha)
1575+
# Only send output via sideband if capabilities are set
1576+
if self._client_capabilities is not None:
1577+
if stdout and self.has_capability(CAPABILITY_SIDE_BAND_64K):
1578+
self.proto.write_sideband(SIDE_BAND_CHANNEL_PROGRESS, stdout)
1579+
if stderr and self.has_capability(CAPABILITY_SIDE_BAND_64K):
1580+
self.proto.write_sideband(SIDE_BAND_CHANNEL_PROGRESS, stderr)
1581+
return None
1582+
except HookError as err:
1583+
# Return error message to mark this ref as failed
1584+
return str(err).encode("utf-8")
1585+
15151586
def _on_post_receive(self, client_refs: dict[bytes, tuple[bytes, bytes]]) -> None:
15161587
"""Run post-receive hook.
15171588
@@ -1569,6 +1640,22 @@ def handle(self) -> None:
15691640
client_refs.append((ObjectID(oldsha), ObjectID(newsha), Ref(ref_name)))
15701641
ref_line = self.proto.read_pkt_line()
15711642

1643+
# Run pre-receive hook before processing the pack
1644+
# If it fails, we abort the entire push
1645+
try:
1646+
self._on_pre_receive(client_refs)
1647+
except HookError:
1648+
# Hook failed, report error to client and abort
1649+
# We still need to consume the pack data to avoid protocol errors
1650+
if self.has_capability(CAPABILITY_REPORT_STATUS):
1651+
# Send unpack error status
1652+
status = [(b"unpack", b"pre-receive hook declined")]
1653+
# Add 'ng' (not good) status for all refs
1654+
for _, _, ref_name in client_refs:
1655+
status.append((ref_name, b"pre-receive hook declined"))
1656+
self._report_status(status)
1657+
return
1658+
15721659
# backend can now deal with this refs and read a pack using self.read
15731660
status = list(self._apply_pack(client_refs))
15741661

tests/test_server.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from dulwich.errors import (
3131
GitProtocolError,
3232
HangupException,
33+
HookError,
3334
NotGitRepository,
3435
UnexpectedCommandError,
3536
)
@@ -387,6 +388,110 @@ def test_apply_pack_del_ref(self) -> None:
387388
self.assertEqual(status[1][0], b"refs/heads/fake-branch")
388389
self.assertEqual(status[1][1], b"ok")
389390

391+
def test_pre_receive_hook_success(self) -> None:
392+
"""Test that pre-receive hook is called and can succeed."""
393+
394+
def mock_hook_execute(client_refs):
395+
# Verify the hook receives the expected refs
396+
self.assertEqual(len(client_refs), 1)
397+
self.assertEqual(client_refs[0][0], ONE)
398+
self.assertEqual(client_refs[0][1], TWO)
399+
self.assertEqual(client_refs[0][2], b"refs/heads/master")
400+
return (b"", b"")
401+
402+
# Create a mock hook
403+
from unittest.mock import Mock
404+
405+
mock_hook = Mock()
406+
mock_hook.execute = mock_hook_execute
407+
self._repo.hooks["pre-receive"] = mock_hook
408+
409+
# Test that the hook is called
410+
client_refs = [(ONE, TWO, b"refs/heads/master")]
411+
self._handler._on_pre_receive(client_refs)
412+
413+
def test_pre_receive_hook_failure(self) -> None:
414+
"""Test that pre-receive hook can abort a push."""
415+
416+
def mock_hook_execute(client_refs):
417+
raise HookError("pre-receive hook declined")
418+
419+
# Create a mock hook
420+
from unittest.mock import Mock
421+
422+
mock_hook = Mock()
423+
mock_hook.execute = mock_hook_execute
424+
self._repo.hooks["pre-receive"] = mock_hook
425+
426+
# Test that the hook failure raises HookError
427+
client_refs = [(ONE, TWO, b"refs/heads/master")]
428+
self.assertRaises(HookError, self._handler._on_pre_receive, client_refs)
429+
430+
def test_update_hook_success(self) -> None:
431+
"""Test that update hook is called and can succeed."""
432+
433+
def mock_hook_execute(ref_name, old_sha, new_sha):
434+
# Verify the hook receives the expected arguments
435+
self.assertEqual(ref_name, b"refs/heads/master")
436+
self.assertEqual(old_sha, ONE)
437+
self.assertEqual(new_sha, TWO)
438+
return (b"", b"")
439+
440+
# Create a mock hook
441+
from unittest.mock import Mock
442+
443+
mock_hook = Mock()
444+
mock_hook.execute = mock_hook_execute
445+
self._repo.hooks["update"] = mock_hook
446+
447+
# Test that the hook is called
448+
result = self._handler._on_update(b"refs/heads/master", ONE, TWO)
449+
self.assertIsNone(result)
450+
451+
def test_update_hook_failure(self) -> None:
452+
"""Test that update hook can decline a ref update."""
453+
454+
def mock_hook_execute(ref_name, old_sha, new_sha):
455+
raise HookError("update hook declined")
456+
457+
# Create a mock hook
458+
from unittest.mock import Mock
459+
460+
mock_hook = Mock()
461+
mock_hook.execute = mock_hook_execute
462+
self._repo.hooks["update"] = mock_hook
463+
464+
# Test that the hook failure returns an error message
465+
result = self._handler._on_update(b"refs/heads/master", ONE, TWO)
466+
self.assertIsNotNone(result)
467+
self.assertIn(b"update hook declined", result)
468+
469+
def test_update_hook_declines_ref(self) -> None:
470+
"""Test that update hook can decline a ref during pack application."""
471+
refs = {b"refs/heads/master": ONE}
472+
self._repo.refs._update(refs)
473+
474+
# Create a hook that declines the ref
475+
def mock_hook_execute(ref_name, old_sha, new_sha):
476+
raise HookError("update hook declined this ref")
477+
478+
# Create a mock hook
479+
from unittest.mock import Mock
480+
481+
mock_hook = Mock()
482+
mock_hook.execute = mock_hook_execute
483+
self._repo.hooks["update"] = mock_hook
484+
485+
# Test the ref update part without reading pack data
486+
self._handler.set_client_capabilities([b"delete-refs"])
487+
488+
# The update hook should decline the ref
489+
ref_status = self._handler._on_update(b"refs/heads/master", ONE, ZERO_SHA)
490+
491+
# Verify hook declined the ref
492+
self.assertIsNotNone(ref_status)
493+
self.assertIn(b"update hook declined", ref_status)
494+
390495

391496
class ProtocolGraphWalkerEmptyTestCase(TestCase):
392497
def setUp(self) -> None:

0 commit comments

Comments
 (0)