sid-harness 0.3.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.
- sid_harness/__init__.py +13 -0
- sid_harness/__main__.py +4 -0
- sid_harness/_native.py +1639 -0
- sid_harness/brave_proof.py +246 -0
- sid_harness/bridge.py +851 -0
- sid_harness/cli.py +937 -0
- sid_harness/client.py +161 -0
- sid_harness/companion.py +151 -0
- sid_harness/config.py +68 -0
- sid_harness/context/__init__.py +40 -0
- sid_harness/context/index.py +267 -0
- sid_harness/context/retrieval.py +74 -0
- sid_harness/editor.py +231 -0
- sid_harness/gateway.py +106 -0
- sid_harness/git_write.py +598 -0
- sid_harness/local_workspace.py +421 -0
- sid_harness/loop.py +394 -0
- sid_harness/lsp.py +598 -0
- sid_harness/policy/__init__.py +44 -0
- sid_harness/policy/repo_policy.py +626 -0
- sid_harness/receipt.py +142 -0
- sid_harness/runner.py +468 -0
- sid_harness/selftest.py +149 -0
- sid_harness/session/__init__.py +33 -0
- sid_harness/session/bounds.py +140 -0
- sid_harness/session/diff_gate.py +93 -0
- sid_harness/session/session.py +427 -0
- sid_harness/session/store.py +88 -0
- sid_harness/tools/__init__.py +52 -0
- sid_harness/tools/mcp_client.py +285 -0
- sid_harness/tools/plan.py +138 -0
- sid_harness/tools/registry.py +159 -0
- sid_harness/tools/skills.py +185 -0
- sid_harness/tools/subagents.py +112 -0
- sid_harness/tui.py +96 -0
- sid_harness/workspace.py +107 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
- sid_harness-0.3.0.dist-info/METADATA +229 -0
- sid_harness-0.3.0.dist-info/RECORD +45 -0
- sid_harness-0.3.0.dist-info/WHEEL +5 -0
- sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
- sid_harness-0.3.0.dist-info/top_level.txt +1 -0
sid_harness/git_write.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""Opt-in git write path (SUB-95): branch, commit the approved diff, open a PR.
|
|
2
|
+
|
|
3
|
+
The harness is read-only by default. This module is the explicit opt-in that
|
|
4
|
+
finishes the everyday coder loop after a run: create a branch, commit only the
|
|
5
|
+
approved diff, push that branch, and open a pull request through ``gh``.
|
|
6
|
+
|
|
7
|
+
Every mutating step needs its own human approval. If no approver is supplied
|
|
8
|
+
each step fails closed; a falsy decision or a raising approver refuses the step
|
|
9
|
+
and stops the sequence. A partly completed sequence is reported honestly (the
|
|
10
|
+
branch and any commit already made stay named in the result).
|
|
11
|
+
|
|
12
|
+
Hard guards, enforced here rather than by convention:
|
|
13
|
+
|
|
14
|
+
* the write target may never be ``main``/``master`` (or a sub-ref of either) —
|
|
15
|
+
validated before any approval prompt or git command is issued;
|
|
16
|
+
* no force is ever built into a command (no ``-f``/``--force``/``--force-with-lease``);
|
|
17
|
+
* no commit happens without an explicit approval for the commit step;
|
|
18
|
+
* only the explicit approved paths are staged and committed.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
from collections.abc import Callable, Iterable
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .local_workspace import git_info
|
|
31
|
+
|
|
32
|
+
PROTECTED_BRANCHES = frozenset({"main", "master"})
|
|
33
|
+
PROTECTED_REF_PREFIX = "refs/heads/"
|
|
34
|
+
|
|
35
|
+
_GIT_TIMEOUT = 60.0
|
|
36
|
+
_GH_TIMEOUT = 120.0
|
|
37
|
+
|
|
38
|
+
_BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
|
|
39
|
+
_REMOTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
40
|
+
_WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:")
|
|
41
|
+
_URL_RE = re.compile(r"https?://[^\s]+")
|
|
42
|
+
|
|
43
|
+
_FORCE_FLAGS = frozenset(
|
|
44
|
+
{"-f", "--force", "--force-with-lease", "--force-if-includes"}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_MAX_DETAIL_CHARS = 2000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GitWriteError(RuntimeError):
|
|
51
|
+
"""A git write step was refused or could not complete."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Refused(GitWriteError):
|
|
55
|
+
"""An approval gate refused the step; no further mutation may happen."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Failed(GitWriteError):
|
|
59
|
+
"""A git/gh command failed; the sequence stops and reports what completed."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
Approver = Callable[["GitWriteStep"], bool]
|
|
63
|
+
GitRunner = Callable[[Path, list[str]], subprocess.CompletedProcess]
|
|
64
|
+
GhRunner = Callable[[list[str], Path], subprocess.CompletedProcess]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --------------------------------------------------------------------------- #
|
|
68
|
+
# Validation helpers (pure — testable without a repo)
|
|
69
|
+
# --------------------------------------------------------------------------- #
|
|
70
|
+
def normalize_branch(value: object) -> str:
|
|
71
|
+
"""Return a branch name without the ``refs/heads/`` prefix."""
|
|
72
|
+
text = str(value or "").strip()
|
|
73
|
+
if text.startswith(PROTECTED_REF_PREFIX):
|
|
74
|
+
text = text[len(PROTECTED_REF_PREFIX) :]
|
|
75
|
+
return text
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_protected_branch(value: object) -> bool:
|
|
79
|
+
"""True when ``value`` names main/master or a sub-ref of either."""
|
|
80
|
+
name = normalize_branch(value).lower()
|
|
81
|
+
if not name:
|
|
82
|
+
return False
|
|
83
|
+
if name in PROTECTED_BRANCHES:
|
|
84
|
+
return True
|
|
85
|
+
return name.split("/", 1)[0] in PROTECTED_BRANCHES
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def assert_writable_branch(value: object) -> str:
|
|
89
|
+
"""Validate a branch the harness may commit/push to; refuse protected refs."""
|
|
90
|
+
name = normalize_branch(value)
|
|
91
|
+
if not name:
|
|
92
|
+
raise GitWriteError("a branch name is required")
|
|
93
|
+
if is_protected_branch(name):
|
|
94
|
+
raise GitWriteError(
|
|
95
|
+
f"refusing to write to protected branch {name!r}: the git-write path "
|
|
96
|
+
"never commits or pushes main/master"
|
|
97
|
+
)
|
|
98
|
+
if (
|
|
99
|
+
not _BRANCH_RE.fullmatch(name)
|
|
100
|
+
or ".." in name
|
|
101
|
+
or name.endswith(("/", ".", ".lock"))
|
|
102
|
+
or "//" in name
|
|
103
|
+
):
|
|
104
|
+
raise GitWriteError(f"invalid branch name: {value!r}")
|
|
105
|
+
return name
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def assert_safe_remote(value: object) -> str:
|
|
109
|
+
remote = str(value or "").strip()
|
|
110
|
+
if not remote or not _REMOTE_RE.fullmatch(remote) or is_protected_branch(remote):
|
|
111
|
+
raise GitWriteError(f"invalid remote name: {value!r}")
|
|
112
|
+
return remote
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def contains_force_flag(argv: Iterable[str]) -> bool:
|
|
116
|
+
"""True when an argv carries a force-push flag (it never should)."""
|
|
117
|
+
return any(
|
|
118
|
+
token in _FORCE_FLAGS or token.startswith("--force")
|
|
119
|
+
for token in argv
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _assert_no_force(argv: list[str]) -> list[str]:
|
|
124
|
+
if contains_force_flag(argv):
|
|
125
|
+
raise GitWriteError(f"refusing a force argument: {argv!r}")
|
|
126
|
+
return argv
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _validate_paths(paths: Iterable[object]) -> tuple[str, ...]:
|
|
130
|
+
validated: list[str] = []
|
|
131
|
+
for raw in paths:
|
|
132
|
+
text = str(raw or "").strip()
|
|
133
|
+
if not text:
|
|
134
|
+
raise GitWriteError("an approved path may not be empty")
|
|
135
|
+
normalized = text.replace("\\", "/")
|
|
136
|
+
if (
|
|
137
|
+
normalized.startswith("/")
|
|
138
|
+
or _WINDOWS_ABSOLUTE.match(normalized)
|
|
139
|
+
or ".." in normalized.split("/")
|
|
140
|
+
):
|
|
141
|
+
raise GitWriteError(f"approved path escapes the workspace: {text!r}")
|
|
142
|
+
if normalized not in validated:
|
|
143
|
+
validated.append(normalized)
|
|
144
|
+
return tuple(validated)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --------------------------------------------------------------------------- #
|
|
148
|
+
# Command builders (pure; the guards live here, not at the call sites)
|
|
149
|
+
# --------------------------------------------------------------------------- #
|
|
150
|
+
def branch_command(branch: str) -> list[str]:
|
|
151
|
+
return ["checkout", "-b", assert_writable_branch(branch)]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def add_command(paths: Iterable[object]) -> list[str]:
|
|
155
|
+
validated = _validate_paths(paths)
|
|
156
|
+
if not validated:
|
|
157
|
+
raise GitWriteError("no approved paths to stage")
|
|
158
|
+
return ["add", "--", *validated]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def commit_command(message: str, paths: Iterable[object]) -> list[str]:
|
|
162
|
+
text = str(message or "").strip()
|
|
163
|
+
if not text:
|
|
164
|
+
raise GitWriteError("a commit message is required")
|
|
165
|
+
if "\x00" in text:
|
|
166
|
+
raise GitWriteError("commit message must not contain NUL bytes")
|
|
167
|
+
validated = _validate_paths(paths)
|
|
168
|
+
if not validated:
|
|
169
|
+
raise GitWriteError("no approved paths to commit")
|
|
170
|
+
return ["commit", "-m", text, "--", *validated]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def push_command(remote: str, branch: str) -> list[str]:
|
|
174
|
+
target = assert_writable_branch(branch)
|
|
175
|
+
safe_remote = assert_safe_remote(remote)
|
|
176
|
+
return _assert_no_force(
|
|
177
|
+
["push", "--set-upstream", safe_remote, target]
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def pr_command(
|
|
182
|
+
branch: str,
|
|
183
|
+
*,
|
|
184
|
+
base: str,
|
|
185
|
+
title: str,
|
|
186
|
+
body: str,
|
|
187
|
+
) -> list[str]:
|
|
188
|
+
head = assert_writable_branch(branch)
|
|
189
|
+
base_name = normalize_branch(base)
|
|
190
|
+
if not base_name:
|
|
191
|
+
raise GitWriteError("a pull-request base branch is required")
|
|
192
|
+
return _assert_no_force(
|
|
193
|
+
[
|
|
194
|
+
"pr",
|
|
195
|
+
"create",
|
|
196
|
+
"--head",
|
|
197
|
+
head,
|
|
198
|
+
"--base",
|
|
199
|
+
base_name,
|
|
200
|
+
"--title",
|
|
201
|
+
str(title or "").strip() or head,
|
|
202
|
+
"--body",
|
|
203
|
+
str(body or "").strip(),
|
|
204
|
+
]
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --------------------------------------------------------------------------- #
|
|
209
|
+
# Plan / result shapes
|
|
210
|
+
# --------------------------------------------------------------------------- #
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class GitWriteStep:
|
|
213
|
+
"""One mutating step, shown to the approver before it runs."""
|
|
214
|
+
|
|
215
|
+
name: str
|
|
216
|
+
description: str
|
|
217
|
+
command: str | None = None
|
|
218
|
+
|
|
219
|
+
def render(self) -> str:
|
|
220
|
+
if self.command:
|
|
221
|
+
return f"{self.name}: {self.description}\n $ {self.command}"
|
|
222
|
+
return f"{self.name}: {self.description}"
|
|
223
|
+
|
|
224
|
+
def as_dict(self) -> dict[str, Any]:
|
|
225
|
+
return {
|
|
226
|
+
"name": self.name,
|
|
227
|
+
"description": self.description,
|
|
228
|
+
"command": self.command,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True)
|
|
233
|
+
class GitWritePlan:
|
|
234
|
+
"""What the opt-in path will do, decided before anything mutates."""
|
|
235
|
+
|
|
236
|
+
directory: Path
|
|
237
|
+
branch: str
|
|
238
|
+
message: str
|
|
239
|
+
paths: tuple[str, ...]
|
|
240
|
+
remote: str = "origin"
|
|
241
|
+
base_branch: str = "main"
|
|
242
|
+
open_pr: bool = True
|
|
243
|
+
pr_title: str | None = None
|
|
244
|
+
pr_body: str | None = None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass
|
|
248
|
+
class GitWriteResult:
|
|
249
|
+
"""Honest outcome: names the branch, commit, and PR (plus what was refused)."""
|
|
250
|
+
|
|
251
|
+
status: str = "refused"
|
|
252
|
+
branch: str | None = None
|
|
253
|
+
commit: str | None = None
|
|
254
|
+
pr_url: str | None = None
|
|
255
|
+
pushed: bool = False
|
|
256
|
+
reason: str | None = None
|
|
257
|
+
steps: list[dict[str, Any]] = field(default_factory=list)
|
|
258
|
+
commands: list[str] = field(default_factory=list)
|
|
259
|
+
|
|
260
|
+
def as_dict(self) -> dict[str, Any]:
|
|
261
|
+
return {
|
|
262
|
+
"status": self.status,
|
|
263
|
+
"branch": self.branch,
|
|
264
|
+
"commit": self.commit,
|
|
265
|
+
"pr_url": self.pr_url,
|
|
266
|
+
"pr": self.pr_url,
|
|
267
|
+
"pushed": self.pushed,
|
|
268
|
+
"reason": self.reason,
|
|
269
|
+
"steps": list(self.steps),
|
|
270
|
+
"commands": list(self.commands),
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# --------------------------------------------------------------------------- #
|
|
275
|
+
# Defaults for the runner/CLI
|
|
276
|
+
# --------------------------------------------------------------------------- #
|
|
277
|
+
def default_branch_name(run_id: str) -> str:
|
|
278
|
+
safe = re.sub(r"[^A-Za-z0-9._/-]+", "-", str(run_id or "run").strip())
|
|
279
|
+
return f"sid/{safe or 'run'}"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def default_commit_message(task: str, run_id: str) -> str:
|
|
283
|
+
first_line = " ".join(str(task or "").split())
|
|
284
|
+
if not first_line:
|
|
285
|
+
return f"sid: apply approved changes ({run_id})"
|
|
286
|
+
if len(first_line) > 72:
|
|
287
|
+
first_line = first_line[:69].rstrip() + "..."
|
|
288
|
+
return first_line
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --------------------------------------------------------------------------- #
|
|
292
|
+
# Command execution
|
|
293
|
+
# --------------------------------------------------------------------------- #
|
|
294
|
+
def _run_git(root: Path, args: list[str]) -> subprocess.CompletedProcess:
|
|
295
|
+
return subprocess.run(
|
|
296
|
+
["git", "-C", str(root), *args],
|
|
297
|
+
capture_output=True,
|
|
298
|
+
text=True,
|
|
299
|
+
encoding="utf-8",
|
|
300
|
+
errors="replace",
|
|
301
|
+
timeout=_GIT_TIMEOUT,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _run_gh(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
|
|
306
|
+
return subprocess.run(
|
|
307
|
+
["gh", *args],
|
|
308
|
+
cwd=str(cwd),
|
|
309
|
+
capture_output=True,
|
|
310
|
+
text=True,
|
|
311
|
+
encoding="utf-8",
|
|
312
|
+
errors="replace",
|
|
313
|
+
timeout=_GH_TIMEOUT,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _display(argv: list[str]) -> str:
|
|
318
|
+
return " ".join(argv)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _detail(proc: subprocess.CompletedProcess | None) -> str:
|
|
322
|
+
if proc is None:
|
|
323
|
+
return "no process result"
|
|
324
|
+
text = (getattr(proc, "stderr", "") or "").strip()
|
|
325
|
+
if not text:
|
|
326
|
+
text = (getattr(proc, "stdout", "") or "").strip()
|
|
327
|
+
if len(text) > _MAX_DETAIL_CHARS:
|
|
328
|
+
text = text[:_MAX_DETAIL_CHARS] + "..."
|
|
329
|
+
return text or f"exit {getattr(proc, 'returncode', '?')}"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _check(
|
|
333
|
+
proc: subprocess.CompletedProcess | None,
|
|
334
|
+
action: str,
|
|
335
|
+
result: GitWriteResult,
|
|
336
|
+
) -> None:
|
|
337
|
+
if proc is None or proc.returncode != 0:
|
|
338
|
+
raise _Failed(f"{action} failed: {_detail(proc)}")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _record(result: GitWriteResult, argv: list[str], proc: subprocess.CompletedProcess) -> None:
|
|
342
|
+
result.commands.append(_display(argv))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _git(
|
|
346
|
+
root: Path,
|
|
347
|
+
args: list[str],
|
|
348
|
+
run: GitRunner,
|
|
349
|
+
result: GitWriteResult,
|
|
350
|
+
) -> subprocess.CompletedProcess:
|
|
351
|
+
argv = ["git", "-C", str(root), *args]
|
|
352
|
+
try:
|
|
353
|
+
proc = run(root, args)
|
|
354
|
+
except FileNotFoundError as exc:
|
|
355
|
+
raise _Failed(f"git is not available: {exc}") from exc
|
|
356
|
+
_record(result, argv, proc)
|
|
357
|
+
return proc
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _gh(
|
|
361
|
+
root: Path,
|
|
362
|
+
argv: list[str],
|
|
363
|
+
run: GhRunner,
|
|
364
|
+
result: GitWriteResult,
|
|
365
|
+
) -> subprocess.CompletedProcess:
|
|
366
|
+
try:
|
|
367
|
+
proc = run(argv, root)
|
|
368
|
+
except FileNotFoundError as exc:
|
|
369
|
+
raise _Failed(f"gh CLI is not available: {exc}") from exc
|
|
370
|
+
_record(result, ["gh", *argv], proc)
|
|
371
|
+
return proc
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _current_branch(root: Path, run: GitRunner, result: GitWriteResult) -> str:
|
|
375
|
+
proc = _git(root, ["rev-parse", "--abbrev-ref", "HEAD"], run, result)
|
|
376
|
+
_check(proc, "read current branch", result)
|
|
377
|
+
return normalize_branch(proc.stdout)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _branch_exists(root: Path, branch: str, run: GitRunner, result: GitWriteResult) -> bool:
|
|
381
|
+
proc = _git(
|
|
382
|
+
root,
|
|
383
|
+
["rev-parse", "--verify", "--quiet", f"{PROTECTED_REF_PREFIX}{branch}"],
|
|
384
|
+
run,
|
|
385
|
+
result,
|
|
386
|
+
)
|
|
387
|
+
return proc.returncode == 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _require_approval(step: GitWriteStep, approve: Approver | None) -> None:
|
|
391
|
+
if approve is None:
|
|
392
|
+
raise _Refused(
|
|
393
|
+
f"no approver supplied; {step.name} requires explicit human approval"
|
|
394
|
+
)
|
|
395
|
+
try:
|
|
396
|
+
decision = bool(approve(step))
|
|
397
|
+
except Exception as exc: # a broken approver must fail closed
|
|
398
|
+
raise _Refused(
|
|
399
|
+
f"approver raised {type(exc).__name__}: {exc}"
|
|
400
|
+
) from exc
|
|
401
|
+
if not decision:
|
|
402
|
+
raise _Refused(f"{step.name} refused by reviewer")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _gate(
|
|
406
|
+
result: GitWriteResult,
|
|
407
|
+
approve: Approver | None,
|
|
408
|
+
step: GitWriteStep,
|
|
409
|
+
) -> None:
|
|
410
|
+
entry = step.as_dict()
|
|
411
|
+
entry["approved"] = False
|
|
412
|
+
result.steps.append(entry)
|
|
413
|
+
try:
|
|
414
|
+
_require_approval(step, approve)
|
|
415
|
+
except _Refused as exc:
|
|
416
|
+
entry["detail"] = str(exc)
|
|
417
|
+
raise
|
|
418
|
+
entry["approved"] = True
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _extract_url(text: str) -> str | None:
|
|
422
|
+
matches = _URL_RE.findall(text or "")
|
|
423
|
+
return matches[-1] if matches else None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# --------------------------------------------------------------------------- #
|
|
427
|
+
# Execute
|
|
428
|
+
# --------------------------------------------------------------------------- #
|
|
429
|
+
def execute_git_write(
|
|
430
|
+
plan: GitWritePlan,
|
|
431
|
+
*,
|
|
432
|
+
approve: Approver | None,
|
|
433
|
+
git_runner: GitRunner | None = None,
|
|
434
|
+
gh_runner: GhRunner | None = None,
|
|
435
|
+
) -> GitWriteResult:
|
|
436
|
+
"""Run the opt-in sequence: branch -> commit -> push -> PR.
|
|
437
|
+
|
|
438
|
+
Returns a result even on refusal/failure, so a receipt always names what
|
|
439
|
+
happened. Hard guards are checked before the first approval prompt.
|
|
440
|
+
"""
|
|
441
|
+
run_git = git_runner or _run_git
|
|
442
|
+
run_gh = gh_runner or _run_gh
|
|
443
|
+
result = GitWriteResult()
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
branch = assert_writable_branch(plan.branch)
|
|
447
|
+
base_branch = normalize_branch(plan.base_branch or "main")
|
|
448
|
+
if not base_branch:
|
|
449
|
+
raise GitWriteError("a pull-request base branch is required")
|
|
450
|
+
paths = _validate_paths(plan.paths)
|
|
451
|
+
if not paths:
|
|
452
|
+
raise GitWriteError("no approved changes to commit")
|
|
453
|
+
message = str(plan.message or "").strip()
|
|
454
|
+
if not message:
|
|
455
|
+
raise GitWriteError("a commit message is required")
|
|
456
|
+
safe_remote = assert_safe_remote(plan.remote)
|
|
457
|
+
except GitWriteError as exc:
|
|
458
|
+
result.reason = str(exc)
|
|
459
|
+
return result
|
|
460
|
+
|
|
461
|
+
result.branch = branch
|
|
462
|
+
info = git_info(plan.directory)
|
|
463
|
+
if not info.is_repo or info.root is None:
|
|
464
|
+
result.reason = f"not a git repository: {plan.directory}"
|
|
465
|
+
return result
|
|
466
|
+
root = info.root
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
# 1 — branch ------------------------------------------------------- #
|
|
470
|
+
current = _current_branch(root, run_git, result)
|
|
471
|
+
if normalize_branch(current) == branch and _branch_exists(
|
|
472
|
+
root, branch, run_git, result
|
|
473
|
+
):
|
|
474
|
+
result.steps.append(
|
|
475
|
+
{
|
|
476
|
+
"name": "branch",
|
|
477
|
+
"description": f"already on branch {branch!r}",
|
|
478
|
+
"command": None,
|
|
479
|
+
"approved": True,
|
|
480
|
+
"detail": "no branch creation needed",
|
|
481
|
+
}
|
|
482
|
+
)
|
|
483
|
+
else:
|
|
484
|
+
if _branch_exists(root, branch, run_git, result):
|
|
485
|
+
raise _Refused(
|
|
486
|
+
f"branch {branch!r} already exists; refusing to move an "
|
|
487
|
+
"existing branch"
|
|
488
|
+
)
|
|
489
|
+
args = branch_command(branch)
|
|
490
|
+
_gate(
|
|
491
|
+
result,
|
|
492
|
+
approve,
|
|
493
|
+
GitWriteStep(
|
|
494
|
+
"branch",
|
|
495
|
+
f"create branch {branch!r} from {current or 'HEAD'}",
|
|
496
|
+
_display(["git", "-C", str(root), *args]),
|
|
497
|
+
),
|
|
498
|
+
)
|
|
499
|
+
_check(_git(root, args, run_git, result), "create branch", result)
|
|
500
|
+
|
|
501
|
+
# 2 — commit only the approved paths ------------------------------- #
|
|
502
|
+
add_args = add_command(paths)
|
|
503
|
+
commit_args = commit_command(message, paths)
|
|
504
|
+
_gate(
|
|
505
|
+
result,
|
|
506
|
+
approve,
|
|
507
|
+
GitWriteStep(
|
|
508
|
+
"commit",
|
|
509
|
+
f"commit {len(paths)} approved path(s): {', '.join(paths)}",
|
|
510
|
+
_display(["git", "-C", str(root), *commit_args]),
|
|
511
|
+
),
|
|
512
|
+
)
|
|
513
|
+
_check(_git(root, add_args, run_git, result), "stage approved paths", result)
|
|
514
|
+
_check(
|
|
515
|
+
_git(root, commit_args, run_git, result),
|
|
516
|
+
"commit approved paths",
|
|
517
|
+
result,
|
|
518
|
+
)
|
|
519
|
+
head = _git(root, ["rev-parse", "HEAD"], run_git, result)
|
|
520
|
+
_check(head, "read commit sha", result)
|
|
521
|
+
result.commit = head.stdout.strip() or None
|
|
522
|
+
|
|
523
|
+
# 3 — push the branch (never main/master, never forced) ------------ #
|
|
524
|
+
push_args = push_command(safe_remote, branch)
|
|
525
|
+
_gate(
|
|
526
|
+
result,
|
|
527
|
+
approve,
|
|
528
|
+
GitWriteStep(
|
|
529
|
+
"push",
|
|
530
|
+
f"push branch {branch!r} to {safe_remote!r} (no force)",
|
|
531
|
+
_display(["git", "-C", str(root), *push_args]),
|
|
532
|
+
),
|
|
533
|
+
)
|
|
534
|
+
_check(
|
|
535
|
+
_git(root, push_args, run_git, result),
|
|
536
|
+
"push branch",
|
|
537
|
+
result,
|
|
538
|
+
)
|
|
539
|
+
result.pushed = True
|
|
540
|
+
|
|
541
|
+
# 4 — open the pull request via gh --------------------------------- #
|
|
542
|
+
if plan.open_pr:
|
|
543
|
+
title = (plan.pr_title or message.splitlines()[0])[:120]
|
|
544
|
+
body = plan.pr_body if plan.pr_body is not None else message
|
|
545
|
+
args = pr_command(branch, base=base_branch, title=title, body=body)
|
|
546
|
+
_gate(
|
|
547
|
+
result,
|
|
548
|
+
approve,
|
|
549
|
+
GitWriteStep(
|
|
550
|
+
"pr",
|
|
551
|
+
f"open a pull request {branch!r} -> {base_branch!r}",
|
|
552
|
+
_display(["gh", *args]),
|
|
553
|
+
),
|
|
554
|
+
)
|
|
555
|
+
proc = _gh(root, args, run_gh, result)
|
|
556
|
+
_check(proc, "open pull request", result)
|
|
557
|
+
url = _extract_url(proc.stdout)
|
|
558
|
+
if not url:
|
|
559
|
+
raise _Failed("gh pr create returned no pull-request URL")
|
|
560
|
+
result.pr_url = url
|
|
561
|
+
|
|
562
|
+
result.status = "completed"
|
|
563
|
+
return result
|
|
564
|
+
except _Refused as exc:
|
|
565
|
+
result.status = "refused"
|
|
566
|
+
result.reason = str(exc)
|
|
567
|
+
return result
|
|
568
|
+
except _Failed as exc:
|
|
569
|
+
result.status = "failed"
|
|
570
|
+
result.reason = str(exc)
|
|
571
|
+
return result
|
|
572
|
+
except GitWriteError as exc: # defensive: a builder guard tripped mid-way
|
|
573
|
+
result.status = "failed"
|
|
574
|
+
result.reason = str(exc)
|
|
575
|
+
return result
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
__all__ = [
|
|
579
|
+
"PROTECTED_BRANCHES",
|
|
580
|
+
"Approver",
|
|
581
|
+
"GitWriteError",
|
|
582
|
+
"GitWritePlan",
|
|
583
|
+
"GitWriteResult",
|
|
584
|
+
"GitWriteStep",
|
|
585
|
+
"add_command",
|
|
586
|
+
"assert_safe_remote",
|
|
587
|
+
"assert_writable_branch",
|
|
588
|
+
"branch_command",
|
|
589
|
+
"commit_command",
|
|
590
|
+
"contains_force_flag",
|
|
591
|
+
"default_branch_name",
|
|
592
|
+
"default_commit_message",
|
|
593
|
+
"execute_git_write",
|
|
594
|
+
"is_protected_branch",
|
|
595
|
+
"normalize_branch",
|
|
596
|
+
"pr_command",
|
|
597
|
+
"push_command",
|
|
598
|
+
]
|