shikiru-cli 0.2.0__py3-none-any.whl

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 (319) hide show
  1. shikiru/__init__.py +1 -0
  2. shikiru/__main__.py +8 -0
  3. shikiru/agents/__init__.py +3 -0
  4. shikiru/agents/contracts.py +590 -0
  5. shikiru/agents/registry.py +87 -0
  6. shikiru/agents/sdk/__init__.py +17 -0
  7. shikiru/agents/sdk/reset_time.py +137 -0
  8. shikiru/agents/sdk/turn.py +335 -0
  9. shikiru/agents/settings.py +151 -0
  10. shikiru/agents/testing/__init__.py +26 -0
  11. shikiru/agents/testing/contract_suite.py +653 -0
  12. shikiru/agents/testing/resume.py +15 -0
  13. shikiru/bootstrap.py +1277 -0
  14. shikiru/cli/__init__.py +1 -0
  15. shikiru/cli/app.py +770 -0
  16. shikiru/cli/context.py +63 -0
  17. shikiru/cli/render.py +1475 -0
  18. shikiru/cli/report.py +156 -0
  19. shikiru/cli/session_prompt.py +63 -0
  20. shikiru/cli/view.py +131 -0
  21. shikiru/clock.py +41 -0
  22. shikiru/config/__init__.py +8 -0
  23. shikiru/config/defaults.toml +131 -0
  24. shikiru/config/loader.py +178 -0
  25. shikiru/config/settings.py +392 -0
  26. shikiru/dashboard/__init__.py +3 -0
  27. shikiru/dashboard/actions_list.py +799 -0
  28. shikiru/dashboard/activity.py +150 -0
  29. shikiru/dashboard/app.py +1765 -0
  30. shikiru/dashboard/blocker_evidence.py +122 -0
  31. shikiru/dashboard/blocker_rows.py +84 -0
  32. shikiru/dashboard/briefing_view.py +871 -0
  33. shikiru/dashboard/contract.py +1086 -0
  34. shikiru/dashboard/covered.py +32 -0
  35. shikiru/dashboard/detail.py +1209 -0
  36. shikiru/dashboard/detail_fold.py +244 -0
  37. shikiru/dashboard/detail_hint.py +68 -0
  38. shikiru/dashboard/details_list_view.py +150 -0
  39. shikiru/dashboard/diagnostics.py +80 -0
  40. shikiru/dashboard/diagnostics_sections.py +186 -0
  41. shikiru/dashboard/evidence_table.py +123 -0
  42. shikiru/dashboard/extra_details.py +214 -0
  43. shikiru/dashboard/fault_guidance.py +216 -0
  44. shikiru/dashboard/fold_bodies.py +276 -0
  45. shikiru/dashboard/help_keys.py +166 -0
  46. shikiru/dashboard/help_screen.py +204 -0
  47. shikiru/dashboard/interaction.py +17 -0
  48. shikiru/dashboard/ladder_editor.py +544 -0
  49. shikiru/dashboard/live_settings.py +62 -0
  50. shikiru/dashboard/motion.py +368 -0
  51. shikiru/dashboard/orchestration_fields.py +201 -0
  52. shikiru/dashboard/palette.py +1606 -0
  53. shikiru/dashboard/palette_app.py +89 -0
  54. shikiru/dashboard/palette_drafts.py +33 -0
  55. shikiru/dashboard/resolution.py +573 -0
  56. shikiru/dashboard/resolution_view.py +302 -0
  57. shikiru/dashboard/row_list.py +256 -0
  58. shikiru/dashboard/section_rows.py +173 -0
  59. shikiru/dashboard/services.py +665 -0
  60. shikiru/dashboard/session_history.py +402 -0
  61. shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
  62. shikiru/dashboard/task_picker.py +358 -0
  63. shikiru/dashboard/tick.py +59 -0
  64. shikiru/dashboard/timeline_filter.py +129 -0
  65. shikiru/dashboard/timeline_section.py +385 -0
  66. shikiru/dashboard/usage_body.py +269 -0
  67. shikiru/dashboard/views.py +2314 -0
  68. shikiru/dashboard/wizard.py +1389 -0
  69. shikiru/domain/__init__.py +1 -0
  70. shikiru/domain/acceptance.py +130 -0
  71. shikiru/domain/activity.py +28 -0
  72. shikiru/domain/attempts.py +333 -0
  73. shikiru/domain/briefing.py +257 -0
  74. shikiru/domain/commands.py +219 -0
  75. shikiru/domain/decisions.py +231 -0
  76. shikiru/domain/docker.py +262 -0
  77. shikiru/domain/engine_uptime.py +87 -0
  78. shikiru/domain/escalation.py +194 -0
  79. shikiru/domain/escalation_evidence.py +186 -0
  80. shikiru/domain/escalation_panel.py +144 -0
  81. shikiru/domain/events.py +71 -0
  82. shikiru/domain/evidence_export.py +89 -0
  83. shikiru/domain/faults.py +86 -0
  84. shikiru/domain/feedback.py +180 -0
  85. shikiru/domain/feedback_rounds.py +172 -0
  86. shikiru/domain/fencing.py +31 -0
  87. shikiru/domain/ladder.py +102 -0
  88. shikiru/domain/ladder_text.py +44 -0
  89. shikiru/domain/levels.py +44 -0
  90. shikiru/domain/motion.py +199 -0
  91. shikiru/domain/packet_evidence.py +377 -0
  92. shikiru/domain/pushes.py +47 -0
  93. shikiru/domain/quota_blocks.py +44 -0
  94. shikiru/domain/quota_waits.py +219 -0
  95. shikiru/domain/recovery.py +194 -0
  96. shikiru/domain/reports.py +498 -0
  97. shikiru/domain/results.py +217 -0
  98. shikiru/domain/runs.py +85 -0
  99. shikiru/domain/scheduling.py +231 -0
  100. shikiru/domain/status_tasks.py +40 -0
  101. shikiru/domain/steps.py +325 -0
  102. shikiru/domain/tasks.py +174 -0
  103. shikiru/domain/timeline.py +208 -0
  104. shikiru/domain/timeline_phrases.py +271 -0
  105. shikiru/domain/versions.py +18 -0
  106. shikiru/domain/workflow.py +372 -0
  107. shikiru/engine/__init__.py +0 -0
  108. shikiru/engine/acceptance.py +90 -0
  109. shikiru/engine/agents.py +61 -0
  110. shikiru/engine/cosmetic_decorator.py +99 -0
  111. shikiru/engine/daemon.py +409 -0
  112. shikiru/engine/dependency.py +37 -0
  113. shikiru/engine/escalation_filing.py +110 -0
  114. shikiru/engine/escalations.py +401 -0
  115. shikiru/engine/events.py +285 -0
  116. shikiru/engine/evidence.py +81 -0
  117. shikiru/engine/fault_reporter.py +144 -0
  118. shikiru/engine/feedback.py +577 -0
  119. shikiru/engine/feedback_audit.py +80 -0
  120. shikiru/engine/feedback_intake.py +95 -0
  121. shikiru/engine/feedback_snapshot.py +148 -0
  122. shikiru/engine/flow.py +87 -0
  123. shikiru/engine/handlers/__init__.py +33 -0
  124. shikiru/engine/handlers/base.py +148 -0
  125. shikiru/engine/handlers/engine.py +24 -0
  126. shikiru/engine/handlers/escalations.py +174 -0
  127. shikiru/engine/handlers/operator.py +81 -0
  128. shikiru/engine/handlers/tasks.py +237 -0
  129. shikiru/engine/history.py +128 -0
  130. shikiru/engine/integration.py +1234 -0
  131. shikiru/engine/integration_turns.py +337 -0
  132. shikiru/engine/layout_guard.py +362 -0
  133. shikiru/engine/lifecycle.py +60 -0
  134. shikiru/engine/monitor.py +317 -0
  135. shikiru/engine/notification_delivery.py +124 -0
  136. shikiru/engine/notification_feed.py +57 -0
  137. shikiru/engine/notifier.py +91 -0
  138. shikiru/engine/pull_request_closing.py +117 -0
  139. shikiru/engine/push_service.py +177 -0
  140. shikiru/engine/quota_probe_threads.py +79 -0
  141. shikiru/engine/quota_reconciler.py +210 -0
  142. shikiru/engine/quota_schedule.py +24 -0
  143. shikiru/engine/quota_waits.py +102 -0
  144. shikiru/engine/reconcile.py +91 -0
  145. shikiru/engine/recovery.py +284 -0
  146. shikiru/engine/retention.py +102 -0
  147. shikiru/engine/root_preparation.py +86 -0
  148. shikiru/engine/scheduler.py +320 -0
  149. shikiru/engine/session_guard.py +46 -0
  150. shikiru/engine/sessions.py +95 -0
  151. shikiru/engine/signalled.py +75 -0
  152. shikiru/engine/step_runner.py +140 -0
  153. shikiru/engine/supervisor.py +101 -0
  154. shikiru/engine/takeover.py +223 -0
  155. shikiru/engine/task_facts.py +46 -0
  156. shikiru/engine/tick_guard.py +95 -0
  157. shikiru/engine/titles.py +297 -0
  158. shikiru/engine/transitions.py +79 -0
  159. shikiru/engine/turn_context.py +206 -0
  160. shikiru/engine/turn_execution.py +30 -0
  161. shikiru/engine/turn_loop.py +230 -0
  162. shikiru/engine/turn_processes.py +79 -0
  163. shikiru/engine/turns.py +457 -0
  164. shikiru/errors.py +144 -0
  165. shikiru/helper/__init__.py +3 -0
  166. shikiru/helper/helper.py +182 -0
  167. shikiru/helper/models.py +11 -0
  168. shikiru/mcp/__init__.py +1 -0
  169. shikiru/mcp/server.py +196 -0
  170. shikiru/operator/__init__.py +33 -0
  171. shikiru/operator/attachment.py +24 -0
  172. shikiru/operator/commands.py +319 -0
  173. shikiru/operator/detectors.py +374 -0
  174. shikiru/operator/doctor.py +351 -0
  175. shikiru/operator/events.py +57 -0
  176. shikiru/operator/evidence_export.py +498 -0
  177. shikiru/operator/evidence_writer.py +78 -0
  178. shikiru/operator/lifecycle.py +294 -0
  179. shikiru/operator/plugin_checks.py +194 -0
  180. shikiru/operator/probe.py +287 -0
  181. shikiru/operator/queries.py +1601 -0
  182. shikiru/operator/services.py +36 -0
  183. shikiru/operator/sessions.py +378 -0
  184. shikiru/operator/strip_views.py +162 -0
  185. shikiru/operator/timeline.py +416 -0
  186. shikiru/operator/toml_text.py +62 -0
  187. shikiru/palette.py +18 -0
  188. shikiru/paths.py +91 -0
  189. shikiru/plugins/__init__.py +0 -0
  190. shikiru/plugins/agents/__init__.py +1 -0
  191. shikiru/plugins/agents/claude/__init__.py +5 -0
  192. shikiru/plugins/agents/claude/agent.py +251 -0
  193. shikiru/plugins/agents/claude/context.py +116 -0
  194. shikiru/plugins/agents/claude/headless.py +92 -0
  195. shikiru/plugins/agents/claude/hook.py +57 -0
  196. shikiru/plugins/agents/claude/model_catalog.py +105 -0
  197. shikiru/plugins/agents/claude/options.py +62 -0
  198. shikiru/plugins/agents/claude/turn.py +1018 -0
  199. shikiru/plugins/agents/codex/__init__.py +5 -0
  200. shikiru/plugins/agents/codex/agent.py +486 -0
  201. shikiru/plugins/agents/codex/headless.py +166 -0
  202. shikiru/plugins/agents/codex/home.py +14 -0
  203. shikiru/plugins/agents/codex/model_catalog.py +128 -0
  204. shikiru/plugins/agents/codex/options.py +94 -0
  205. shikiru/plugins/agents/codex/quota.py +195 -0
  206. shikiru/plugins/agents/codex/sandbox.py +31 -0
  207. shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
  208. shikiru/plugins/agents/codex/turn.py +767 -0
  209. shikiru/plugins/agents/codex/usage.py +106 -0
  210. shikiru/plugins/agents/shared_settings.py +62 -0
  211. shikiru/plugins/widgets/__init__.py +3 -0
  212. shikiru/plugins/widgets/dialogue.py +144 -0
  213. shikiru/plugins/widgets/disk.py +123 -0
  214. shikiru/plugins/widgets/docker.py +225 -0
  215. shikiru/plugins/widgets/escalations.py +402 -0
  216. shikiru/plugins/widgets/faults.py +204 -0
  217. shikiru/plugins/widgets/header.py +321 -0
  218. shikiru/plugins/widgets/queue.py +1140 -0
  219. shikiru/plugins/widgets/queue_hint.py +93 -0
  220. shikiru/plugins/widgets/resources.py +105 -0
  221. shikiru/plugins/widgets/strips.py +82 -0
  222. shikiru/plugins/widgets/usage.py +176 -0
  223. shikiru/private.py +94 -0
  224. shikiru/process.py +535 -0
  225. shikiru/prompts/__init__.py +3 -0
  226. shikiru/prompts/renderer.py +85 -0
  227. shikiru/prompts/templates/_base.md.j2 +17 -0
  228. shikiru/prompts/templates/_macros.md.j2 +61 -0
  229. shikiru/prompts/templates/conflicts.md.j2 +22 -0
  230. shikiru/prompts/templates/consult.md.j2 +5 -0
  231. shikiru/prompts/templates/continue.md.j2 +4 -0
  232. shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
  233. shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
  234. shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
  235. shikiru/prompts/templates/handoff.md.j2 +23 -0
  236. shikiru/prompts/templates/implement.md.j2 +13 -0
  237. shikiru/prompts/templates/plan.md.j2 +30 -0
  238. shikiru/prompts/templates/rework.md.j2 +13 -0
  239. shikiru/prompts/templates/triage.md.j2 +20 -0
  240. shikiru/prompts/templates/verify.md.j2 +29 -0
  241. shikiru/store/__init__.py +1 -0
  242. shikiru/store/commands.py +242 -0
  243. shikiru/store/database.py +297 -0
  244. shikiru/store/escalations.py +240 -0
  245. shikiru/store/events.py +213 -0
  246. shikiru/store/feedback.py +266 -0
  247. shikiru/store/migrations/001_initial.sql +63 -0
  248. shikiru/store/migrations/002_headless_turns.sql +31 -0
  249. shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
  250. shikiru/store/migrations/004_repo_base_ref.sql +3 -0
  251. shikiru/store/migrations/005_complexity_explicit.sql +3 -0
  252. shikiru/store/migrations/006_events_kind_index.sql +1 -0
  253. shikiru/store/migrations/007_notification_cursor.sql +1 -0
  254. shikiru/store/migrations/008_reported_usage.sql +1 -0
  255. shikiru/store/migrations/009_billing_declarations.sql +2 -0
  256. shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
  257. shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
  258. shikiru/store/migrations/013_push_targets.sql +7 -0
  259. shikiru/store/migrations/014_agent_block_time.sql +6 -0
  260. shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
  261. shikiru/store/migrations/016_feedback_rounds.sql +29 -0
  262. shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
  263. shikiru/store/migrations/__init__.py +1 -0
  264. shikiru/store/notification_cursor.py +29 -0
  265. shikiru/store/pushes.py +117 -0
  266. shikiru/store/quotas.py +153 -0
  267. shikiru/store/reader.py +310 -0
  268. shikiru/store/retention.py +92 -0
  269. shikiru/store/runs.py +227 -0
  270. shikiru/store/tasks.py +949 -0
  271. shikiru/telemetry/__init__.py +1 -0
  272. shikiru/telemetry/disk.py +167 -0
  273. shikiru/telemetry/docker.py +164 -0
  274. shikiru/telemetry/resources.py +296 -0
  275. shikiru/tmux/__init__.py +3 -0
  276. shikiru/tmux/client.py +637 -0
  277. shikiru/tmux/decorator.py +225 -0
  278. shikiru/tmux/layout.py +200 -0
  279. shikiru/tmux/session.py +381 -0
  280. shikiru/ui/__init__.py +9 -0
  281. shikiru/ui/age.py +45 -0
  282. shikiru/ui/bubbles.py +41 -0
  283. shikiru/ui/compact.py +1209 -0
  284. shikiru/ui/console.py +49 -0
  285. shikiru/ui/fold.py +50 -0
  286. shikiru/ui/gated_static.py +117 -0
  287. shikiru/ui/gauge.py +40 -0
  288. shikiru/ui/glyphs.py +109 -0
  289. shikiru/ui/keys.py +57 -0
  290. shikiru/ui/live_interval.py +42 -0
  291. shikiru/ui/popup.py +311 -0
  292. shikiru/ui/quota_status.py +26 -0
  293. shikiru/ui/recent_window.py +78 -0
  294. shikiru/ui/screen.py +57 -0
  295. shikiru/ui/section.py +193 -0
  296. shikiru/ui/session.py +65 -0
  297. shikiru/ui/size.py +17 -0
  298. shikiru/ui/splash.py +42 -0
  299. shikiru/ui/status.py +47 -0
  300. shikiru/ui/status_strip.py +149 -0
  301. shikiru/ui/stream.py +152 -0
  302. shikiru/ui/strip.py +332 -0
  303. shikiru/ui/theme.py +83 -0
  304. shikiru/ui/tokens.py +51 -0
  305. shikiru/view_log.py +126 -0
  306. shikiru/workspace/__init__.py +3 -0
  307. shikiru/workspace/base_refs.py +160 -0
  308. shikiru/workspace/caches.py +136 -0
  309. shikiru/workspace/facts.py +118 -0
  310. shikiru/workspace/git.py +601 -0
  311. shikiru/workspace/github.py +744 -0
  312. shikiru/workspace/messages.py +107 -0
  313. shikiru/workspace/reports.py +145 -0
  314. shikiru/workspace/roots.py +180 -0
  315. shikiru_cli-0.2.0.dist-info/METADATA +428 -0
  316. shikiru_cli-0.2.0.dist-info/RECORD +319 -0
  317. shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
  318. shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
  319. shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,601 @@
1
+ """The only place git argv is built: worktrees, branches and facts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.errors import CommandFailed, InvariantError
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Sequence
13
+
14
+ from shikiru.process import ProcessResult, SubprocessRunner
15
+
16
+ _GIT_BIN = "git"
17
+ _HEAD_REF = "HEAD"
18
+ _HEADS_REF_PREFIX = "refs/heads/"
19
+ _REMOTE_TRACKING_REF_PREFIX = "refs/remotes/"
20
+ _REFSPEC_SEPARATOR = ":"
21
+ _REMOTE_HEADS_OPTION = "--heads"
22
+ _REMOTE_REF_FIELDS = 2
23
+ _FORCE_REFSPEC_PREFIX = "+"
24
+ _PUSH_TIMEOUT_SECONDS = 300.0
25
+ GIT_STOP_GRACE = 10.0
26
+ _WORKTREE_LIST_PREFIX = "worktree "
27
+ _WORKTREE_BRANCH_PREFIX = "branch "
28
+ _WORKTREE_LOCKED_PREFIX = "locked"
29
+ _REMOVAL_LOCK_REASON = "shikiru: removal in progress"
30
+ _STATUS_IGNORED_PREFIX = "!! "
31
+ _STATUS_CODE_LENGTH = 2
32
+ _STATUS_PATH_OFFSET = _STATUS_CODE_LENGTH + 1
33
+ _PORCELAIN_FLAG = "--porcelain"
34
+ _RENAME_COPY_CODES = frozenset(("R", "C"))
35
+
36
+ _ENGINE_NAME = "Shikiru"
37
+ _ENGINE_EMAIL = "shikiru@localhost"
38
+
39
+ _MERGE_BASE_IS_ANCESTOR = 0
40
+ _MERGE_BASE_IS_NOT_ANCESTOR = 1
41
+ _NUMSTAT_BINARY_MARKER = "-"
42
+ _NUMSTAT_RENAME_PATH_RECORDS = 2
43
+ _NUMSTAT_SEPARATOR = "\t"
44
+ _NUMSTAT_RECORD_SEPARATOR = "\0"
45
+ _NUMSTAT_MALFORMED_RECORD = "git numstat returned a malformed record"
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class LineChangeStats:
50
+ """Distinct changed paths, their line additions and deletions, binary and untracked paths."""
51
+
52
+ changed_paths: int
53
+ added_lines: int
54
+ deleted_lines: int
55
+ binary_entries: int
56
+ untracked_paths: int
57
+
58
+
59
+ class Git:
60
+ """A small, intention-revealing wrapper over the git CLI, always through `SubprocessRunner`."""
61
+
62
+ def __init__(self, runner: SubprocessRunner, *, command_timeout: float) -> None:
63
+ """Run every git command through `runner`, never a shell, each within `command_timeout`."""
64
+ self._runner = runner
65
+ self._command_timeout = command_timeout
66
+
67
+ async def worktree_add(
68
+ self, repo_dir: Path, worktree_path: Path, *, new_branch: str, base: str
69
+ ) -> None:
70
+ """Add a worktree at `worktree_path` on new branch `new_branch`, starting from `base`."""
71
+ await self._run(
72
+ repo_dir,
73
+ "worktree",
74
+ "add",
75
+ "-b",
76
+ new_branch,
77
+ str(worktree_path),
78
+ base,
79
+ mutating=True,
80
+ )
81
+
82
+ async def worktree_add_existing_branch(
83
+ self, repo_dir: Path, worktree_path: Path, branch: str
84
+ ) -> None:
85
+ """Add a worktree at `worktree_path` checking out the already-existing `branch`."""
86
+ await self._run(repo_dir, "worktree", "add", str(worktree_path), branch, mutating=True)
87
+
88
+ async def worktree_remove(self, repo_dir: Path, worktree_path: Path) -> None:
89
+ """Remove the worktree at `worktree_path`, discarding any local changes in it.
90
+
91
+ The worktree is first locked as being removed, so a removal that fails or times out
92
+ midway leaves a state `worktree_removal_pending` recognises, and a later call finishes
93
+ it.
94
+ """
95
+ if not await self.worktree_removal_pending(repo_dir, worktree_path):
96
+ await self._run(
97
+ repo_dir,
98
+ "worktree",
99
+ "lock",
100
+ "--reason",
101
+ _REMOVAL_LOCK_REASON,
102
+ str(worktree_path),
103
+ mutating=True,
104
+ )
105
+ await self._run(
106
+ repo_dir,
107
+ "worktree",
108
+ "remove",
109
+ "--force",
110
+ "--force",
111
+ str(worktree_path),
112
+ mutating=True,
113
+ )
114
+
115
+ async def worktree_removal_pending(self, repo_dir: Path, worktree_path: Path) -> bool:
116
+ """Return whether a removal of `worktree_path` began and did not finish."""
117
+ reasons = await self._worktree_lock_reasons(repo_dir)
118
+ return reasons.get(worktree_path.resolve()) == _REMOVAL_LOCK_REASON
119
+
120
+ async def worktree_exists(self, repo_dir: Path, worktree_path: Path) -> bool:
121
+ """Return whether `worktree_path` is a registered worktree of the repo at `repo_dir`."""
122
+ return worktree_path.resolve() in await self._worktree_paths(repo_dir)
123
+
124
+ async def branch_exists(self, repo_dir: Path, branch: str) -> bool:
125
+ """Return whether local branch `branch` exists in the repo at `repo_dir`."""
126
+ result = await self._run(
127
+ repo_dir, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}", check=False
128
+ )
129
+ return result.returncode == 0
130
+
131
+ async def delete_branch(self, repo_dir: Path, branch: str) -> None:
132
+ """Force-delete local branch `branch` in the repo at `repo_dir`."""
133
+ await self._run(repo_dir, "branch", "-D", branch, mutating=True)
134
+
135
+ async def current_branch(self, repo_dir: Path) -> str:
136
+ """Return the name of the branch currently checked out in `repo_dir`."""
137
+ result = await self._run(repo_dir, "rev-parse", "--abbrev-ref", "HEAD")
138
+ return result.stdout.strip()
139
+
140
+ async def head_branch(self, repo_dir: Path) -> str | None:
141
+ """Return the branch `HEAD` is on in `repo_dir`, or `None` when it is detached."""
142
+ result = await self._run(
143
+ repo_dir, "symbolic-ref", "--quiet", "--short", "HEAD", check=False
144
+ )
145
+ return result.stdout.strip() if result.returncode == 0 else None
146
+
147
+ async def is_repository_root(self, path: Path) -> bool:
148
+ """Return whether `path` is the top-level directory of a git working tree."""
149
+ return await self.repository_root(path) == path.resolve()
150
+
151
+ async def repository_root(self, path: Path) -> Path | None:
152
+ """Return the top-level directory containing `path`, or `None` outside a worktree."""
153
+ result = await self._run(path, "rev-parse", "--show-toplevel", check=False)
154
+ if result.returncode != 0:
155
+ return None
156
+ return Path(result.stdout.strip()).resolve()
157
+
158
+ async def has_commit(self, repo_dir: Path) -> bool:
159
+ """Return whether `repo_dir` has a commit at `HEAD`."""
160
+ result = await self._run(
161
+ repo_dir, "rev-parse", "--verify", "--quiet", _HEAD_REF, check=False
162
+ )
163
+ return result.returncode == 0
164
+
165
+ async def rev_parse(self, repo_dir: Path, ref: str) -> str:
166
+ """Resolve `ref` to a commit sha in the repo at `repo_dir`."""
167
+ result = await self._run(repo_dir, "rev-parse", ref)
168
+ return result.stdout.strip()
169
+
170
+ async def log_oneline(self, repo_dir: Path, commit_range: str) -> list[str]:
171
+ """Return one line per commit in `commit_range` (oldest first as git orders them)."""
172
+ result = await self._run(repo_dir, "log", "--oneline", commit_range)
173
+ return [line for line in result.stdout.splitlines() if line]
174
+
175
+ async def commit_parents(self, repo_dir: Path, commit: str) -> tuple[str, ...]:
176
+ """Return the parents of `commit` in their recorded order."""
177
+ result = await self._run(repo_dir, "rev-list", "--parents", "-n", "1", commit)
178
+ return tuple(result.stdout.split()[1:])
179
+
180
+ async def diff_stat(self, repo_dir: Path, commit_range: str) -> str:
181
+ """Return `git diff --stat` for `commit_range`, verbatim.
182
+
183
+ This output is display-formatted for humans: long paths are elided to `...` and
184
+ renames are shown as `old => new`. Never parse paths out of it; use `diff_name_only`.
185
+ """
186
+ result = await self._run(repo_dir, "diff", "--stat", commit_range)
187
+ return result.stdout
188
+
189
+ async def merge_base(self, repo_dir: Path, ref: str, other: str) -> str:
190
+ """Return the sha of the best common ancestor of `ref` and `other`."""
191
+ result = await self._run(repo_dir, "merge-base", ref, other)
192
+ return result.stdout.strip()
193
+
194
+ async def working_tree_line_changes(
195
+ self, repo_dir: Path, *, against: str = _HEAD_REF
196
+ ) -> LineChangeStats:
197
+ """Count the working tree's changed paths against `against`, and its line changes.
198
+
199
+ A path is counted once however git reports it: committed and uncommitted edits to a
200
+ tracked file, or a tracked deletion and an untracked file at the same path, are one
201
+ path. Untracked paths carry no line counts.
202
+ """
203
+ tracked = await self._run(repo_dir, "diff", "--numstat", "-z", against)
204
+ tracked_paths, added_lines, deleted_lines, binary_entries = _parse_numstat(tracked.stdout)
205
+ untracked = await self._run(repo_dir, "ls-files", "--others", "--exclude-standard", "-z")
206
+ untracked_paths = {
207
+ path for path in untracked.stdout.split(_NUMSTAT_RECORD_SEPARATOR) if path
208
+ }
209
+ return LineChangeStats(
210
+ changed_paths=len(tracked_paths | untracked_paths),
211
+ added_lines=added_lines,
212
+ deleted_lines=deleted_lines,
213
+ binary_entries=binary_entries,
214
+ untracked_paths=len(untracked_paths),
215
+ )
216
+
217
+ async def diff_name_only(self, repo_dir: Path, commit_range: str) -> list[str]:
218
+ """Return the full, unformatted path of every file changed in `commit_range`."""
219
+ result = await self._run(repo_dir, "diff", "--name-only", commit_range)
220
+ return [line for line in result.stdout.splitlines() if line]
221
+
222
+ async def diff_patch(self, repo_dir: Path, commit_range: str, paths: Sequence[str]) -> str:
223
+ """Return the binary-safe patch of `paths` changed in `commit_range`, empty if none."""
224
+ result = await self._run(repo_dir, "diff", "--binary", commit_range, "--", *paths)
225
+ return result.stdout
226
+
227
+ async def apply_patch(self, repo_dir: Path, patch: str) -> None:
228
+ """Apply `patch` to the index and working tree of `repo_dir`, merging three-way."""
229
+ await self._run(repo_dir, "apply", "--3way", "--index", input_text=patch, mutating=True)
230
+
231
+ async def commit(self, repo_dir: Path, message: str) -> None:
232
+ """Commit the index of `repo_dir` as the engine's own identity."""
233
+ await self._run(
234
+ repo_dir,
235
+ "-c",
236
+ f"user.name={_ENGINE_NAME}",
237
+ "-c",
238
+ f"user.email={_ENGINE_EMAIL}",
239
+ "commit",
240
+ "-m",
241
+ message,
242
+ mutating=True,
243
+ )
244
+
245
+ async def status_porcelain(self, repo_dir: Path, *, include_ignored: bool = False) -> list[str]:
246
+ """Return one `git status --porcelain` line per entry, optionally including ignored ones."""
247
+ args = ["status", _PORCELAIN_FLAG]
248
+ if include_ignored:
249
+ args.append("--ignored")
250
+ result = await self._run(repo_dir, *args)
251
+ return [line for line in result.stdout.splitlines() if line]
252
+
253
+ async def ignored_paths(self, repo_dir: Path) -> list[Path]:
254
+ """Return every top-level path git ignores in `repo_dir` (from `status --ignored`).
255
+
256
+ Not exhaustive: git reports an ignored directory itself but not paths nested inside
257
+ another ignored directory, so a path ignored only because its parent is ignored will
258
+ not appear here. This matches the documented cache globs, which target the ignored
259
+ directory directly (e.g. `node_modules`), not paths beneath it.
260
+ """
261
+ lines = await self.status_porcelain(repo_dir, include_ignored=True)
262
+ paths: list[Path] = []
263
+ for line in lines:
264
+ if not line.startswith(_STATUS_IGNORED_PREFIX):
265
+ continue
266
+ relative = line[len(_STATUS_IGNORED_PREFIX) :].strip().rstrip("/")
267
+ paths.append(repo_dir / relative)
268
+ return paths
269
+
270
+ async def merge_base_is_ancestor(self, repo_dir: Path, ancestor: str, descendant: str) -> bool:
271
+ """Return whether `ancestor` is an ancestor of `descendant` (or the same commit)."""
272
+ result = await self._run(
273
+ repo_dir, "merge-base", "--is-ancestor", ancestor, descendant, check=False
274
+ )
275
+ if result.returncode not in (_MERGE_BASE_IS_ANCESTOR, _MERGE_BASE_IS_NOT_ANCESTOR):
276
+ raise CommandFailed(result.argv, result.returncode, result.stderr)
277
+ return result.returncode == _MERGE_BASE_IS_ANCESTOR
278
+
279
+ async def rebase(self, repo_dir: Path, base: str) -> None:
280
+ """Rebase the current branch onto `base`, raising when git reports a conflict."""
281
+ await self._run(repo_dir, "rebase", base, mutating=True)
282
+
283
+ async def rebase_abort(self, repo_dir: Path) -> None:
284
+ """Abort the rebase in progress, restoring the branch to where it was before it began."""
285
+ await self._run(repo_dir, "rebase", "--abort", mutating=True)
286
+
287
+ async def rebase_continue(self, repo_dir: Path) -> None:
288
+ """Continue a rebase after its conflicts have been resolved and staged."""
289
+ await self._run(repo_dir, "-c", "core.editor=true", "rebase", "--continue", mutating=True)
290
+
291
+ async def merge_ff_only(self, repo_dir: Path, branch: str) -> None:
292
+ """Fast-forward the checked-out base branch to `branch` only."""
293
+ await self._run(repo_dir, "merge", "--ff-only", branch, mutating=True)
294
+
295
+ async def fast_forward_checked_out(self, repo_dir: Path, base: str, target: str) -> None:
296
+ """Fast-forward the checked-out `base` to `target`, ignoring inherited merge options.
297
+
298
+ Autostash is off, and the branch's configured `mergeOptions` are blanked, so no
299
+ configured flag or strategy can turn the update into a squash or a real merge.
300
+ """
301
+ await self._run(
302
+ repo_dir,
303
+ "-c",
304
+ f"branch.{base}.mergeOptions=",
305
+ "merge",
306
+ "--ff-only",
307
+ "--no-autostash",
308
+ target,
309
+ mutating=True,
310
+ )
311
+
312
+ async def update_ref(
313
+ self, repo_dir: Path, branch: str, new_sha: str, expected_old_sha: str
314
+ ) -> None:
315
+ """Atomically move `branch` from `expected_old_sha` to `new_sha`."""
316
+ await self._run(
317
+ repo_dir,
318
+ "update-ref",
319
+ f"refs/heads/{branch}",
320
+ new_sha,
321
+ expected_old_sha,
322
+ mutating=True,
323
+ )
324
+
325
+ async def commit_tree(self, repo_dir: Path, tree_rev: str, parent: str, message: str) -> str:
326
+ """Create, as the engine's own identity, a commit of `tree_rev`'s tree on `parent`.
327
+
328
+ No ref or working tree moves; the new commit's sha is returned.
329
+ """
330
+ result = await self._run(
331
+ repo_dir,
332
+ "-c",
333
+ f"user.name={_ENGINE_NAME}",
334
+ "-c",
335
+ f"user.email={_ENGINE_EMAIL}",
336
+ "commit-tree",
337
+ f"{tree_rev}^{{tree}}",
338
+ "-p",
339
+ parent,
340
+ "-m",
341
+ message,
342
+ mutating=True,
343
+ )
344
+ return result.stdout.strip()
345
+
346
+ async def find_commit(self, repo_dir: Path, commit_range: str, text: str) -> str | None:
347
+ """Return the newest commit in `commit_range` whose message contains `text`, if any."""
348
+ result = await self._run(
349
+ repo_dir, "log", "--format=%H", "--fixed-strings", f"--grep={text}", "-n1", commit_range
350
+ )
351
+ return result.stdout.strip() or None
352
+
353
+ async def committed_line_changes(
354
+ self, repo_dir: Path, before: str, after: str
355
+ ) -> LineChangeStats:
356
+ """Count the paths and lines changed between the commits `before` and `after`."""
357
+ result = await self._run(repo_dir, "diff", "--numstat", "-z", before, after)
358
+ paths, added_lines, deleted_lines, binary_entries = _parse_numstat(result.stdout)
359
+ return LineChangeStats(
360
+ changed_paths=len(paths),
361
+ added_lines=added_lines,
362
+ deleted_lines=deleted_lines,
363
+ binary_entries=binary_entries,
364
+ untracked_paths=0,
365
+ )
366
+
367
+ async def remote_configured(self, repo_dir: Path, remote: str) -> bool:
368
+ """Return whether the repo at `repo_dir` has a remote named `remote` configured."""
369
+ result = await self._run(repo_dir, "remote")
370
+ return remote in result.stdout.splitlines()
371
+
372
+ async def remote_branch_sha(self, repo_dir: Path, remote: str, branch: str) -> str | None:
373
+ """Return the sha `branch` had when last pushed to or fetched from `remote`, if ever."""
374
+ result = await self._run(
375
+ repo_dir,
376
+ "rev-parse",
377
+ "--verify",
378
+ "--quiet",
379
+ f"refs/remotes/{remote}/{branch}",
380
+ check=False,
381
+ )
382
+ return result.stdout.strip() if result.returncode == 0 else None
383
+
384
+ async def fetch_remote_branch(
385
+ self, repo_dir: Path, remote: str, branch: str, *, timeout: float
386
+ ) -> str | None:
387
+ """Fetch `remote`'s branch to its tracking ref; return the fetched SHA if present."""
388
+ remote_ref = f"{_HEADS_REF_PREFIX}{branch}"
389
+ result = await self._run(
390
+ repo_dir,
391
+ "ls-remote",
392
+ _REMOTE_HEADS_OPTION,
393
+ remote,
394
+ remote_ref,
395
+ timeout=timeout,
396
+ )
397
+ matching_shas = [
398
+ fields[0]
399
+ for line in result.stdout.splitlines()
400
+ if len(fields := line.split()) == _REMOTE_REF_FIELDS and fields[1] == remote_ref
401
+ ]
402
+ if not matching_shas:
403
+ return None
404
+ if len(matching_shas) > 1:
405
+ message = f"git ls-remote returned multiple values for {remote_ref}"
406
+ raise InvariantError(message)
407
+ tracking_ref = f"{_REMOTE_TRACKING_REF_PREFIX}{remote}/{branch}"
408
+ await self.fetch(
409
+ repo_dir,
410
+ remote,
411
+ f"{_FORCE_REFSPEC_PREFIX}{remote_ref}{_REFSPEC_SEPARATOR}{tracking_ref}",
412
+ timeout=timeout,
413
+ )
414
+ return await self.remote_branch_sha(repo_dir, remote, branch)
415
+
416
+ async def local_branch_sha(self, repo_dir: Path, branch: str) -> str | None:
417
+ """Return the sha `branch` currently has locally, or `None` when that ref is missing."""
418
+ result = await self._run(
419
+ repo_dir,
420
+ "rev-parse",
421
+ "--verify",
422
+ "--quiet",
423
+ f"{_HEADS_REF_PREFIX}{branch}",
424
+ check=False,
425
+ )
426
+ return result.stdout.strip() if result.returncode == 0 else None
427
+
428
+ async def fetch(self, repo_dir: Path, remote: str, *refspecs: str, timeout: float) -> None:
429
+ """Fetch `refspecs` from `remote` within `timeout` seconds, changing only the refs named."""
430
+ await self._run(
431
+ repo_dir,
432
+ "fetch",
433
+ "--no-tags",
434
+ remote,
435
+ *refspecs,
436
+ timeout=timeout,
437
+ mutating=True,
438
+ )
439
+
440
+ async def push(self, repo_dir: Path, remote: str, branch: str, *, lease: str | None) -> None:
441
+ """Push `branch` to `remote` as its upstream, overwriting it only if it is still `lease`.
442
+
443
+ Without a `lease` the push never overwrites the remote branch.
444
+ """
445
+ lease_args = () if lease is None else (f"--force-with-lease={branch}:{lease}",)
446
+ await self._run(
447
+ repo_dir,
448
+ "push",
449
+ *lease_args,
450
+ "-u",
451
+ remote,
452
+ branch,
453
+ timeout=_PUSH_TIMEOUT_SECONDS,
454
+ mutating=True,
455
+ )
456
+
457
+ async def push_base(self, repo_dir: Path, remote: str, branch: str) -> None:
458
+ """Push local `branch` to the same-named branch of `remote`, never forcing or adding refs.
459
+
460
+ A remote branch that has diverged rejects the push instead of being overwritten.
461
+ """
462
+ ref = f"refs/heads/{branch}"
463
+ await self._run(
464
+ repo_dir,
465
+ "push",
466
+ "--no-follow-tags",
467
+ remote,
468
+ f"{ref}:{ref}",
469
+ timeout=_PUSH_TIMEOUT_SECONDS,
470
+ mutating=True,
471
+ )
472
+
473
+ async def dirty_paths(self, repo_dir: Path) -> set[str]:
474
+ """Return paths changed in the working tree, including untracked paths."""
475
+ result = await self._run(repo_dir, "status", "--porcelain=v1", "-z")
476
+ entries = result.stdout.split("\0")
477
+ paths: set[str] = set()
478
+ index = 0
479
+ while index < len(entries):
480
+ entry = entries[index]
481
+ index += 1
482
+ if not entry:
483
+ continue
484
+ status = entry[:_STATUS_CODE_LENGTH]
485
+ path = entry[_STATUS_PATH_OFFSET:]
486
+ paths.add(path)
487
+ if any(code in _RENAME_COPY_CODES for code in status) and index < len(entries):
488
+ paths.add(entries[index])
489
+ index += 1
490
+ return paths
491
+
492
+ async def is_rebase_in_progress(self, repo_dir: Path) -> bool:
493
+ """Return whether a rebase operation is currently in progress."""
494
+ merge_dir = await self._git_path(repo_dir, "rebase-merge")
495
+ apply_dir = await self._git_path(repo_dir, "rebase-apply")
496
+ return merge_dir.exists() or apply_dir.exists()
497
+
498
+ async def unmerged_paths(self, repo_dir: Path) -> list[str]:
499
+ """Return the paths with unresolved merge conflicts in `repo_dir`."""
500
+ result = await self._run(repo_dir, "diff", "--name-only", "--diff-filter=U")
501
+ return [line for line in result.stdout.splitlines() if line]
502
+
503
+ async def append_to_exclude(self, repo_dir: Path, line: str) -> None:
504
+ """Append `line` to this repo's shared `info/exclude`, unless it is already there."""
505
+ exclude_path = await self._exclude_path(repo_dir)
506
+ text = exclude_path.read_text() if exclude_path.exists() else ""
507
+ if line in text.splitlines():
508
+ return
509
+ with exclude_path.open("a") as handle:
510
+ if text and not text.endswith("\n"):
511
+ handle.write("\n")
512
+ handle.write(f"{line}\n")
513
+
514
+ async def _exclude_path(self, repo_dir: Path) -> Path:
515
+ result = await self._run(
516
+ repo_dir, "rev-parse", "--path-format=absolute", "--git-common-dir"
517
+ )
518
+ git_common_dir = result.stdout.strip()
519
+ return Path(git_common_dir) / "info" / "exclude"
520
+
521
+ async def _git_path(self, repo_dir: Path, name: str) -> Path:
522
+ result = await self._run(repo_dir, "rev-parse", "--git-path", name)
523
+ path = Path(result.stdout.strip())
524
+ return path if path.is_absolute() else repo_dir / path
525
+
526
+ async def _worktree_paths(self, repo_dir: Path) -> set[Path]:
527
+ return set(await self._worktree_lock_reasons(repo_dir))
528
+
529
+ async def _worktree_lock_reasons(self, repo_dir: Path) -> dict[Path, str | None]:
530
+ result = await self._run(repo_dir, "worktree", "list", "--porcelain")
531
+ reasons: dict[Path, str | None] = {}
532
+ current: Path | None = None
533
+ for line in result.stdout.splitlines():
534
+ if line.startswith(_WORKTREE_LIST_PREFIX):
535
+ current = Path(line[len(_WORKTREE_LIST_PREFIX) :]).resolve()
536
+ reasons[current] = None
537
+ elif line.startswith(_WORKTREE_LOCKED_PREFIX) and current is not None:
538
+ reasons[current] = line[len(_WORKTREE_LOCKED_PREFIX) :].strip()
539
+ return reasons
540
+
541
+ async def branch_checkouts(self, repo_dir: Path, branch: str) -> list[Path]:
542
+ """Return the worktrees whose `HEAD` is on `branch`, main checkout first."""
543
+ result = await self._run(repo_dir, "worktree", "list", "--porcelain")
544
+ checked_out_ref = f"{_WORKTREE_BRANCH_PREFIX}{_HEADS_REF_PREFIX}{branch}"
545
+ checkouts: list[Path] = []
546
+ current: Path | None = None
547
+ for line in result.stdout.splitlines():
548
+ if line.startswith(_WORKTREE_LIST_PREFIX):
549
+ current = Path(line[len(_WORKTREE_LIST_PREFIX) :]).resolve()
550
+ elif line == checked_out_ref and current is not None:
551
+ checkouts.append(current)
552
+ return checkouts
553
+
554
+ async def _run(
555
+ self,
556
+ repo_dir: Path,
557
+ *args: str,
558
+ check: bool = True,
559
+ timeout: float | None = None,
560
+ input_text: str | None = None,
561
+ mutating: bool = False,
562
+ ) -> ProcessResult:
563
+ return await self._runner.run(
564
+ [_GIT_BIN, *args],
565
+ timeout=self._command_timeout if timeout is None else timeout,
566
+ cwd=repo_dir,
567
+ check=check,
568
+ input_text=input_text,
569
+ stop_grace=GIT_STOP_GRACE if mutating else None,
570
+ )
571
+
572
+
573
+ def _parse_numstat(output: str) -> tuple[set[str], int, int, int]:
574
+ changed_paths: set[str] = set()
575
+ added_lines = 0
576
+ deleted_lines = 0
577
+ binary_entries = 0
578
+ records = output.split(_NUMSTAT_RECORD_SEPARATOR)
579
+ index = 0
580
+ while index < len(records):
581
+ record = records[index]
582
+ index += 1
583
+ if not record:
584
+ continue
585
+ added, separator, remainder = record.partition(_NUMSTAT_SEPARATOR)
586
+ deleted, second_separator, path = remainder.partition(_NUMSTAT_SEPARATOR)
587
+ if not separator or not second_separator:
588
+ raise InvariantError(_NUMSTAT_MALFORMED_RECORD)
589
+ if not path:
590
+ new_path_index = index + 1
591
+ if new_path_index >= len(records):
592
+ raise InvariantError(_NUMSTAT_MALFORMED_RECORD)
593
+ path = records[new_path_index]
594
+ index += _NUMSTAT_RENAME_PATH_RECORDS
595
+ changed_paths.add(path)
596
+ if _NUMSTAT_BINARY_MARKER in (added, deleted):
597
+ binary_entries += 1
598
+ else:
599
+ added_lines += int(added)
600
+ deleted_lines += int(deleted)
601
+ return changed_paths, added_lines, deleted_lines, binary_entries