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.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
shikiru/workspace/git.py
ADDED
|
@@ -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
|