claude-dev-env 2.2.1 → 2.4.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.
Files changed (170) hide show
  1. package/CLAUDE.md +3 -2
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  9. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  10. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  11. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  12. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  13. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  14. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  17. package/_shared/pr-loop/worker-spawn.md +1 -1
  18. package/agents/CLAUDE.md +1 -0
  19. package/agents/code-verifier.md +4 -4
  20. package/agents/skill-writer-agent.md +84 -0
  21. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  22. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  23. package/docs/CLAUDE.md +1 -0
  24. package/docs/references/CLAUDE.md +1 -0
  25. package/docs/references/code-review-enforcement.md +97 -0
  26. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  27. package/hooks/blocking/CLAUDE.md +8 -1
  28. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  29. package/hooks/blocking/code_review_gate_deny.py +74 -0
  30. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  31. package/hooks/blocking/code_review_push_gate.py +140 -0
  32. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  33. package/hooks/blocking/code_review_stamp_store.py +233 -0
  34. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  35. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  36. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  37. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  38. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  39. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  40. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  41. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  42. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  43. package/hooks/blocking/config/verified_commit_constants.py +24 -9
  44. package/hooks/blocking/conftest.py +2 -0
  45. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  46. package/hooks/blocking/destructive_command_blocker.py +19 -6
  47. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  48. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  49. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  50. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  51. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  52. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  53. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  54. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  55. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  56. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  57. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  58. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  59. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  60. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  61. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  62. package/hooks/blocking/test_verification_verdict_store.py +920 -810
  63. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  64. package/hooks/blocking/verification_verdict_store.py +118 -5
  65. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  66. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  67. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  68. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  69. package/hooks/git-hooks/pre_push.py +89 -2
  70. package/hooks/git-hooks/test_pre_push.py +103 -0
  71. package/hooks/hooks.json +16 -1
  72. package/hooks/hooks_constants/CLAUDE.md +1 -0
  73. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  74. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  75. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  76. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  77. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  78. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  79. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  80. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  81. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  83. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  84. package/hooks/validators/CLAUDE.md +1 -0
  85. package/hooks/validators/mypy_integration.py +63 -50
  86. package/hooks/validators/pyproject_config_discovery.py +101 -0
  87. package/hooks/validators/ruff_integration.py +211 -23
  88. package/hooks/validators/run_all_validators.py +21 -14
  89. package/hooks/validators/test_mypy_integration.py +32 -0
  90. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  91. package/hooks/validators/test_ruff_integration.py +68 -1
  92. package/hooks/validators/test_run_all_validators.py +25 -0
  93. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  94. package/package.json +1 -1
  95. package/rules/docstring-prose-matches-implementation.md +20 -43
  96. package/rules/durable-post-artifacts.md +7 -0
  97. package/scripts/CLAUDE.md +2 -1
  98. package/scripts/claude-chain.example.json +15 -3
  99. package/scripts/claude_chain_runner.py +130 -27
  100. package/scripts/claude_chain_usage.py +346 -0
  101. package/scripts/codec_forwarding_test_support.py +83 -0
  102. package/scripts/conftest.py +8 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  104. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
  105. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  106. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  107. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  108. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  109. package/scripts/invoke_code_review.py +550 -38
  110. package/scripts/resolve_worker_spawn.py +8 -1
  111. package/scripts/test_claude_chain_runner.py +412 -6
  112. package/scripts/test_claude_chain_usage.py +534 -0
  113. package/scripts/test_invoke_code_review.py +298 -4
  114. package/scripts/test_invoke_code_review_codec.py +77 -0
  115. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  116. package/skills/autoconverge/SKILL.md +9 -3
  117. package/skills/autoconverge/reference/convergence.md +2 -1
  118. package/skills/autoconverge/reference/multi-pr.md +6 -1
  119. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  120. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  121. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  123. package/skills/autoconverge/workflow/converge.mjs +24 -8
  124. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  125. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  126. package/skills/fresh-branch/CLAUDE.md +2 -0
  127. package/skills/fresh-branch/SKILL.md +2 -0
  128. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  129. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  131. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  132. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  133. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  134. package/skills/orchestrator/SKILL.md +1 -20
  135. package/skills/orchestrator-refresh/SKILL.md +1 -1
  136. package/skills/pr-converge/SKILL.md +3 -3
  137. package/skills/pr-converge/reference/examples.md +3 -3
  138. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  139. package/skills/pr-converge/reference/ground-rules.md +3 -3
  140. package/skills/pr-converge/reference/per-tick.md +5 -5
  141. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  142. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  143. package/skills/prototype/SKILL.md +86 -0
  144. package/skills/prototype/reference/honest-limitations.md +23 -0
  145. package/skills/prototype/reference/promotion-tasks.md +23 -0
  146. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  147. package/skills/prototype/scripts/conftest.py +15 -0
  148. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  149. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  150. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  151. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  152. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  153. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  154. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  155. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  156. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  157. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  158. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  159. package/skills/prototype/workflows/promotion.md +27 -0
  160. package/skills/prototype/workflows/sandbox.md +35 -0
  161. package/skills/skill-builder/CLAUDE.md +3 -3
  162. package/skills/skill-builder/SKILL.md +5 -5
  163. package/skills/skill-builder/references/CLAUDE.md +1 -1
  164. package/skills/skill-builder/references/delegation-map.md +3 -3
  165. package/skills/skill-builder/references/description-field.md +1 -1
  166. package/skills/skill-builder/references/skill-modularity.md +2 -3
  167. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  168. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  169. package/skills/skill-builder/workflows/new-skill.md +2 -2
  170. package/skills/team-advisor/SKILL.md +5 -4
@@ -8,9 +8,9 @@
8
8
  "base_commit": "abc...", "agent": "claude", "repo_root": "..."}
9
9
 
10
10
  Never runs ``git checkout -b`` in the caller's working tree. Fetches the base
11
- ref, then ``git worktree add -b`` into ``Temp/<agent>/<branch>`` (Windows) or
12
- ``gettempdir()/<agent>/<branch>`` elsewhere. Exit 0 prints success JSON; any
13
- failure prints ``{"error": ...}`` and exits non-zero.
11
+ ref, then ``git worktree add -b --no-track`` into ``Temp/<agent>/<branch>``
12
+ (Windows) or ``gettempdir()/<agent>/<branch>`` elsewhere. Exit 0 prints success
13
+ JSON; any failure prints ``{"error": ...}`` and exits non-zero.
14
14
  """
15
15
 
16
16
  from __future__ import annotations
@@ -19,11 +19,17 @@ import argparse
19
19
  import json
20
20
  import os
21
21
  import re
22
- import subprocess
23
22
  import sys
24
23
  import tempfile
25
24
  from pathlib import Path
26
25
 
26
+ from fresh_branch_git_commands import (
27
+ assert_git_accepts_branch_name,
28
+ create_worktree_branch,
29
+ fetch_base_ref,
30
+ resolve_base_commit,
31
+ resolve_repo_root,
32
+ )
27
33
  from fresh_branch_scripts_constants.fresh_branch_cli_constants import (
28
34
  AGENT_SLUG_PATTERN,
29
35
  ALL_AGENT_DETECTION_MARKERS,
@@ -31,34 +37,15 @@ from fresh_branch_scripts_constants.fresh_branch_cli_constants import (
31
37
  DEFAULT_AGENT_SLUG,
32
38
  DEFAULT_BASE_REF,
33
39
  ERROR_AGENT_SLUG_INVALID,
34
- ERROR_BASE_COMMIT_LOOKUP,
35
- ERROR_BASE_REF_MISSING,
36
40
  ERROR_BRANCH_NAME_REQUIRED,
37
41
  ERROR_BRANCH_NAME_UNSAFE,
38
42
  ERROR_CLI_ARGUMENTS,
39
- ERROR_FETCH_FAILED,
40
- ERROR_REPO_NOT_GIT,
41
43
  ERROR_UNIQUE_PATH_EXHAUSTED,
42
- ERROR_WORKTREE_FAILED,
43
44
  ERROR_WORKTREE_PATH_OUTSIDE_ROOT,
44
45
  EXIT_CODE_FAILURE,
45
46
  EXIT_CODE_SUCCESS,
46
47
  FRESH_BRANCH_AGENT_ENV_VAR,
47
- GIT_BRANCH_FLAG,
48
- GIT_BRANCH_FORMAT_FLAG,
49
- GIT_CHECK_REF_FORMAT,
50
- GIT_COMMAND,
51
- GIT_FETCH,
52
- GIT_QUIET_FLAG,
53
- GIT_REFS_REMOTES_PREFIX,
54
- GIT_REMOTE_NAME_ORIGIN,
55
48
  GIT_REMOTE_PREFIX,
56
- GIT_REV_PARSE,
57
- GIT_SHOW_REF,
58
- GIT_SHOW_TOPLEVEL,
59
- GIT_VERIFY_FLAG,
60
- GIT_WORKTREE,
61
- GIT_WORKTREE_ADD,
62
49
  MAXIMUM_UNIQUE_PATH_ATTEMPTS,
63
50
  PATH_SEGMENT_CURRENT,
64
51
  PATH_SEGMENT_PARENT,
@@ -80,13 +67,7 @@ def resolve_agent_slug(maybe_flag_agent: str | None) -> str:
80
67
 
81
68
  ::
82
69
 
83
- resolve_agent_slug("Grok") # ok: "grok" (flag wins)
84
- # FRESH_BRANCH_AGENT=codex, no flag -> "codex"
85
- # CURSOR_TRACE_ID set, no flag/env -> "cursor"
86
- # no flag, env, or markers -> "claude"
87
-
88
- Order: ``--agent`` flag, then ``FRESH_BRANCH_AGENT``, then the first set
89
- marker in ``ALL_AGENT_DETECTION_MARKERS``, else ``DEFAULT_AGENT_SLUG``.
70
+ resolve_agent_slug("Grok") # ok: "grok"
90
71
 
91
72
  Args:
92
73
  maybe_flag_agent: Explicit ``--agent`` value, or None.
@@ -102,6 +83,10 @@ def resolve_agent_slug(maybe_flag_agent: str | None) -> str:
102
83
  explicit_agent = os.environ.get(FRESH_BRANCH_AGENT_ENV_VAR)
103
84
  if explicit_agent is not None and explicit_agent.strip():
104
85
  return _normalize_agent_slug(explicit_agent)
86
+ return _detect_agent_slug_from_environment()
87
+
88
+
89
+ def _detect_agent_slug_from_environment() -> str:
105
90
  for each_env_name, each_agent_slug in ALL_AGENT_DETECTION_MARKERS:
106
91
  maybe_marker = os.environ.get(each_env_name)
107
92
  if maybe_marker is not None and str(maybe_marker).strip():
@@ -171,9 +156,6 @@ def normalize_base_ref(base_ref: str) -> str:
171
156
  normalize_base_ref("origin/main") # ok: "origin/main"
172
157
  normalize_base_ref("upstream/dev") # ok: "upstream/dev"
173
158
 
174
- Bare names expand to ``origin/<name>`` so worktree start-points use the
175
- remote-tracking tip that ``git fetch`` updates, not a stale local branch.
176
-
177
159
  Args:
178
160
  base_ref: User-supplied base ref or bare branch name.
179
161
 
@@ -194,61 +176,39 @@ def create_fresh_branch(
194
176
  agent_slug: str,
195
177
  base_ref: str,
196
178
  ) -> dict[str, str]:
197
- """Fetch base_ref and create an isolated worktree branch.
179
+ """Fetch base_ref and create an isolated worktree branch with no upstream.
198
180
 
199
181
  ::
200
182
 
201
183
  create_fresh_branch("fix/x", Path("."), "grok", "origin/main")
202
- # ok: worktree at Temp/grok/fix/x; source checkout stays on its branch
203
-
204
- Args:
205
- branch_name: New branch to create.
206
- repo_path: Path inside the source repository.
207
- agent_slug: Host label for the scratch root segment.
208
- base_ref: Base ref (default ``origin/main``).
209
-
210
- Returns:
211
- Success payload mapping.
212
-
213
- Raises:
214
- ValueError: When branch_name is empty or path-unsafe, or agent_slug
215
- fails the path-safety pattern.
216
- RuntimeError: On git or path failures.
217
184
  """
218
- cleaned_branch = branch_name.strip()
219
- if not cleaned_branch:
220
- raise ValueError(ERROR_BRANCH_NAME_REQUIRED)
221
- _validate_branch_name_for_worktree_path(cleaned_branch)
185
+ cleaned_branch = _require_safe_branch_name(branch_name)
222
186
  normalized_agent_slug = _normalize_agent_slug(agent_slug)
223
- resolved_base_ref = normalize_base_ref(base_ref)
224
- repo_root = _resolve_repo_root(repo_path)
225
- _fetch_base_ref(repo_root, resolved_base_ref)
226
- base_commit = _resolve_base_commit(repo_root, resolved_base_ref)
227
- agent_worktree_root = resolve_agent_worktree_root(normalized_agent_slug)
228
- preferred_path = agent_worktree_root / cleaned_branch
229
- _assert_path_is_under_agent_root(
230
- candidate_path=preferred_path,
231
- agent_worktree_root=agent_worktree_root,
232
- )
233
- worktree_path = resolve_unique_worktree_path(preferred_path)
234
- _assert_path_is_under_agent_root(
235
- candidate_path=worktree_path,
236
- agent_worktree_root=agent_worktree_root,
187
+ resolved_base_ref, repo_root, base_commit = _resolve_branch_base(
188
+ repo_path, base_ref,
237
189
  )
238
- _create_worktree_branch(
190
+ worktree_path = _allocate_worktree_path(cleaned_branch, normalized_agent_slug)
191
+ create_worktree_branch(
239
192
  repo_root,
240
193
  branch_name=cleaned_branch,
241
194
  worktree_path=worktree_path,
242
195
  base_ref=resolved_base_ref,
243
196
  )
244
- return {
245
- PAYLOAD_KEY_BRANCH: cleaned_branch,
246
- PAYLOAD_KEY_WORKTREE_PATH: str(worktree_path.resolve()),
247
- PAYLOAD_KEY_BASE_REF: resolved_base_ref,
248
- PAYLOAD_KEY_BASE_COMMIT: base_commit,
249
- PAYLOAD_KEY_AGENT: normalized_agent_slug,
250
- PAYLOAD_KEY_REPO_ROOT: str(repo_root),
251
- }
197
+ return _build_success_payload(
198
+ cleaned_branch, worktree_path, resolved_base_ref, base_commit,
199
+ normalized_agent_slug, repo_root,
200
+ )
201
+
202
+
203
+ def _resolve_branch_base(
204
+ repo_path: Path,
205
+ base_ref: str,
206
+ ) -> tuple[str, Path, str]:
207
+ resolved_base_ref = normalize_base_ref(base_ref)
208
+ repo_root = resolve_repo_root(repo_path)
209
+ fetch_base_ref(repo_root, resolved_base_ref)
210
+ base_commit = resolve_base_commit(repo_root, resolved_base_ref)
211
+ return resolved_base_ref, repo_root, base_commit
252
212
 
253
213
 
254
214
  def main() -> int:
@@ -273,6 +233,47 @@ def main() -> int:
273
233
  return EXIT_CODE_FAILURE
274
234
 
275
235
 
236
+ def _require_safe_branch_name(branch_name: str) -> str:
237
+ cleaned_branch = branch_name.strip()
238
+ if not cleaned_branch:
239
+ raise ValueError(ERROR_BRANCH_NAME_REQUIRED)
240
+ _validate_branch_name_for_worktree_path(cleaned_branch)
241
+ return cleaned_branch
242
+
243
+
244
+ def _allocate_worktree_path(branch_name: str, agent_slug: str) -> Path:
245
+ agent_worktree_root = resolve_agent_worktree_root(agent_slug)
246
+ preferred_path = agent_worktree_root / branch_name
247
+ _assert_path_is_under_agent_root(
248
+ candidate_path=preferred_path,
249
+ agent_worktree_root=agent_worktree_root,
250
+ )
251
+ worktree_path = resolve_unique_worktree_path(preferred_path)
252
+ _assert_path_is_under_agent_root(
253
+ candidate_path=worktree_path,
254
+ agent_worktree_root=agent_worktree_root,
255
+ )
256
+ return worktree_path
257
+
258
+
259
+ def _build_success_payload(
260
+ branch_name: str,
261
+ worktree_path: Path,
262
+ base_ref: str,
263
+ base_commit: str,
264
+ agent_slug: str,
265
+ repo_root: Path,
266
+ ) -> dict[str, str]:
267
+ return {
268
+ PAYLOAD_KEY_BRANCH: branch_name,
269
+ PAYLOAD_KEY_WORKTREE_PATH: str(worktree_path.resolve()),
270
+ PAYLOAD_KEY_BASE_REF: base_ref,
271
+ PAYLOAD_KEY_BASE_COMMIT: base_commit,
272
+ PAYLOAD_KEY_AGENT: agent_slug,
273
+ PAYLOAD_KEY_REPO_ROOT: str(repo_root),
274
+ }
275
+
276
+
276
277
  def _normalize_agent_slug(raw_agent: str) -> str:
277
278
  agent_slug = raw_agent.strip().lower()
278
279
  if re.fullmatch(AGENT_SLUG_PATTERN, agent_slug) is None:
@@ -287,16 +288,7 @@ def _validate_branch_name_for_worktree_path(branch_name: str) -> None:
287
288
  for each_segment in branch_as_path.parts:
288
289
  if each_segment in (PATH_SEGMENT_CURRENT, PATH_SEGMENT_PARENT):
289
290
  raise ValueError(ERROR_BRANCH_NAME_UNSAFE)
290
- _assert_git_accepts_branch_name(branch_name)
291
-
292
-
293
- def _assert_git_accepts_branch_name(branch_name: str) -> None:
294
- completed = _run_git(
295
- [GIT_CHECK_REF_FORMAT, GIT_BRANCH_FORMAT_FLAG, branch_name],
296
- working_directory=Path.cwd(),
297
- )
298
- if completed.returncode != 0:
299
- raise ValueError(ERROR_BRANCH_NAME_UNSAFE)
291
+ assert_git_accepts_branch_name(branch_name)
300
292
 
301
293
 
302
294
  def _assert_path_is_under_agent_root(
@@ -315,63 +307,6 @@ def _assert_path_is_under_agent_root(
315
307
  ) from error
316
308
 
317
309
 
318
- def _resolve_repo_root(repo_path: Path) -> Path:
319
- completed = _run_git(
320
- [GIT_REV_PARSE, GIT_SHOW_TOPLEVEL],
321
- working_directory=repo_path,
322
- )
323
- if completed.returncode != 0:
324
- raise RuntimeError(ERROR_REPO_NOT_GIT % repo_path)
325
- return Path(completed.stdout.strip())
326
-
327
-
328
- def _fetch_base_ref(repo_root: Path, base_ref: str) -> None:
329
- remote_name, remote_branch = _split_remote_ref(base_ref)
330
- completed = _run_git(
331
- [GIT_FETCH, remote_name, remote_branch],
332
- working_directory=repo_root,
333
- )
334
- if completed.returncode != 0:
335
- stderr_text = completed.stderr.strip() or completed.stdout.strip()
336
- raise RuntimeError(ERROR_FETCH_FAILED % (base_ref, stderr_text))
337
- if not _is_ref_present(repo_root, base_ref):
338
- raise RuntimeError(ERROR_BASE_REF_MISSING % base_ref)
339
-
340
-
341
- def _resolve_base_commit(repo_root: Path, base_ref: str) -> str:
342
- completed = _run_git(
343
- [GIT_REV_PARSE, base_ref],
344
- working_directory=repo_root,
345
- )
346
- if completed.returncode != 0:
347
- stderr_text = completed.stderr.strip() or completed.stdout.strip()
348
- raise RuntimeError(ERROR_BASE_COMMIT_LOOKUP % (base_ref, stderr_text))
349
- return completed.stdout.strip()
350
-
351
-
352
- def _create_worktree_branch(
353
- repo_root: Path,
354
- branch_name: str,
355
- worktree_path: Path,
356
- base_ref: str,
357
- ) -> None:
358
- worktree_path.parent.mkdir(parents=True, exist_ok=True)
359
- completed = _run_git(
360
- [
361
- GIT_WORKTREE,
362
- GIT_WORKTREE_ADD,
363
- GIT_BRANCH_FLAG,
364
- branch_name,
365
- str(worktree_path),
366
- base_ref,
367
- ],
368
- working_directory=repo_root,
369
- )
370
- if completed.returncode != 0:
371
- stderr_text = completed.stderr.strip() or completed.stdout.strip()
372
- raise RuntimeError(ERROR_WORKTREE_FAILED % stderr_text)
373
-
374
-
375
310
  def _parse_arguments() -> argparse.Namespace:
376
311
  parser = argparse.ArgumentParser(
377
312
  description="Create a fresh branch in an isolated git worktree.",
@@ -404,42 +339,5 @@ def _parse_arguments() -> argparse.Namespace:
404
339
  raise ValueError(ERROR_CLI_ARGUMENTS) from exit_error
405
340
 
406
341
 
407
- def _split_remote_ref(base_ref: str) -> tuple[str, str]:
408
- if base_ref.startswith(GIT_REMOTE_PREFIX):
409
- return GIT_REMOTE_NAME_ORIGIN, base_ref[len(GIT_REMOTE_PREFIX) :]
410
- if "/" in base_ref:
411
- remote_name, remote_branch = base_ref.split("/", 1)
412
- return remote_name, remote_branch
413
- return GIT_REMOTE_NAME_ORIGIN, base_ref
414
-
415
-
416
- def _is_ref_present(repo_root: Path, base_ref: str) -> bool:
417
- remote_ref = f"{GIT_REFS_REMOTES_PREFIX}{base_ref}"
418
- completed = _run_git(
419
- [GIT_SHOW_REF, GIT_VERIFY_FLAG, GIT_QUIET_FLAG, remote_ref],
420
- working_directory=repo_root,
421
- )
422
- if completed.returncode == 0:
423
- return True
424
- completed = _run_git(
425
- [GIT_REV_PARSE, GIT_VERIFY_FLAG, GIT_QUIET_FLAG, base_ref],
426
- working_directory=repo_root,
427
- )
428
- return completed.returncode == 0
429
-
430
-
431
- def _run_git(
432
- all_git_arguments: list[str],
433
- working_directory: Path,
434
- ) -> subprocess.CompletedProcess[str]:
435
- return subprocess.run(
436
- [GIT_COMMAND, *all_git_arguments],
437
- cwd=str(working_directory),
438
- check=False,
439
- capture_output=True,
440
- text=True,
441
- )
442
-
443
-
444
342
  if __name__ == "__main__":
445
343
  sys.exit(main())
@@ -0,0 +1,285 @@
1
+ """Git commands behind the fresh-branch worktree creator.
2
+
3
+ ::
4
+
5
+ create_worktree_branch(repo_root, "fix/x", worktree_path, "origin/main")
6
+ # ok: git worktree add -b fix/x --no-track <path> origin/main
7
+ # flag: git worktree add -b fix/x <path> origin/main
8
+
9
+ Git auto-tracks a remote-tracking start point, leaving the new branch with
10
+ ``branch.<name>.merge = refs/heads/main``. Under ``push.default = upstream`` a
11
+ bare ``git push`` from that branch fast-forwards straight onto main, so every
12
+ worktree here is created with ``GIT_NO_TRACK_FLAG``.
13
+
14
+ The fetch, resolve, and worktree-add commands raise on failure. ``run_git``
15
+ returns the completed process without raising, and ``is_ref_present`` reads its
16
+ return code to answer a bool.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ from fresh_branch_scripts_constants.fresh_branch_cli_constants import (
25
+ ERROR_BASE_COMMIT_LOOKUP,
26
+ ERROR_BASE_REF_MISSING,
27
+ ERROR_BRANCH_NAME_UNSAFE,
28
+ ERROR_FETCH_FAILED,
29
+ ERROR_REPO_NOT_GIT,
30
+ ERROR_WORKTREE_FAILED,
31
+ GIT_BRANCH_FLAG,
32
+ GIT_BRANCH_FORMAT_FLAG,
33
+ GIT_CHECK_REF_FORMAT,
34
+ GIT_COMMAND,
35
+ GIT_FETCH,
36
+ GIT_NO_TRACK_FLAG,
37
+ GIT_QUIET_FLAG,
38
+ GIT_REFS_REMOTES_PREFIX,
39
+ GIT_REMOTE_NAME_ORIGIN,
40
+ GIT_REMOTE_PREFIX,
41
+ GIT_REV_PARSE,
42
+ GIT_SHOW_REF,
43
+ GIT_SHOW_TOPLEVEL,
44
+ GIT_VERIFY_FLAG,
45
+ GIT_WORKTREE,
46
+ GIT_WORKTREE_ADD,
47
+ )
48
+
49
+
50
+ def run_git(
51
+ all_git_arguments: list[str],
52
+ working_directory: Path,
53
+ ) -> subprocess.CompletedProcess[str]:
54
+ """Run one git command and capture its output without raising.
55
+
56
+ Args:
57
+ all_git_arguments: Arguments that follow the ``git`` executable.
58
+ working_directory: Directory the command runs in.
59
+
60
+ Returns:
61
+ The completed process, carrying return code and captured text.
62
+ """
63
+ return subprocess.run(
64
+ [GIT_COMMAND, *all_git_arguments],
65
+ cwd=str(working_directory),
66
+ check=False,
67
+ capture_output=True,
68
+ text=True,
69
+ )
70
+
71
+
72
+ def assert_git_accepts_branch_name(branch_name: str) -> None:
73
+ """Raise when git rejects branch_name as a branch ref format.
74
+
75
+ ::
76
+
77
+ assert_git_accepts_branch_name("fix/x") # ok: returns None
78
+ assert_git_accepts_branch_name("fix..x") # flag: ValueError
79
+
80
+ Args:
81
+ branch_name: Candidate branch name.
82
+
83
+ Raises:
84
+ ValueError: When ``git check-ref-format`` rejects the name.
85
+ """
86
+ completed = run_git(
87
+ [GIT_CHECK_REF_FORMAT, GIT_BRANCH_FORMAT_FLAG, branch_name],
88
+ working_directory=Path.cwd(),
89
+ )
90
+ if completed.returncode != 0:
91
+ raise ValueError(ERROR_BRANCH_NAME_UNSAFE)
92
+
93
+
94
+ def resolve_repo_root(repo_path: Path) -> Path:
95
+ """Return the git toplevel directory holding repo_path.
96
+
97
+ Args:
98
+ repo_path: Any path inside the source repository.
99
+
100
+ Returns:
101
+ The repository root directory.
102
+
103
+ Raises:
104
+ RuntimeError: When repo_path is not inside a git repository.
105
+ """
106
+ completed = run_git(
107
+ [GIT_REV_PARSE, GIT_SHOW_TOPLEVEL],
108
+ working_directory=repo_path,
109
+ )
110
+ if completed.returncode != 0:
111
+ raise RuntimeError(ERROR_REPO_NOT_GIT % repo_path)
112
+ return Path(completed.stdout.strip())
113
+
114
+
115
+ def fetch_base_ref(repo_root: Path, base_ref: str) -> None:
116
+ """Fetch base_ref into repo_root and confirm the ref then exists.
117
+
118
+ Args:
119
+ repo_root: Source repository root.
120
+ base_ref: Ref to fetch, such as ``origin/main``.
121
+
122
+ Raises:
123
+ RuntimeError: When the fetch fails or the ref is still absent.
124
+ """
125
+ remote_name, remote_branch = split_remote_ref(base_ref)
126
+ completed = run_git(
127
+ [GIT_FETCH, remote_name, remote_branch],
128
+ working_directory=repo_root,
129
+ )
130
+ if completed.returncode != 0:
131
+ raise RuntimeError(
132
+ ERROR_FETCH_FAILED % (base_ref, read_failure_text(completed)),
133
+ )
134
+ if not is_ref_present(repo_root, base_ref):
135
+ raise RuntimeError(ERROR_BASE_REF_MISSING % base_ref)
136
+
137
+
138
+ def resolve_base_commit(repo_root: Path, base_ref: str) -> str:
139
+ """Return the commit SHA that base_ref points at.
140
+
141
+ Args:
142
+ repo_root: Source repository root.
143
+ base_ref: Ref to resolve, such as ``origin/main``.
144
+
145
+ Returns:
146
+ The full commit SHA at base_ref.
147
+
148
+ Raises:
149
+ RuntimeError: When git cannot resolve the ref to a commit.
150
+ """
151
+ completed = run_git(
152
+ [GIT_REV_PARSE, base_ref],
153
+ working_directory=repo_root,
154
+ )
155
+ if completed.returncode != 0:
156
+ raise RuntimeError(
157
+ ERROR_BASE_COMMIT_LOOKUP % (base_ref, read_failure_text(completed)),
158
+ )
159
+ return completed.stdout.strip()
160
+
161
+
162
+ def create_worktree_branch(
163
+ repo_root: Path,
164
+ branch_name: str,
165
+ worktree_path: Path,
166
+ base_ref: str,
167
+ ) -> None:
168
+ """Create branch_name at base_ref in a new worktree, with no upstream.
169
+
170
+ ::
171
+
172
+ create_worktree_branch(repo_root, "fix/x", path, "origin/main")
173
+ # ok: branch.fix/x.merge is unset
174
+ # flag: branch.fix/x.merge = refs/heads/main (auto-tracked)
175
+
176
+ Args:
177
+ repo_root: Source repository root.
178
+ branch_name: New branch to create.
179
+ worktree_path: Directory that will hold the new worktree.
180
+ base_ref: Start point for the new branch.
181
+
182
+ Raises:
183
+ RuntimeError: When ``git worktree add`` fails.
184
+ """
185
+ worktree_path.parent.mkdir(parents=True, exist_ok=True)
186
+ completed = run_git(
187
+ build_worktree_add_arguments(branch_name, worktree_path, base_ref),
188
+ working_directory=repo_root,
189
+ )
190
+ if completed.returncode != 0:
191
+ raise RuntimeError(ERROR_WORKTREE_FAILED % read_failure_text(completed))
192
+
193
+
194
+ def build_worktree_add_arguments(
195
+ branch_name: str,
196
+ worktree_path: Path,
197
+ base_ref: str,
198
+ ) -> list[str]:
199
+ """Return the ``git worktree add`` argument list, including --no-track.
200
+
201
+ ::
202
+
203
+ build_worktree_add_arguments("fix/x", Path("/tmp/x"), "origin/main")
204
+ # ok: ["worktree", "add", "-b", "fix/x", "--no-track", "/tmp/x",
205
+ # "origin/main"]
206
+
207
+ Args:
208
+ branch_name: New branch to create.
209
+ worktree_path: Directory that will hold the new worktree.
210
+ base_ref: Start point for the new branch.
211
+
212
+ Returns:
213
+ Arguments to pass to ``run_git``.
214
+ """
215
+ return [
216
+ GIT_WORKTREE,
217
+ GIT_WORKTREE_ADD,
218
+ GIT_BRANCH_FLAG,
219
+ branch_name,
220
+ GIT_NO_TRACK_FLAG,
221
+ str(worktree_path),
222
+ base_ref,
223
+ ]
224
+
225
+
226
+ def split_remote_ref(base_ref: str) -> tuple[str, str]:
227
+ """Split base_ref into its remote name and branch name.
228
+
229
+ ::
230
+
231
+ split_remote_ref("origin/main") # ok: ("origin", "main")
232
+ split_remote_ref("upstream/dev") # ok: ("upstream", "dev")
233
+ split_remote_ref("main") # ok: ("origin", "main")
234
+
235
+ Args:
236
+ base_ref: Base ref or bare branch name.
237
+
238
+ Returns:
239
+ The remote name and the branch name to fetch.
240
+ """
241
+ if base_ref.startswith(GIT_REMOTE_PREFIX):
242
+ return GIT_REMOTE_NAME_ORIGIN, base_ref[len(GIT_REMOTE_PREFIX) :]
243
+ if "/" in base_ref:
244
+ remote_name, remote_branch = base_ref.split("/", 1)
245
+ return remote_name, remote_branch
246
+ return GIT_REMOTE_NAME_ORIGIN, base_ref
247
+
248
+
249
+ def is_ref_present(repo_root: Path, base_ref: str) -> bool:
250
+ """Report whether base_ref resolves in repo_root.
251
+
252
+ Checks the remote-tracking ref first, then base_ref as written, so both
253
+ ``origin/main`` and a local tag or branch answer correctly.
254
+
255
+ Args:
256
+ repo_root: Source repository root.
257
+ base_ref: Ref to look for.
258
+
259
+ Returns:
260
+ True when git resolves the ref.
261
+ """
262
+ remote_ref = f"{GIT_REFS_REMOTES_PREFIX}{base_ref}"
263
+ completed = run_git(
264
+ [GIT_SHOW_REF, GIT_VERIFY_FLAG, GIT_QUIET_FLAG, remote_ref],
265
+ working_directory=repo_root,
266
+ )
267
+ if completed.returncode == 0:
268
+ return True
269
+ completed = run_git(
270
+ [GIT_REV_PARSE, GIT_VERIFY_FLAG, GIT_QUIET_FLAG, base_ref],
271
+ working_directory=repo_root,
272
+ )
273
+ return completed.returncode == 0
274
+
275
+
276
+ def read_failure_text(completed: subprocess.CompletedProcess[str]) -> str:
277
+ """Return the message a failed git command left behind.
278
+
279
+ Args:
280
+ completed: A completed git process with a non-zero return code.
281
+
282
+ Returns:
283
+ Trimmed stderr, falling back to trimmed stdout.
284
+ """
285
+ return completed.stderr.strip() or completed.stdout.strip()
@@ -48,6 +48,7 @@ GIT_SHOW_REF = "show-ref"
48
48
  GIT_WORKTREE = "worktree"
49
49
  GIT_WORKTREE_ADD = "add"
50
50
  GIT_BRANCH_FLAG = "-b"
51
+ GIT_NO_TRACK_FLAG = "--no-track"
51
52
  GIT_VERIFY_FLAG = "--verify"
52
53
  GIT_QUIET_FLAG = "--quiet"
53
54
  GIT_SHOW_TOPLEVEL = "--show-toplevel"
@@ -0,0 +1,4 @@
1
+ [pytest]
2
+ python_files = test_*.py
3
+ python_classes = Test*
4
+ python_functions = test_* should_*