claude-dev-env 7.0.0 → 7.1.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/.agents/skills/pull-request/scripts/pull_request.py +2 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
- package/.agents/skills/session-continuity/AGENTS.md +1 -0
- package/.agents/skills/session-continuity/CLAUDE.md +1 -0
- package/.agents/skills/session-continuity/README.md +189 -0
- package/.agents/skills/session-continuity/SKILL.md +114 -0
- package/.agents/skills/session-continuity/continuity.mjs +375 -0
- package/.agents/skills/session-continuity/continuity.test.mjs +303 -0
- package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
- package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
- package/bin/cde.mjs +36 -8
- package/bin/cde.test.mjs +48 -0
- package/bin/ever-shipped-skills.mjs +2 -20
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/expand_home_directory_tokens.mjs +4 -5
- package/bin/expand_home_directory_tokens.test.mjs +128 -0
- package/bin/git_hooks_installer.mjs +22 -3
- package/bin/git_hooks_installer.test.mjs +159 -5
- package/bin/install-session-continuity.mjs +78 -0
- package/bin/install-session-continuity.test.mjs +42 -0
- package/bin/install.codex-rules.test.mjs +5 -2
- package/bin/install.cursor-rules.test.mjs +5 -2
- package/bin/install.mjs +478 -63
- package/bin/install.policy-lint.test.mjs +56 -4
- package/bin/install.profiles.test.mjs +10 -0
- package/bin/install.prune.test.mjs +227 -17
- package/bin/install.test.mjs +500 -120
- package/bin/install.transaction.test.mjs +3 -3
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -8
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
- package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
- package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
- package/hooks/git-hooks/post_commit.py +33 -0
- package/hooks/git-hooks/pre_commit.py +18 -90
- package/hooks/git-hooks/pre_push.py +45 -541
- package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
- package/hooks/git-hooks/test_native_hook_support.py +129 -0
- package/hooks/git-hooks/test_native_policy_commit.py +39 -6
- package/hooks/git-hooks/test_post_commit.py +43 -0
- package/hooks/git-hooks/test_pre_commit.py +36 -392
- package/hooks/git-hooks/test_pre_push.py +48 -1631
- package/hooks/git-hooks/test_verification_notice.py +60 -0
- package/hooks/git-hooks/test_verification_start.py +173 -0
- package/hooks/git-hooks/verification_notice.py +192 -0
- package/hooks/git-hooks/verification_notice_context.py +253 -0
- package/hooks/git-hooks/verification_notice_state.py +237 -0
- package/hooks/git-hooks/verification_start.py +100 -0
- package/hooks/hooks.json +0 -119
- package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
- package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
- package/hooks/validators/run_all_validators.py +5 -1
- package/hooks/validators/security_checks.py +57 -2
- package/hooks/validators/test_hook_timing_harness.py +2 -5
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
- package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
- package/hooks/validators/test_security_checks.py +57 -0
- package/package.json +1 -1
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/git-workflow.md +12 -0
- package/rules/pstack-host-mapping.md +48 -0
- package/rules/vault-context.md +3 -5
- package/rules/verify-runtime-state.md +7 -0
- package/scripts/audit-hooks-cli.mjs +5 -0
- package/scripts/audit-hooks.mjs +72 -16
- package/scripts/automatic_advisory/__init__.py +1 -0
- package/scripts/automatic_advisory/checkout.py +51 -0
- package/scripts/automatic_advisory/cli.py +334 -0
- package/scripts/automatic_advisory/config/__init__.py +1 -0
- package/scripts/automatic_advisory/config/constants.py +93 -0
- package/scripts/automatic_advisory/config/timing.py +4 -0
- package/scripts/automatic_advisory/configuration.py +197 -0
- package/scripts/automatic_advisory/execution.py +350 -0
- package/scripts/automatic_advisory/git.py +69 -0
- package/scripts/automatic_advisory/model.py +107 -0
- package/scripts/automatic_advisory/process_host.py +32 -0
- package/scripts/automatic_advisory/publisher.py +84 -0
- package/scripts/automatic_advisory/runner.py +399 -0
- package/scripts/automatic_advisory/state.py +116 -0
- package/scripts/automatic_advisory/window_flags.py +41 -0
- package/scripts/automatic_advisory/windows_job.py +122 -0
- package/scripts/codex_compat_materializer.py +99 -13
- package/scripts/durable_post_lint.py +30 -2
- package/scripts/durable_post_lint_config/config/constants.py +10 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
- package/scripts/local_report_cli.py +184 -0
- package/scripts/local_report_core.py +247 -0
- package/scripts/local_report_publisher.py +12 -0
- package/scripts/local_report_validation.py +357 -0
- package/scripts/local_verification/__init__.py +23 -0
- package/scripts/local_verification/check_runner.py +364 -0
- package/scripts/local_verification/check_support.py +99 -0
- package/scripts/local_verification/cli.py +158 -0
- package/scripts/local_verification/command_runner.py +84 -0
- package/scripts/local_verification/config/__init__.py +1 -0
- package/scripts/local_verification/config/constants.py +89 -0
- package/scripts/local_verification/config/timing.py +1 -0
- package/scripts/local_verification/git_state.py +146 -0
- package/scripts/local_verification/manifest.py +224 -0
- package/scripts/local_verification/model.py +174 -0
- package/scripts/local_verification/runner.py +234 -0
- package/scripts/policy_lint/adapter_pairing.py +58 -3
- package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
- package/scripts/pr_verification/__init__.py +1 -0
- package/scripts/pr_verification/branch_refs.py +114 -0
- package/scripts/pr_verification/config/__init__.py +1 -0
- package/scripts/pr_verification/config/constants.py +84 -0
- package/scripts/pr_verification/config/timing.py +5 -0
- package/scripts/pr_verification/github.py +137 -0
- package/scripts/pr_verification/github_api.py +376 -0
- package/scripts/pr_verification/github_labels.py +88 -0
- package/scripts/pr_verification/github_parsing.py +182 -0
- package/scripts/pr_verification/github_transport.py +91 -0
- package/scripts/pr_verification/lock.py +122 -0
- package/scripts/pr_verification/model.py +43 -0
- package/scripts/pr_verification/requirements.txt +1 -0
- package/scripts/test_durable_post_lint.py +97 -0
- package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
- package/scripts/tests/test-audit-hooks.mjs +94 -2
- package/scripts/tests/test_automatic_advisory.py +376 -0
- package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
- package/scripts/tests/test_automatic_advisory_polling.py +216 -0
- package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
- package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
- package/scripts/tests/test_automatic_advisory_settings.py +48 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
- package/scripts/tests/test_closed_pr_label.py +157 -0
- package/scripts/tests/test_codex_compat_materializer.py +189 -6
- package/scripts/tests/test_github_labels.py +101 -0
- package/scripts/tests/test_github_transport.py +125 -0
- package/scripts/tests/test_local_report_publisher.py +396 -0
- package/scripts/tests/test_local_report_publisher_cli.py +85 -0
- package/scripts/tests/test_local_verification.py +348 -0
- package/scripts/tests/test_local_verification_cli.py +98 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
- package/scripts/tests/test_pr_verification_github.py +392 -0
- package/scripts/tests/test_pr_verification_lock.py +168 -0
- package/scripts/tests/test_verification_notice.py +347 -0
- package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
- package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
- package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
- package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
- package/hooks/blocking/destructive_command_blocker.py +0 -2073
- package/hooks/blocking/fable_spawn_gate.py +0 -187
- package/hooks/blocking/luna_fast_mode_gate.py +0 -160
- package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
- package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
- package/hooks/blocking/pii_prevention_blocker.py +0 -365
- package/hooks/blocking/piped_pytest_blocker.py +0 -1223
- package/hooks/blocking/question_to_user_enforcer.py +0 -130
- package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
- package/hooks/blocking/sensitive_file_protector.py +0 -167
- package/hooks/blocking/session_edit_stage_gate.py +0 -684
- package/hooks/blocking/session_handoff_blocker.py +0 -182
- package/hooks/blocking/shell_substitution_blocker.py +0 -350
- package/hooks/blocking/test_ask_user_question_shape.py +0 -69
- package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
- package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
- package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
- package/hooks/blocking/test_fable_spawn_gate.py +0 -381
- package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
- package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
- package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
- package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
- package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
- package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
- package/hooks/blocking/test_sensitive_file_protector.py +0 -365
- package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
- package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
- package/hooks/blocking/test_session_handoff_blocker.py +0 -318
- package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
- package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
- package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
- package/hooks/blocking/unscoped_search_blocker.py +0 -391
- package/hooks/blocking/write_existing_file_blocker.py +0 -124
- package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
- package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
import verification_notice
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _record_notice_execution(
|
|
11
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
12
|
+
) -> tuple[io.StringIO, list[object]]:
|
|
13
|
+
notice_context = object()
|
|
14
|
+
all_started_contexts: list[object] = []
|
|
15
|
+
monkeypatch.setattr(
|
|
16
|
+
verification_notice,
|
|
17
|
+
"_load_notice_context",
|
|
18
|
+
lambda event, repository: notice_context,
|
|
19
|
+
)
|
|
20
|
+
monkeypatch.setattr(
|
|
21
|
+
verification_notice,
|
|
22
|
+
"build_verification_notice",
|
|
23
|
+
lambda context: "notice\n",
|
|
24
|
+
)
|
|
25
|
+
monkeypatch.setattr(
|
|
26
|
+
verification_notice,
|
|
27
|
+
"start_automatic_advisory",
|
|
28
|
+
all_started_contexts.append,
|
|
29
|
+
)
|
|
30
|
+
return io.StringIO(), all_started_contexts
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_notice_only_prints_without_starting_verification(
|
|
34
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
35
|
+
) -> None:
|
|
36
|
+
standard_output, all_started_contexts = _record_notice_execution(monkeypatch)
|
|
37
|
+
|
|
38
|
+
exit_code = verification_notice.main(
|
|
39
|
+
["--event", "commit", "--notice-only", "--repo", str(tmp_path)],
|
|
40
|
+
stdout=standard_output,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert exit_code == 0
|
|
44
|
+
assert standard_output.getvalue() == "notice\n"
|
|
45
|
+
assert all_started_contexts == []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_default_notice_prints_and_starts_verification(
|
|
49
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
50
|
+
) -> None:
|
|
51
|
+
standard_output, all_started_contexts = _record_notice_execution(monkeypatch)
|
|
52
|
+
|
|
53
|
+
exit_code = verification_notice.main(
|
|
54
|
+
["--event", "commit", "--repo", str(tmp_path)],
|
|
55
|
+
stdout=standard_output,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert exit_code == 0
|
|
59
|
+
assert standard_output.getvalue() == "notice\n"
|
|
60
|
+
assert len(all_started_contexts) == 1
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
scripts_directory = Path(__file__).resolve().parents[2] / "scripts"
|
|
11
|
+
if str(scripts_directory) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(scripts_directory))
|
|
13
|
+
|
|
14
|
+
from verification_notice import build_verification_notice
|
|
15
|
+
from verification_notice_context import VerificationNoticeContext
|
|
16
|
+
from verification_start import start_automatic_advisory
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_context(
|
|
20
|
+
repository_root: Path,
|
|
21
|
+
*,
|
|
22
|
+
event: str = "commit",
|
|
23
|
+
repository_remote: str = "jonecho/python-automation",
|
|
24
|
+
) -> VerificationNoticeContext:
|
|
25
|
+
git_directory = repository_root / ".git"
|
|
26
|
+
return VerificationNoticeContext(
|
|
27
|
+
event=event,
|
|
28
|
+
repository_remote=repository_remote,
|
|
29
|
+
repository_root=repository_root,
|
|
30
|
+
current_head="a" * 40,
|
|
31
|
+
manifest_path=None,
|
|
32
|
+
manifest_is_available=False,
|
|
33
|
+
git_directory=git_directory,
|
|
34
|
+
report_is_present=False,
|
|
35
|
+
all_report_fields=None,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_runner_configuration(repository_root: Path, configuration: object) -> None:
|
|
40
|
+
runner_path = repository_root / ".git" / "local-verification" / "runner.json"
|
|
41
|
+
runner_path.parent.mkdir(parents=True)
|
|
42
|
+
runner_path.write_text(json.dumps(configuration), encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_runner_configuration(repository_root: Path) -> tuple[str, str]:
|
|
46
|
+
python_path = str(repository_root / "venv" / "Scripts" / "python.exe")
|
|
47
|
+
settings_path = str(repository_root / "settings.json")
|
|
48
|
+
write_runner_configuration(
|
|
49
|
+
repository_root,
|
|
50
|
+
{"python": python_path, "settings": settings_path},
|
|
51
|
+
)
|
|
52
|
+
return python_path, settings_path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_start_automatic_advisory_launches_without_waiting(
|
|
56
|
+
tmp_path: Path,
|
|
57
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
58
|
+
) -> None:
|
|
59
|
+
python_path, settings_path = create_runner_configuration(tmp_path)
|
|
60
|
+
|
|
61
|
+
all_launches: list[tuple[object, dict[str, object]]] = []
|
|
62
|
+
|
|
63
|
+
class UnwaitedProcess:
|
|
64
|
+
def wait(self) -> None:
|
|
65
|
+
raise AssertionError("native startup must not wait")
|
|
66
|
+
|
|
67
|
+
def record_launch(
|
|
68
|
+
command: object,
|
|
69
|
+
**launch_options: object,
|
|
70
|
+
) -> UnwaitedProcess:
|
|
71
|
+
all_launches.append((command, launch_options))
|
|
72
|
+
return UnwaitedProcess()
|
|
73
|
+
|
|
74
|
+
monkeypatch.setattr(subprocess, "Popen", record_launch)
|
|
75
|
+
|
|
76
|
+
start_automatic_advisory(build_context(tmp_path))
|
|
77
|
+
|
|
78
|
+
assert len(all_launches) == 1
|
|
79
|
+
_assert_launch_matches_runner(
|
|
80
|
+
all_launches[0],
|
|
81
|
+
python_path,
|
|
82
|
+
settings_path,
|
|
83
|
+
tmp_path,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _assert_launch_matches_runner(
|
|
88
|
+
launch: tuple[object, dict[str, object]],
|
|
89
|
+
python_path: str,
|
|
90
|
+
settings_path: str,
|
|
91
|
+
repository_root: Path,
|
|
92
|
+
) -> None:
|
|
93
|
+
command, launch_options = launch
|
|
94
|
+
assert command == [
|
|
95
|
+
python_path,
|
|
96
|
+
str(Path(__file__).resolve().parents[2] / "scripts" / "automatic_advisory" / "cli.py"),
|
|
97
|
+
"--settings",
|
|
98
|
+
settings_path,
|
|
99
|
+
"--start",
|
|
100
|
+
]
|
|
101
|
+
assert launch_options["cwd"] == repository_root
|
|
102
|
+
assert launch_options["stdin"] is subprocess.DEVNULL
|
|
103
|
+
assert launch_options["stdout"] is subprocess.DEVNULL
|
|
104
|
+
assert launch_options["stderr"] is subprocess.DEVNULL
|
|
105
|
+
assert "--once" not in command
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_start_automatic_advisory_ignores_malformed_configuration(
|
|
109
|
+
tmp_path: Path,
|
|
110
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
111
|
+
) -> None:
|
|
112
|
+
runner_path = tmp_path / ".git" / "local-verification" / "runner.json"
|
|
113
|
+
runner_path.parent.mkdir(parents=True)
|
|
114
|
+
runner_path.write_text("not-json", encoding="utf-8")
|
|
115
|
+
monkeypatch.setattr(
|
|
116
|
+
subprocess,
|
|
117
|
+
"Popen",
|
|
118
|
+
lambda *arguments, **options: pytest.fail("malformed configuration launched a process"),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
start_automatic_advisory(build_context(tmp_path))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_start_automatic_advisory_ignores_other_repository_and_event(
|
|
125
|
+
tmp_path: Path,
|
|
126
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
127
|
+
) -> None:
|
|
128
|
+
write_runner_configuration(
|
|
129
|
+
tmp_path,
|
|
130
|
+
{"python": str(tmp_path / "python"), "settings": str(tmp_path / "settings")},
|
|
131
|
+
)
|
|
132
|
+
launch_count = 0
|
|
133
|
+
|
|
134
|
+
def count_launches(*arguments: object, **options: object) -> None:
|
|
135
|
+
nonlocal launch_count
|
|
136
|
+
launch_count += 1
|
|
137
|
+
|
|
138
|
+
monkeypatch.setattr(subprocess, "Popen", count_launches)
|
|
139
|
+
|
|
140
|
+
start_automatic_advisory(
|
|
141
|
+
build_context(tmp_path, repository_remote="other-owner/other-repository")
|
|
142
|
+
)
|
|
143
|
+
start_automatic_advisory(build_context(tmp_path, event="merge"))
|
|
144
|
+
|
|
145
|
+
assert launch_count == 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_start_automatic_advisory_ignores_launch_failure(
|
|
149
|
+
tmp_path: Path,
|
|
150
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
151
|
+
) -> None:
|
|
152
|
+
write_runner_configuration(
|
|
153
|
+
tmp_path,
|
|
154
|
+
{"python": str(tmp_path / "python"), "settings": str(tmp_path / "settings")},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def fail_to_launch(*arguments: object, **options: object) -> None:
|
|
158
|
+
raise OSError("process unavailable")
|
|
159
|
+
|
|
160
|
+
monkeypatch.setattr(subprocess, "Popen", fail_to_launch)
|
|
161
|
+
|
|
162
|
+
start_automatic_advisory(build_context(tmp_path))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_notice_prints_runner_python_in_canonical_command(tmp_path: Path) -> None:
|
|
166
|
+
python_path, settings_path = create_runner_configuration(tmp_path)
|
|
167
|
+
|
|
168
|
+
notice_text = build_verification_notice(build_context(tmp_path))
|
|
169
|
+
|
|
170
|
+
expected_command = (
|
|
171
|
+
f"& '{python_path}' '{tmp_path / '.github' / 'ci' / 'local_verify.py'}' --base origin/main"
|
|
172
|
+
)
|
|
173
|
+
assert expected_command in notice_text
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Print a bounded, nonblocking local verification reminder for native Git."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Never, TextIO
|
|
11
|
+
|
|
12
|
+
from git_hooks_constants.verification_notice_constants import (
|
|
13
|
+
ALL_NOTICE_EVENTS,
|
|
14
|
+
BASE_ARGUMENT,
|
|
15
|
+
BASE_REFERENCE,
|
|
16
|
+
COMMAND_SEPARATOR,
|
|
17
|
+
EXECUTOR_ARGUMENT,
|
|
18
|
+
EXECUTOR_DIRECTORY_NAME,
|
|
19
|
+
EXECUTOR_FILE_NAME,
|
|
20
|
+
GIT_COMMAND_SUCCESS_EXIT_CODE,
|
|
21
|
+
GIT_DIRECTORY_NAME,
|
|
22
|
+
JSON_ENCODING,
|
|
23
|
+
LOCAL_VERIFICATION_DIRECTORY_NAME,
|
|
24
|
+
LOCAL_VERIFICATION_PACKAGE_DIRECTORY_NAME,
|
|
25
|
+
NO_VERIFIED_SHA,
|
|
26
|
+
NOTICE_ADVISORY_LINE,
|
|
27
|
+
NOTICE_HEADER,
|
|
28
|
+
NOTICE_LINE_SEPARATOR,
|
|
29
|
+
NOTICE_RUN_LINE,
|
|
30
|
+
OUTPUT_ARGUMENT,
|
|
31
|
+
PACKAGE_ROOT_PARENT_INDEX,
|
|
32
|
+
POWERSHELL_CALL_OPERATOR,
|
|
33
|
+
POWERSHELL_ESCAPED_QUOTE,
|
|
34
|
+
POWERSHELL_QUOTE,
|
|
35
|
+
PYTHON_COMMAND,
|
|
36
|
+
REPORT_FILE_NAME,
|
|
37
|
+
SCOPED_VERIFICATION_SCRIPT_PATH,
|
|
38
|
+
TARGET_REPOSITORY_REMOTE,
|
|
39
|
+
UNKNOWN_SHA,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
verification_scripts_directory = (
|
|
43
|
+
Path(__file__).resolve().parents[PACKAGE_ROOT_PARENT_INDEX] / EXECUTOR_DIRECTORY_NAME
|
|
44
|
+
)
|
|
45
|
+
if str(verification_scripts_directory) not in sys.path:
|
|
46
|
+
sys.path.insert(0, str(verification_scripts_directory))
|
|
47
|
+
|
|
48
|
+
from verification_notice_context import (
|
|
49
|
+
VerificationNoticeContext,
|
|
50
|
+
_load_notice_context,
|
|
51
|
+
_run_git_query,
|
|
52
|
+
)
|
|
53
|
+
from verification_start import load_runner_configuration, start_automatic_advisory
|
|
54
|
+
from verification_notice_state import (
|
|
55
|
+
_build_state_message,
|
|
56
|
+
_evaluate_context,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _NoticeArgumentParser(argparse.ArgumentParser):
|
|
61
|
+
def error(self, message: str) -> Never:
|
|
62
|
+
raise ValueError(message)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_verification_notice(context: VerificationNoticeContext) -> str:
|
|
66
|
+
"""Render the advisory for one normalized repository context.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
context: Repository fields that define the rendered notice.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The formatted advisory text, or an empty string for another repository.
|
|
73
|
+
"""
|
|
74
|
+
if context.repository_remote != TARGET_REPOSITORY_REMOTE:
|
|
75
|
+
return ""
|
|
76
|
+
current_head = context.current_head or UNKNOWN_SHA
|
|
77
|
+
state, verified_head = _evaluate_context(context, _run_git_query)
|
|
78
|
+
all_notice_lines = (
|
|
79
|
+
NOTICE_HEADER,
|
|
80
|
+
f"Repository remote: {context.repository_remote}",
|
|
81
|
+
f"Event: {context.event}",
|
|
82
|
+
f"Current SHA: {current_head}",
|
|
83
|
+
f"Verified SHA: {verified_head or NO_VERIFIED_SHA}",
|
|
84
|
+
f"State: {state}",
|
|
85
|
+
_build_state_message(state, context.manifest_is_available),
|
|
86
|
+
NOTICE_ADVISORY_LINE,
|
|
87
|
+
NOTICE_RUN_LINE,
|
|
88
|
+
_build_canonical_command(context),
|
|
89
|
+
)
|
|
90
|
+
return NOTICE_LINE_SEPARATOR.join(all_notice_lines) + NOTICE_LINE_SEPARATOR
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main(
|
|
94
|
+
all_arguments: Sequence[str] | None = None,
|
|
95
|
+
*,
|
|
96
|
+
stdout: TextIO = sys.stdout,
|
|
97
|
+
) -> int:
|
|
98
|
+
"""Read Git metadata and print a reminder without affecting the Git action.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
all_arguments: Optional command arguments for the notice CLI.
|
|
102
|
+
stdout: Stream receiving the rendered notice.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Zero so the native Git action continues.
|
|
106
|
+
"""
|
|
107
|
+
parsed_arguments = _parse_arguments(all_arguments)
|
|
108
|
+
if parsed_arguments is None:
|
|
109
|
+
return GIT_COMMAND_SUCCESS_EXIT_CODE
|
|
110
|
+
try:
|
|
111
|
+
_write_parsed_notice(parsed_arguments, stdout)
|
|
112
|
+
except (OSError, UnicodeError, ValueError):
|
|
113
|
+
return GIT_COMMAND_SUCCESS_EXIT_CODE
|
|
114
|
+
return GIT_COMMAND_SUCCESS_EXIT_CODE
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _write_parsed_notice(parsed_arguments: argparse.Namespace, stdout: TextIO) -> None:
|
|
118
|
+
notice_context = _load_notice_context(
|
|
119
|
+
parsed_arguments.event,
|
|
120
|
+
Path(parsed_arguments.repository),
|
|
121
|
+
)
|
|
122
|
+
if notice_context is None:
|
|
123
|
+
return
|
|
124
|
+
_write_notice(stdout, build_verification_notice(notice_context))
|
|
125
|
+
if parsed_arguments.notice_only:
|
|
126
|
+
return
|
|
127
|
+
start_automatic_advisory(notice_context)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_arguments(
|
|
131
|
+
all_arguments: Sequence[str] | None,
|
|
132
|
+
) -> argparse.Namespace | None:
|
|
133
|
+
argument_parser = _NoticeArgumentParser(add_help=True)
|
|
134
|
+
argument_parser.add_argument("--event", required=True)
|
|
135
|
+
argument_parser.add_argument("--notice-only", action="store_true")
|
|
136
|
+
argument_parser.add_argument("--repo", dest="repository", required=True)
|
|
137
|
+
try:
|
|
138
|
+
parsed_arguments, unknown_arguments = argument_parser.parse_known_args(all_arguments)
|
|
139
|
+
except (SystemExit, ValueError):
|
|
140
|
+
return None
|
|
141
|
+
if unknown_arguments or parsed_arguments.event not in ALL_NOTICE_EVENTS:
|
|
142
|
+
return None
|
|
143
|
+
return parsed_arguments
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _write_notice(stdout: TextIO, notice_text: str) -> None:
|
|
147
|
+
try:
|
|
148
|
+
stdout.write(notice_text)
|
|
149
|
+
except UnicodeError:
|
|
150
|
+
stream_encoding = getattr(stdout, "encoding", None) or JSON_ENCODING
|
|
151
|
+
safe_notice_text = notice_text.encode(
|
|
152
|
+
stream_encoding,
|
|
153
|
+
errors="replace",
|
|
154
|
+
).decode(stream_encoding, errors="replace")
|
|
155
|
+
stdout.write(safe_notice_text)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_canonical_command(context: VerificationNoticeContext) -> str:
|
|
159
|
+
git_directory = context.git_directory or context.repository_root / GIT_DIRECTORY_NAME
|
|
160
|
+
report_path = git_directory / LOCAL_VERIFICATION_DIRECTORY_NAME / REPORT_FILE_NAME
|
|
161
|
+
executor_path = (
|
|
162
|
+
Path(__file__).resolve().parents[PACKAGE_ROOT_PARENT_INDEX]
|
|
163
|
+
/ EXECUTOR_DIRECTORY_NAME
|
|
164
|
+
/ LOCAL_VERIFICATION_PACKAGE_DIRECTORY_NAME
|
|
165
|
+
/ EXECUTOR_FILE_NAME
|
|
166
|
+
)
|
|
167
|
+
all_command_parts = (
|
|
168
|
+
_build_python_command(context),
|
|
169
|
+
_quote_windows_path(context.repository_root / SCOPED_VERIFICATION_SCRIPT_PATH),
|
|
170
|
+
f"{BASE_ARGUMENT} {BASE_REFERENCE}",
|
|
171
|
+
f"{EXECUTOR_ARGUMENT} {_quote_windows_path(executor_path)}",
|
|
172
|
+
f"{OUTPUT_ARGUMENT} {_quote_windows_path(report_path)}",
|
|
173
|
+
)
|
|
174
|
+
return COMMAND_SEPARATOR.join(all_command_parts)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _build_python_command(context: VerificationNoticeContext) -> str:
|
|
178
|
+
runner_configuration = load_runner_configuration(context)
|
|
179
|
+
if runner_configuration is None:
|
|
180
|
+
return PYTHON_COMMAND
|
|
181
|
+
python_path, _ = runner_configuration
|
|
182
|
+
return POWERSHELL_CALL_OPERATOR + _quote_windows_path(Path(python_path))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _quote_windows_path(path: Path) -> str:
|
|
186
|
+
path_text = str(path)
|
|
187
|
+
escaped_path = path_text.replace(POWERSHELL_QUOTE, POWERSHELL_ESCAPED_QUOTE)
|
|
188
|
+
return f"{POWERSHELL_QUOTE}{escaped_path}{POWERSHELL_QUOTE}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Load repository context used by the native Git verification notice."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from git_hooks_constants import (
|
|
13
|
+
GIT_COMMAND_TIMEOUT_SECONDS,
|
|
14
|
+
GIT_EXECUTABLE_NAME,
|
|
15
|
+
GIT_OUTPUT_DECODE_ERRORS_POLICY,
|
|
16
|
+
GIT_OUTPUT_ENCODING_NAME,
|
|
17
|
+
)
|
|
18
|
+
from git_hooks_constants.verification_notice_constants import (
|
|
19
|
+
ALL_GIT_ABSOLUTE_DIRECTORY_QUERY,
|
|
20
|
+
ALL_GIT_HEAD_QUERY,
|
|
21
|
+
ALL_GIT_REMOTE_URL_QUERY,
|
|
22
|
+
ALL_GIT_REPOSITORY_ROOT_QUERY,
|
|
23
|
+
GIT_COMMAND_SUCCESS_EXIT_CODE,
|
|
24
|
+
GIT_DIRECTORY_NAME,
|
|
25
|
+
JSON_ENCODING,
|
|
26
|
+
LOCAL_VERIFICATION_DIRECTORY_NAME,
|
|
27
|
+
MANIFEST_DIRECTORY_NAME,
|
|
28
|
+
MANIFEST_FILE_NAME,
|
|
29
|
+
MINIMUM_REMOTE_PART_COUNT,
|
|
30
|
+
OWNER_PART_INDEX,
|
|
31
|
+
REPORT_FILE_NAME,
|
|
32
|
+
REPOSITORY_PART_INDEX,
|
|
33
|
+
SELECTED_MANIFEST_PATH_FIELD,
|
|
34
|
+
SELECTION_FIELD,
|
|
35
|
+
TARGET_REPOSITORY_REMOTE,
|
|
36
|
+
)
|
|
37
|
+
from local_verification.manifest import ManifestRunFatal, load_manifest
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class VerificationNoticeContext:
|
|
42
|
+
event: str
|
|
43
|
+
repository_remote: str
|
|
44
|
+
repository_root: Path
|
|
45
|
+
current_head: str | None
|
|
46
|
+
manifest_path: Path | None
|
|
47
|
+
manifest_is_available: bool
|
|
48
|
+
git_directory: Path | None
|
|
49
|
+
report_is_present: bool
|
|
50
|
+
all_report_fields: Mapping[str, object] | None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalize_repository_remote(remote_url: str) -> str | None:
|
|
54
|
+
"""Return an owner/repository slug from common Git remote URL forms.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
remote_url: Git remote URL to normalize.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The normalized owner/repository slug, or None for an invalid URL.
|
|
61
|
+
"""
|
|
62
|
+
stripped_remote_url = remote_url.strip().rstrip("/")
|
|
63
|
+
if not stripped_remote_url:
|
|
64
|
+
return None
|
|
65
|
+
remote_path = _extract_remote_path(stripped_remote_url)
|
|
66
|
+
all_remote_parts = [each_part for each_part in remote_path.split("/") if each_part]
|
|
67
|
+
if len(all_remote_parts) < MINIMUM_REMOTE_PART_COUNT:
|
|
68
|
+
return None
|
|
69
|
+
repository_name = all_remote_parts[REPOSITORY_PART_INDEX].removesuffix(GIT_DIRECTORY_NAME)
|
|
70
|
+
owner_name = all_remote_parts[OWNER_PART_INDEX]
|
|
71
|
+
if not owner_name or not repository_name:
|
|
72
|
+
return None
|
|
73
|
+
return f"{owner_name}/{repository_name}".casefold()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_notice_context(
|
|
77
|
+
event: str, repository_path: Path
|
|
78
|
+
) -> VerificationNoticeContext | None:
|
|
79
|
+
repository_identity = _load_repository_identity(repository_path)
|
|
80
|
+
if repository_identity is None:
|
|
81
|
+
return None
|
|
82
|
+
repository_root, current_head, git_directory_text = repository_identity
|
|
83
|
+
normalized_remote = normalize_repository_remote(
|
|
84
|
+
_run_git_query(repository_root, ALL_GIT_REMOTE_URL_QUERY, False) or ""
|
|
85
|
+
)
|
|
86
|
+
if normalized_remote != TARGET_REPOSITORY_REMOTE or current_head is None:
|
|
87
|
+
return None
|
|
88
|
+
if not git_directory_text:
|
|
89
|
+
return None
|
|
90
|
+
git_directory = _resolve_common_directory(repository_root, git_directory_text)
|
|
91
|
+
all_report_fields, report_is_present = _load_repository_report(git_directory)
|
|
92
|
+
manifest_path, manifest_is_available = _load_manifest_context(
|
|
93
|
+
repository_root, git_directory, all_report_fields
|
|
94
|
+
)
|
|
95
|
+
return VerificationNoticeContext(
|
|
96
|
+
event=event,
|
|
97
|
+
repository_remote=normalized_remote,
|
|
98
|
+
repository_root=repository_root,
|
|
99
|
+
current_head=current_head,
|
|
100
|
+
manifest_path=manifest_path,
|
|
101
|
+
manifest_is_available=manifest_is_available,
|
|
102
|
+
git_directory=git_directory,
|
|
103
|
+
report_is_present=report_is_present,
|
|
104
|
+
all_report_fields=all_report_fields,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _load_manifest_context(
|
|
109
|
+
repository_root: Path,
|
|
110
|
+
git_directory: Path,
|
|
111
|
+
all_report_fields: Mapping[str, object] | None,
|
|
112
|
+
) -> tuple[Path | None, bool]:
|
|
113
|
+
manifest_path = _resolve_selected_manifest_path(
|
|
114
|
+
repository_root, git_directory, all_report_fields
|
|
115
|
+
)
|
|
116
|
+
return manifest_path, _manifest_is_available(manifest_path)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _load_repository_report(
|
|
120
|
+
git_directory: Path,
|
|
121
|
+
) -> tuple[Mapping[str, object] | None, bool]:
|
|
122
|
+
report_path = git_directory / LOCAL_VERIFICATION_DIRECTORY_NAME / REPORT_FILE_NAME
|
|
123
|
+
return _load_json_mapping(report_path), report_path.is_file()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve_selected_manifest_path(
|
|
127
|
+
repository_root: Path,
|
|
128
|
+
git_directory: Path,
|
|
129
|
+
all_report_fields: Mapping[str, object] | None,
|
|
130
|
+
) -> Path | None:
|
|
131
|
+
selected_path_text = _read_selected_manifest_path(all_report_fields)
|
|
132
|
+
if selected_path_text is None:
|
|
133
|
+
if all_report_fields is not None:
|
|
134
|
+
return None
|
|
135
|
+
return repository_root / MANIFEST_DIRECTORY_NAME / MANIFEST_FILE_NAME
|
|
136
|
+
selected_path = Path(selected_path_text)
|
|
137
|
+
if not selected_path.is_absolute():
|
|
138
|
+
selected_path = repository_root / selected_path
|
|
139
|
+
resolved_path = selected_path.resolve()
|
|
140
|
+
if _is_inside_repository(resolved_path, repository_root):
|
|
141
|
+
return resolved_path
|
|
142
|
+
metadata_directory = git_directory / LOCAL_VERIFICATION_DIRECTORY_NAME
|
|
143
|
+
if _is_inside_repository(resolved_path, metadata_directory):
|
|
144
|
+
return resolved_path
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _read_selected_manifest_path(
|
|
149
|
+
all_report_fields: Mapping[str, object] | None,
|
|
150
|
+
) -> str | None:
|
|
151
|
+
if all_report_fields is None:
|
|
152
|
+
return None
|
|
153
|
+
selection_record = all_report_fields.get(SELECTION_FIELD)
|
|
154
|
+
if not isinstance(selection_record, Mapping):
|
|
155
|
+
return None
|
|
156
|
+
selected_path_text = selection_record.get(SELECTED_MANIFEST_PATH_FIELD)
|
|
157
|
+
if not isinstance(selected_path_text, str) or not selected_path_text.strip():
|
|
158
|
+
return None
|
|
159
|
+
return selected_path_text
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_inside_repository(candidate_path: Path, repository_root: Path) -> bool:
|
|
163
|
+
try:
|
|
164
|
+
candidate_path.relative_to(repository_root)
|
|
165
|
+
except ValueError:
|
|
166
|
+
return False
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _load_repository_identity(
|
|
171
|
+
repository_path: Path,
|
|
172
|
+
) -> tuple[Path, str | None, str | None] | None:
|
|
173
|
+
if not repository_path.is_dir():
|
|
174
|
+
return None
|
|
175
|
+
repository_root_text = _run_git_query(
|
|
176
|
+
repository_path,
|
|
177
|
+
ALL_GIT_REPOSITORY_ROOT_QUERY,
|
|
178
|
+
False,
|
|
179
|
+
)
|
|
180
|
+
if repository_root_text is None:
|
|
181
|
+
return None
|
|
182
|
+
repository_root = Path(repository_root_text).resolve()
|
|
183
|
+
return (
|
|
184
|
+
repository_root,
|
|
185
|
+
_run_git_query(repository_root, ALL_GIT_HEAD_QUERY, False),
|
|
186
|
+
_run_git_query(repository_root, ALL_GIT_ABSOLUTE_DIRECTORY_QUERY, False),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _run_git_query(
|
|
191
|
+
repository_root: Path,
|
|
192
|
+
all_git_arguments: tuple[str, ...],
|
|
193
|
+
should_allow_empty: bool,
|
|
194
|
+
) -> str | None:
|
|
195
|
+
try:
|
|
196
|
+
completed_process = subprocess.run(
|
|
197
|
+
[GIT_EXECUTABLE_NAME, *all_git_arguments],
|
|
198
|
+
cwd=repository_root,
|
|
199
|
+
check=False,
|
|
200
|
+
capture_output=True,
|
|
201
|
+
text=True,
|
|
202
|
+
encoding=GIT_OUTPUT_ENCODING_NAME,
|
|
203
|
+
errors=GIT_OUTPUT_DECODE_ERRORS_POLICY,
|
|
204
|
+
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
205
|
+
)
|
|
206
|
+
except (OSError, subprocess.SubprocessError, UnicodeError):
|
|
207
|
+
return None
|
|
208
|
+
if completed_process.returncode != GIT_COMMAND_SUCCESS_EXIT_CODE:
|
|
209
|
+
return None
|
|
210
|
+
stripped_stdout = completed_process.stdout.strip()
|
|
211
|
+
if stripped_stdout or should_allow_empty:
|
|
212
|
+
return stripped_stdout
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _extract_remote_path(remote_url: str) -> str:
|
|
217
|
+
if "://" in remote_url:
|
|
218
|
+
return urlsplit(remote_url).path
|
|
219
|
+
if "@" in remote_url and ":" in remote_url:
|
|
220
|
+
return remote_url.rsplit(":", 1)[1]
|
|
221
|
+
return remote_url
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _resolve_common_directory(
|
|
225
|
+
repository_root: Path,
|
|
226
|
+
common_directory_text: str,
|
|
227
|
+
) -> Path:
|
|
228
|
+
common_directory_path = Path(common_directory_text)
|
|
229
|
+
if common_directory_path.is_absolute():
|
|
230
|
+
return common_directory_path.resolve()
|
|
231
|
+
return (repository_root / common_directory_path).resolve()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _load_json_mapping(json_path: Path) -> Mapping[str, object] | None:
|
|
235
|
+
if not json_path.is_file():
|
|
236
|
+
return None
|
|
237
|
+
try:
|
|
238
|
+
parsed_json: object = json.loads(json_path.read_text(encoding=JSON_ENCODING))
|
|
239
|
+
except (OSError, UnicodeError, ValueError):
|
|
240
|
+
return None
|
|
241
|
+
if not isinstance(parsed_json, Mapping):
|
|
242
|
+
return None
|
|
243
|
+
return parsed_json
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _manifest_is_available(manifest_path: Path | None) -> bool:
|
|
247
|
+
if manifest_path is None:
|
|
248
|
+
return False
|
|
249
|
+
try:
|
|
250
|
+
load_manifest(manifest_path)
|
|
251
|
+
except (ManifestRunFatal, OSError, UnicodeError, ValueError):
|
|
252
|
+
return False
|
|
253
|
+
return True
|