Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/giton/runner.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Run plugins for a given hook trigger."""
from __future__ import annotations

import os
import shlex
import subprocess
from dataclasses import dataclass
Expand Down Expand Up @@ -102,11 +103,22 @@ def _run_plugin(plugin: PluginRecord, ctx: GitContext) -> PluginResult:
return PluginResult(plugin.name, 0, "", "skipped")

cmd_str = _format_command(plugin.command, ctx)
# Hook-local Git variables point at the caller's repository even when a
# plugin changes directory. Inherit the runtime, but let each child Git
# command discover its repository from cwd (including linked worktrees).
local_variables = subprocess.run(
["git", "rev-parse", "--local-env-vars"],
cwd=ctx.root, check=True, capture_output=True, text=True,
).stdout.splitlines()
plugin_env = os.environ.copy()
for name in local_variables:
plugin_env.pop(name, None)
console.print(f"[bold cyan]› {plugin.name}[/bold cyan] [dim]{cmd_str}[/dim]")
try:
proc = subprocess.run(
shlex.split(cmd_str),
cwd=ctx.root,
env=plugin_env,
check=False,
capture_output=True,
text=True,
Expand Down
46 changes: 46 additions & 0 deletions tests/test_plugin_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
"""
from __future__ import annotations

import json
import os
import shlex
import sys
import stat
import subprocess
from pathlib import Path
Expand Down Expand Up @@ -206,3 +209,46 @@ def test_default_catalog_commands_are_well_formed():
assert formatted # non-empty
# and the binary name (first token) must equal the entry name
assert formatted.split()[0] == entry.record.name


@pytest.mark.parametrize("linked_worktree", [False, True])
def test_hook_plugin_cannot_mutate_parent_git_context(repo, tmp_path, monkeypatch, linked_worktree):
parent = repo
if linked_worktree:
parent = tmp_path / "linked"
_git(["worktree", "add", "-q", "-b", "hook-test", str(parent)], repo)
git_dir = Path(_git(["rev-parse", "--absolute-git-dir"], parent).stdout.strip())
config = Path(_git(["rev-parse", "--path-format=absolute", "--git-path", "config"], parent).stdout.strip())
index = git_dir / "index"
expected_head = _git(["rev-parse", "HEAD"], parent).stdout
expected_config, expected_index = config.read_bytes(), index.read_bytes()
foreign = tmp_path / "foreign"
report = tmp_path / "plugin.json"
script = tmp_path / "child.py"
script.write_text(
"import json, os, pathlib, subprocess, sys\n"
"foreign, report = map(pathlib.Path, sys.argv[1:])\n"
"foreign.mkdir()\n"
"subprocess.run(['git', 'init', '-q', str(foreign)], check=True)\n"
"subprocess.run(['git', '-C', str(foreign), 'config', 'user.name', 'child-only'], check=True)\n"
"subprocess.run(['git', '-C', str(foreign), 'config', 'user.email', 'child@example.test'], check=True)\n"
"(foreign / 'child.txt').write_text('child-only\\n')\n"
"subprocess.run(['git', '-C', str(foreign), 'add', 'child.txt'], check=True)\n"
"subprocess.run(['git', '-C', str(foreign), 'commit', '-q', '-m', 'test: child'], check=True)\n"
"root = subprocess.check_output(['git', '-C', str(foreign), 'rev-parse', '--show-toplevel'], text=True).strip()\n"
"report.write_text(json.dumps({'root': root, 'runtime': os.environ.get('PLUGIN_RUNTIME'), 'git_dir': os.environ.get('GIT_DIR'), 'index': os.environ.get('GIT_INDEX_FILE')}))\n"
)
command = shlex.join([sys.executable, str(script), str(foreign), str(report)])
_register("foreign-git", command, "pre-push")
with monkeypatch.context() as hook_env:
hook_env.setenv("GIT_DIR", str(git_dir))
hook_env.setenv("GIT_INDEX_FILE", str(index))
hook_env.setenv("PLUGIN_RUNTIME", "preserved")
out = run_trigger("pre-push", cwd=parent)
assert out.ok, [(p.stdout, p.stderr) for p in out.plugin_results]
observed = json.loads(report.read_text())
assert observed == {"root": str(foreign), "runtime": "preserved", "git_dir": None, "index": None}
assert (foreign / ".git").is_dir()
assert _git(["rev-parse", "HEAD"], parent).stdout == expected_head
assert config.read_bytes() == expected_config
assert index.read_bytes() == expected_index
Loading
Loading