claude-dev-env 2.24.0 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/git-hooks/AGENTS.md +2 -1
- package/hooks/git-hooks/gate_utils.py +17 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +3 -0
- package/hooks/git-hooks/post_commit.py +31 -1
- package/hooks/git-hooks/pre_push.py +7 -10
- package/hooks/git-hooks/pull_request_handoff.py +32 -0
- package/hooks/git-hooks/test_gate_utils.py +38 -0
- package/hooks/git-hooks/test_post_commit.py +87 -0
- package/package.json +1 -1
|
@@ -9,7 +9,8 @@ Native git hooks that run outside the Claude Code lifecycle — invoked directly
|
|
|
9
9
|
| `pre_commit.py` | `pre-commit` | Runs immediate CODE_RULES and terminology validation over staged changes; exits 1 when any staged file has a blocking violation. CI runs package tests for package Python changes. |
|
|
10
10
|
| `pre_push.py` | `pre-push` | Blocks a push that would land a non-`main` local branch onto remote `main` (or `master`), then runs the CODE_RULES gate. An existing branch's gate base is the merge base with the remote default branch; the gate process still diffs that base against checkout HEAD, so the surface matches the pushed tip only when HEAD is that tip. |
|
|
11
11
|
| `pre_push_base_reference.py` | — | Resolves a usable gate base for `pre_push.py`: reads the pushed remote name from git's arguments, then turns a symbolic default-branch head into a reference that git can resolve |
|
|
12
|
-
| `post_commit.py` | `post-commit` | Runs
|
|
12
|
+
| `post_commit.py` | `post-commit` | Runs bookkeeping, then best-effort reminds the agent about its PR |
|
|
13
|
+
| `pull_request_handoff.py` | — | Prints the full PR title and description handoff prompt |
|
|
13
14
|
| `gate_utils.py` | — | Shared helpers: resolves the gate script path, checks that the path is a safe regular file |
|
|
14
15
|
| `test_config.py` | — | Test configuration helpers |
|
|
15
16
|
| `test_gate_utils.py` | — | Tests for `gate_utils.py` |
|
|
@@ -6,14 +6,29 @@ import os
|
|
|
6
6
|
import stat
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
import git_hooks_constants
|
|
9
10
|
from git_hooks_constants import (
|
|
10
|
-
ALL_GATE_SCRIPT_RELATIVE_PATH,
|
|
11
11
|
CLAUDE_HOME_DEFAULT_SUBDIRECTORY,
|
|
12
12
|
CLAUDE_HOME_ENV_VAR,
|
|
13
13
|
GATE_PATH_OVERRIDE_ENV_VAR,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def load_gate_script_relative_path(
|
|
18
|
+
constants_module: object | None = None,
|
|
19
|
+
) -> tuple[str, ...]:
|
|
20
|
+
"""Return the gate script path segments from git_hooks_constants.
|
|
21
|
+
|
|
22
|
+
Prefer ALL_GATE_SCRIPT_RELATIVE_PATH. Fall back to GATE_SCRIPT_RELATIVE_PATH
|
|
23
|
+
when an older installed constants module has not been updated yet.
|
|
24
|
+
"""
|
|
25
|
+
module = git_hooks_constants if constants_module is None else constants_module
|
|
26
|
+
try:
|
|
27
|
+
return module.ALL_GATE_SCRIPT_RELATIVE_PATH
|
|
28
|
+
except AttributeError:
|
|
29
|
+
return module.GATE_SCRIPT_RELATIVE_PATH
|
|
30
|
+
|
|
31
|
+
|
|
17
32
|
def resolve_gate_script_path() -> tuple[Path, Path | None]:
|
|
18
33
|
"""Return (gate_path, exact_allowed_override_or_none).
|
|
19
34
|
|
|
@@ -34,7 +49,7 @@ def resolve_gate_script_path() -> tuple[Path, Path | None]:
|
|
|
34
49
|
claude_home_directory = Path(claude_home_override).resolve()
|
|
35
50
|
else:
|
|
36
51
|
claude_home_directory = Path.home() / CLAUDE_HOME_DEFAULT_SUBDIRECTORY
|
|
37
|
-
gate_path = claude_home_directory.joinpath(*
|
|
52
|
+
gate_path = claude_home_directory.joinpath(*load_gate_script_relative_path())
|
|
38
53
|
return gate_path, None
|
|
39
54
|
|
|
40
55
|
|
|
@@ -97,6 +97,9 @@ GIT_REV_PARSE_VERIFY_FLAG: str = "--verify"
|
|
|
97
97
|
GIT_QUIET_FLAG: str = "--quiet"
|
|
98
98
|
GIT_SYMBOLIC_REFERENCE_SUBCOMMAND: str = "symbolic-ref"
|
|
99
99
|
GIT_COMMAND_TIMEOUT_SECONDS: int = 30
|
|
100
|
+
GH_EXECUTABLE_NAME: str = "gh"
|
|
101
|
+
GH_PR_VIEW_TIMEOUT_SECONDS: int = 5
|
|
102
|
+
GH_PR_VIEW_ARGUMENTS: tuple[str, ...] = ("pr", "view", "--json", "url", "--jq", ".url")
|
|
100
103
|
GIT_FOR_EACH_REF_SUBCOMMAND: str = "for-each-ref"
|
|
101
104
|
GIT_REFERENCE_SHORT_NAME_FORMAT_ARGUMENT: str = "--format=%(refname:short)"
|
|
102
105
|
COMMIT_OBJECT_NAME_SUFFIX: str = "^{commit}"
|
|
@@ -17,7 +17,14 @@ import sys
|
|
|
17
17
|
from enum import StrEnum
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
from git_hooks_constants import
|
|
20
|
+
from git_hooks_constants import (
|
|
21
|
+
GH_EXECUTABLE_NAME,
|
|
22
|
+
GH_PR_VIEW_ARGUMENTS,
|
|
23
|
+
GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
24
|
+
GIT_COMMAND_SUCCESS_EXIT_CODE,
|
|
25
|
+
GIT_EXECUTABLE_NAME,
|
|
26
|
+
)
|
|
27
|
+
from pull_request_handoff import build_pull_request_reminder
|
|
21
28
|
|
|
22
29
|
|
|
23
30
|
class ParentPointerStatus(StrEnum):
|
|
@@ -163,6 +170,24 @@ def update_parent_pointer(
|
|
|
163
170
|
return ParentPointerStatus.UPDATED, ""
|
|
164
171
|
|
|
165
172
|
|
|
173
|
+
def print_pull_request_reminder(repo_dir: Path) -> None:
|
|
174
|
+
"""Print a reminder when the committed repository has an open pull request."""
|
|
175
|
+
try:
|
|
176
|
+
result = subprocess.run(
|
|
177
|
+
[GH_EXECUTABLE_NAME, *GH_PR_VIEW_ARGUMENTS],
|
|
178
|
+
cwd=repo_dir,
|
|
179
|
+
check=False,
|
|
180
|
+
capture_output=True,
|
|
181
|
+
text=True,
|
|
182
|
+
timeout=GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
183
|
+
)
|
|
184
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
185
|
+
return
|
|
186
|
+
pull_request_url = result.stdout.strip()
|
|
187
|
+
if result.returncode == 0 and pull_request_url:
|
|
188
|
+
print(build_pull_request_reminder(pull_request_url))
|
|
189
|
+
|
|
190
|
+
|
|
166
191
|
def main() -> int:
|
|
167
192
|
"""Update a parent repository after a submodule commit."""
|
|
168
193
|
repo_path_text = run_git_from_current_directory("rev-parse", "--show-toplevel")
|
|
@@ -172,12 +197,14 @@ def main() -> int:
|
|
|
172
197
|
repo_dir = Path(repo_path_text).resolve()
|
|
173
198
|
parent_repo = find_parent_repo(repo_dir)
|
|
174
199
|
if parent_repo is None:
|
|
200
|
+
print_pull_request_reminder(repo_dir)
|
|
175
201
|
return 0
|
|
176
202
|
|
|
177
203
|
commit_msg = run_git("log", "-1", "--pretty=%s", cwd=repo_dir)
|
|
178
204
|
commit_hash = run_git("rev-parse", "HEAD", cwd=repo_dir)
|
|
179
205
|
short_commit_hash = run_git("rev-parse", "--short", "HEAD", cwd=repo_dir)
|
|
180
206
|
if not commit_hash or not short_commit_hash:
|
|
207
|
+
print_pull_request_reminder(repo_dir)
|
|
181
208
|
return 0
|
|
182
209
|
|
|
183
210
|
print()
|
|
@@ -195,14 +222,17 @@ def main() -> int:
|
|
|
195
222
|
print("Parent update failed.")
|
|
196
223
|
if parent_pointer_diagnostic:
|
|
197
224
|
print(f"Git diagnostic: {parent_pointer_diagnostic}")
|
|
225
|
+
print_pull_request_reminder(repo_dir)
|
|
198
226
|
return 0
|
|
199
227
|
if parent_pointer_status is ParentPointerStatus.UNCHANGED:
|
|
200
228
|
print("Parent already up to date.")
|
|
229
|
+
print_pull_request_reminder(repo_dir)
|
|
201
230
|
return 0
|
|
202
231
|
|
|
203
232
|
print("Parent updated successfully.")
|
|
204
233
|
print("================================")
|
|
205
234
|
print()
|
|
235
|
+
print_pull_request_reminder(repo_dir)
|
|
206
236
|
|
|
207
237
|
return 0
|
|
208
238
|
|
|
@@ -87,14 +87,13 @@ from pre_push_base_reference import (
|
|
|
87
87
|
resolve_remote_name_from_arguments,
|
|
88
88
|
resolve_usable_base_reference,
|
|
89
89
|
)
|
|
90
|
+
from pull_request_handoff import build_pull_request_reminder, get_pull_request_url
|
|
90
91
|
|
|
91
92
|
|
|
92
93
|
def _report_unavailable_git(launch_error: Exception) -> int:
|
|
93
94
|
"""Report a git that would not run, and hand back the exit code to use."""
|
|
94
95
|
git_command_unavailable_message = GIT_COMMAND_UNAVAILABLE_MESSAGE
|
|
95
|
-
sys.stderr.write(
|
|
96
|
-
git_command_unavailable_message.format(error=launch_error) + "\n"
|
|
97
|
-
)
|
|
96
|
+
sys.stderr.write(git_command_unavailable_message.format(error=launch_error) + "\n")
|
|
98
97
|
return GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
99
98
|
|
|
100
99
|
|
|
@@ -145,8 +144,7 @@ def is_all_zeros_object_name(object_name: str) -> bool:
|
|
|
145
144
|
if not stripped_object_name:
|
|
146
145
|
return True
|
|
147
146
|
return all(
|
|
148
|
-
each_character == all_zeros_object_name_character
|
|
149
|
-
for each_character in stripped_object_name
|
|
147
|
+
each_character == all_zeros_object_name_character for each_character in stripped_object_name
|
|
150
148
|
)
|
|
151
149
|
|
|
152
150
|
|
|
@@ -385,9 +383,7 @@ def resolve_default_branch_merge_base(
|
|
|
385
383
|
default_branch_reference = resolve_default_branch_reference(remote_name)
|
|
386
384
|
if default_branch_reference is None:
|
|
387
385
|
return None
|
|
388
|
-
default_branch_prefix = REMOTE_BRANCH_REFERENCE_TEMPLATE.format(
|
|
389
|
-
remote=remote_name, branch=""
|
|
390
|
-
)
|
|
386
|
+
default_branch_prefix = REMOTE_BRANCH_REFERENCE_TEMPLATE.format(remote=remote_name, branch="")
|
|
391
387
|
default_branch_name = default_branch_reference.removeprefix(default_branch_prefix)
|
|
392
388
|
if remote_branch_name == default_branch_name:
|
|
393
389
|
return None
|
|
@@ -514,8 +510,6 @@ def invoke_gate(gate_script_path: Path, base_reference: str) -> int:
|
|
|
514
510
|
return completion.returncode
|
|
515
511
|
|
|
516
512
|
|
|
517
|
-
|
|
518
|
-
|
|
519
513
|
def main() -> int:
|
|
520
514
|
stdin_read_failure_message = STDIN_READ_FAILURE_MESSAGE
|
|
521
515
|
gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
@@ -576,6 +570,9 @@ def main() -> int:
|
|
|
576
570
|
code_rules_exit_code = invoke_gate(gate_script_path, usable_base_reference)
|
|
577
571
|
if code_rules_exit_code != 0:
|
|
578
572
|
return code_rules_exit_code
|
|
573
|
+
pull_request_url = get_pull_request_url(Path.cwd())
|
|
574
|
+
if pull_request_url:
|
|
575
|
+
print(build_pull_request_reminder(pull_request_url))
|
|
579
576
|
return 0
|
|
580
577
|
|
|
581
578
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from git_hooks_constants import (
|
|
7
|
+
GH_EXECUTABLE_NAME,
|
|
8
|
+
GH_PR_VIEW_ARGUMENTS,
|
|
9
|
+
GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_pull_request_url(repo_dir: Path) -> str | None:
|
|
14
|
+
try:
|
|
15
|
+
result = subprocess.run(
|
|
16
|
+
[GH_EXECUTABLE_NAME, *GH_PR_VIEW_ARGUMENTS],
|
|
17
|
+
cwd=repo_dir,
|
|
18
|
+
check=False,
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
timeout=GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
22
|
+
)
|
|
23
|
+
except (OSError, subprocess.SubprocessError):
|
|
24
|
+
return None
|
|
25
|
+
if result.returncode != 0:
|
|
26
|
+
return None
|
|
27
|
+
return result.stdout.strip() or None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_pull_request_reminder(url: str) -> str:
|
|
31
|
+
prompt = "Review this pull request: <PR link>\n\nRead the PR and its full diff. Do not use the branch name, commit message, labels, current title, or a shallow summary.\n\nFind the main thing this adds for a user, caller, or operator.\n\nWrite like the reader is smart but knows nothing about this code. Pretend they asked you to explain it like they are five and feel stupid. If a word needs another sentence to explain it, replace the word — or turn it into a tiny story.\n\nYour job is not a dry API blurb. Your job is an illustrative before/after a person can picture.\n\nGold voice for titles and “What this adds”:\n\nThe theme package has a phone icon somewhere. We need to find it.\n\nBefore: yell “anyone know how to find icons?” and wait for a hand.\n\nAfter: call the finder by its name tag: “icon finder, go.”\n\nThat’s all “operation id” is — the finder’s name tag.\n\nRules for that voice:\n\nStart from a concrete scene (what’s in someone’s hand, what they’re hunting for).\nPrefer Before / After when the change is “how you ask” or “how you look it up.”\nUse picture words: hand it, open, find, check, save, skip, stop, call by name, name tag.\nPrefer spoken results: “found it,” “missing,” “duplicated,” “couldn’t.”\nWhen the diff uses an abstract word (operation id, registry, envelope, adapter, digest, schema), do one of two things:\nDrop it and say what the person does with their hands, or\nKeep it once, then gloss it in the same breath with a name-tag / mailbox / checklist metaphor — like the “finder’s name tag” line above.\nNever leave jargon unexplained. Never write as if the reader already knows the jargon.\nAdd a tiny concrete example in parentheses when a name is abstract (like “the phone icon”).\nFew words. Small words. Clear on first read.\nReturn:\n\nRecommended title\n\nFew words. Small words. Clear on first read.\nSay what you hand it, what it does, and what you get back — or the Before/After of how you ask.\nPrefer picture words and spoken results.\nGood title: “Call the icon finder by its name tag instead of asking around”\nGood title: “Given a theme STP and an asset name, report whether that asset was found, missing, or duplicated”\nBad title: anything that only says operation id / registry / typed envelope / adapter / digest / schema with no picture.\nUse “Add” only if it helps. Do not force it.\nNo vague words like “improve,” “enhance,” or “update.”\nReturn one title only.\nWhy this title fits\n\n1–2 short sentences in the same voice.\nIf needed, one Before / After beat, then what you get back.\nIf this section is needed to decode the title, rewrite the title.\nEvidence\n\nKey files or functions that support the title.\nIf you cannot read the PR or full diff, say so and do not propose a title.\n\nThen write a short, paste-ready PR description.\n\nUse this structure:\n\nWhat this adds\n1–2 short paragraphs in that illustrative voice. Lead with a scene when you can (“The theme package has a phone icon somewhere…”). If the change is a new way to ask, use Before / After. Say what you give it, what it does with its hands, and what you get back. If an abstract machine-word must appear, gloss it immediately (“That’s all X is — …”). Describe the job people can see. Do not describe inner machinery. Mention a check only if it is central to the feature. If there are many changes, keep the main one. Mention others only if they explain the main one.\n\nWhy\nOne short paragraph. Same plain level. Say who needs this and what goes wrong without it (the “yell and wait for a hand” problem). If this is needed to decode the feature, rewrite “What this adds.”\n\nVerification\nList only proof from the PR, CI, or your own checks.\n\nReported in the PR\n\n…\nChecked by you / CI here\n\n…\nRules:\n\nFew words. Small words. Clear on first read. ELI5 I-am-stupid mark.\nDo not invent behavior, tests, or benefits.\nStrip branch names, hashes, draft notes, agent notes, and merge notes.\nNo vague words like “improve,” “enhance,” or “update.”\nIllustrative first. Abstract labels only if glossed in the same breath.\nUse the recommended title and paste-ready description to run `gh pr edit <PR link> --title ... --body-file ...`, then run `gh pr view <PR link> --json title,body` to confirm the saved title and body. Report the saved result."
|
|
32
|
+
return prompt.replace("<PR link>", url)
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
|
+
import types
|
|
6
7
|
|
|
7
8
|
import gate_utils
|
|
8
9
|
import pytest
|
|
10
|
+
from git_hooks_constants import ALL_GATE_SCRIPT_RELATIVE_PATH
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def test_resolve_gate_script_path_uses_override_env_var_when_set(
|
|
@@ -218,3 +220,39 @@ def test_is_safe_regular_file_rejects_path_outside_claude_home_env_trust_root(
|
|
|
218
220
|
is_safe = gate_utils.is_safe_regular_file(outside_path, None)
|
|
219
221
|
|
|
220
222
|
assert not is_safe
|
|
223
|
+
|
|
224
|
+
def test_git_hooks_constants_exports_canonical_gate_script_relative_path() -> None:
|
|
225
|
+
assert ALL_GATE_SCRIPT_RELATIVE_PATH == (
|
|
226
|
+
"_shared",
|
|
227
|
+
"pr-loop",
|
|
228
|
+
"scripts",
|
|
229
|
+
"code_rules_gate.py",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_load_gate_script_relative_path_prefers_canonical_name() -> None:
|
|
234
|
+
constants_module = types.SimpleNamespace(
|
|
235
|
+
ALL_GATE_SCRIPT_RELATIVE_PATH=("_shared", "pr-loop", "scripts", "code_rules_gate.py"),
|
|
236
|
+
GATE_SCRIPT_RELATIVE_PATH=("legacy", "path.py"),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
loaded_path = gate_utils.load_gate_script_relative_path(constants_module)
|
|
240
|
+
|
|
241
|
+
assert loaded_path == ("_shared", "pr-loop", "scripts", "code_rules_gate.py")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_load_gate_script_relative_path_falls_back_to_legacy_name() -> None:
|
|
245
|
+
constants_module = types.SimpleNamespace(
|
|
246
|
+
GATE_SCRIPT_RELATIVE_PATH=("_shared", "pr-loop", "scripts", "code_rules_gate.py"),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
loaded_path = gate_utils.load_gate_script_relative_path(constants_module)
|
|
250
|
+
|
|
251
|
+
assert loaded_path == ("_shared", "pr-loop", "scripts", "code_rules_gate.py")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_gate_utils_module_import_resolves_live_constants() -> None:
|
|
255
|
+
loaded_path = gate_utils.load_gate_script_relative_path()
|
|
256
|
+
|
|
257
|
+
assert loaded_path == ALL_GATE_SCRIPT_RELATIVE_PATH
|
|
258
|
+
|
|
@@ -201,3 +201,90 @@ def test_main_prints_git_failure_diagnostic(
|
|
|
201
201
|
|
|
202
202
|
assert post_commit.main() == 0
|
|
203
203
|
assert "Git diagnostic: fatal: fixture commit failure" in capsys.readouterr().out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@pytest.mark.parametrize(
|
|
207
|
+
"completed_process",
|
|
208
|
+
[
|
|
209
|
+
subprocess.CompletedProcess(["gh"], 1, "", "no pull request"),
|
|
210
|
+
subprocess.CompletedProcess(["gh"], 1, "", "auth failed"),
|
|
211
|
+
],
|
|
212
|
+
)
|
|
213
|
+
def test_pull_request_reminder_is_silent_without_successful_url(
|
|
214
|
+
tmp_path: Path,
|
|
215
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
216
|
+
capsys: pytest.CaptureFixture[str],
|
|
217
|
+
completed_process: subprocess.CompletedProcess[str],
|
|
218
|
+
) -> None:
|
|
219
|
+
monkeypatch.setattr(post_commit.subprocess, "run", lambda *args, **kwargs: completed_process)
|
|
220
|
+
|
|
221
|
+
post_commit.print_pull_request_reminder(tmp_path)
|
|
222
|
+
|
|
223
|
+
assert capsys.readouterr().out == ""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_pull_request_reminder_prints_url(
|
|
227
|
+
tmp_path: Path,
|
|
228
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
229
|
+
capsys: pytest.CaptureFixture[str],
|
|
230
|
+
) -> None:
|
|
231
|
+
monkeypatch.setattr(
|
|
232
|
+
post_commit.subprocess,
|
|
233
|
+
"run",
|
|
234
|
+
lambda *args, **kwargs: subprocess.CompletedProcess(
|
|
235
|
+
["gh"], 0, "https://github.com/example/repo/pull/1\n", ""
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
post_commit.print_pull_request_reminder(tmp_path)
|
|
240
|
+
|
|
241
|
+
output = capsys.readouterr().out
|
|
242
|
+
assert output.startswith("Review this pull request: https://github.com/example/repo/pull/1\n")
|
|
243
|
+
assert "<PR link>" not in output
|
|
244
|
+
assert "Do not use the branch name, commit message, labels, current title" in output
|
|
245
|
+
assert "gh pr edit https://github.com/example/repo/pull/1" in output
|
|
246
|
+
assert output.rstrip().endswith("Report the saved result.")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@pytest.mark.parametrize("exception", [FileNotFoundError(), subprocess.TimeoutExpired("gh", 5)])
|
|
250
|
+
def test_pull_request_reminder_ignores_missing_gh_and_timeout(
|
|
251
|
+
tmp_path: Path,
|
|
252
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
253
|
+
capsys: pytest.CaptureFixture[str],
|
|
254
|
+
exception: Exception,
|
|
255
|
+
) -> None:
|
|
256
|
+
def raise_exception(*args: object, **kwargs: object) -> None:
|
|
257
|
+
raise exception
|
|
258
|
+
|
|
259
|
+
monkeypatch.setattr(post_commit.subprocess, "run", raise_exception)
|
|
260
|
+
|
|
261
|
+
post_commit.print_pull_request_reminder(tmp_path)
|
|
262
|
+
|
|
263
|
+
assert capsys.readouterr().out == ""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_main_runs_pull_request_reminder_after_parent_update(
|
|
267
|
+
tmp_path: Path,
|
|
268
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
269
|
+
) -> None:
|
|
270
|
+
events: list[str] = []
|
|
271
|
+
repo = tmp_path / "repo"
|
|
272
|
+
parent = tmp_path / "parent"
|
|
273
|
+
repo.mkdir()
|
|
274
|
+
parent.mkdir()
|
|
275
|
+
monkeypatch.setattr(post_commit, "run_git_from_current_directory", lambda *args: str(repo))
|
|
276
|
+
monkeypatch.setattr(post_commit, "find_parent_repo", lambda repo_dir: parent)
|
|
277
|
+
monkeypatch.setattr(post_commit, "run_git", lambda *args, cwd: "a" * 40)
|
|
278
|
+
monkeypatch.setattr(
|
|
279
|
+
post_commit,
|
|
280
|
+
"update_parent_pointer",
|
|
281
|
+
lambda *args: events.append("parent") or (post_commit.ParentPointerStatus.UPDATED, ""),
|
|
282
|
+
)
|
|
283
|
+
monkeypatch.setattr(
|
|
284
|
+
post_commit,
|
|
285
|
+
"print_pull_request_reminder",
|
|
286
|
+
lambda repo_dir: events.append("reminder"),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
assert post_commit.main() == 0
|
|
290
|
+
assert events == ["parent", "reminder"]
|