gitpro 0.3.1__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.
@@ -0,0 +1,178 @@
1
+ """Version 1 of the agent operation contract; no shell commands or arbitrary environment input."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ VERSION = 1
12
+ MAX_REQUEST = 131072
13
+ DECISIONS = ("ready", "blocked", "needs_input", "needs_approval", "indeterminate")
14
+ FIELDS = {
15
+ "commit": ("message", "author_name", "author_email"),
16
+ "push": ("remote", "branch"),
17
+ "pr-create": ("remote", "base", "head", "title", "body", "draft"),
18
+ }
19
+ REQUIRED = {
20
+ "commit": ("message",),
21
+ "push": ("remote", "branch"),
22
+ "pr-create": ("remote", "base", "head", "title", "body"),
23
+ }
24
+ TEXT = {"type": "string", "minLength": 1, "maxLength": 65536}
25
+ REQUEST_SCHEMA = {
26
+ "type": "object", "additionalProperties": False,
27
+ "required": ["version", "operation", "repo"],
28
+ "properties": {
29
+ "version": {"type": "integer", "enum": [VERSION]},
30
+ "operation": {"type": "string", "enum": list(FIELDS)},
31
+ "repo": {**TEXT, "description": "Absolute path to a worktree; never inferred from the server cwd."},
32
+ **{key: dict(TEXT) for key in sorted(set(sum(FIELDS.values(), ()))) if key not in ("draft", "body")},
33
+ "body": {"type": "string", "maxLength": 65536}, "draft": {"type": "boolean"},
34
+ },
35
+ "oneOf": [
36
+ {"properties": {"operation": {"const": op}},
37
+ "required": list(REQUIRED[op]),
38
+ "not": {"anyOf": [{"required": [key]} for key in sorted(set(sum(FIELDS.values(), ())) - set(fields))]}}
39
+ for op, fields in FIELDS.items()
40
+ ],
41
+ "dependentRequired": {"author_name": ["author_email"], "author_email": ["author_name"]},
42
+ }
43
+ RESULT_SCHEMA = {
44
+ "type": "object", "additionalProperties": False,
45
+ "required": ["version", "command", "ok", "decision", "problems"],
46
+ "properties": {
47
+ "version": {"const": VERSION}, "command": {"enum": ["preflight", "run", "operation-status"]},
48
+ "ok": {"type": "boolean"}, "decision": {"enum": list(DECISIONS)},
49
+ "problems": {"type": "array", "items": {
50
+ "type": "object", "required": ["code", "message", "decision"],
51
+ "properties": {"code": TEXT, "message": TEXT, "decision": {"enum": list(DECISIONS)},
52
+ "action": {"type": "object", "required": ["kind", "argv"],
53
+ "properties": {"kind": {"enum": ["inspect", "request_approval"]},
54
+ "argv": {"type": ["array", "null"],
55
+ "items": {"type": "string"}}}}},
56
+ }},
57
+ "operation": {"enum": [*FIELDS, None]}, "repo": {"type": ["string", "null"]},
58
+ "plan_token": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"},
59
+ "status": {"enum": ["succeeded", "failed", "blocked", "indeterminate"]},
60
+ "replayed": {"type": "boolean"}, "idempotency_key": {"type": "string"},
61
+ "outcome": {"type": "object", "additionalProperties": False,
62
+ "properties": {"commit": TEXT, "tree": TEXT, "remote_oid": TEXT, "pr_url": TEXT,
63
+ "already_present": {"type": "boolean"}}},
64
+ "checks": {"type": "array", "items": {
65
+ "type": "object", "required": ["code", "status", "message"],
66
+ "properties": {"code": TEXT, "status": {"enum": ["pass", "fail", "unknown", "skip"]},
67
+ "message": TEXT}}},
68
+ "identity": {"type": "object", "required": ["id", "name", "email", "sign", "ssh_key", "account"],
69
+ "properties": {"id": TEXT, "name": {"type": ["string", "null"]},
70
+ "email": {"type": ["string", "null"]}, "sign": {"type": "boolean"},
71
+ "ssh_key": {"type": ["string", "null"]},
72
+ "account": {"type": ["object", "null"],
73
+ "required": ["host", "cli", "login"],
74
+ "properties": {"host": TEXT, "cli": {"const": "gh"},
75
+ "login": TEXT}}}},
76
+ "destination": {"type": ["string", "null"]}, "head": {"type": ["string", "null"]},
77
+ "effects": {"type": "object", "required": ["persistent_configuration", "network", "child_identity_isolated"],
78
+ "properties": {"persistent_configuration": {"const": False}, "network": {"type": "boolean"},
79
+ "child_identity_isolated": {"const": True}}},
80
+ "authorization": TEXT,
81
+ },
82
+ }
83
+
84
+
85
+ class OperationError(Exception):
86
+ def __init__(self, code: str, message: str, decision: str = "indeterminate") -> None:
87
+ super().__init__(message)
88
+ self.code, self.decision = code, decision
89
+
90
+ def problem(self) -> dict:
91
+ return {"code": self.code, "message": str(self), "decision": self.decision}
92
+
93
+
94
+ def digest(value: object) -> str:
95
+ return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"),
96
+ ensure_ascii=True, default=str).encode()).hexdigest()
97
+
98
+
99
+ def unique_members(pairs: list[tuple[str, object]]) -> dict[str, object]:
100
+ out: dict[str, object] = {}
101
+ for key, value in pairs:
102
+ if key in out:
103
+ raise OperationError("invalid_request", "Duplicate JSON member.", "needs_input")
104
+ out[key] = value
105
+ return out
106
+
107
+
108
+ def decode(text: str, *, limit: int = MAX_REQUEST) -> object:
109
+ if len(text.encode("utf-8")) > limit:
110
+ raise OperationError("invalid_request", f"Request exceeds {limit // 1024} KiB.", "needs_input")
111
+ try:
112
+ return json.loads(text, object_pairs_hook=unique_members)
113
+ except (ValueError, RecursionError):
114
+ raise OperationError("invalid_request", "Request must be a JSON object.", "needs_input") from None
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class Request:
119
+ operation: str
120
+ repo: Path
121
+ values: dict
122
+
123
+ @classmethod
124
+ def parse(cls, raw: object) -> Request:
125
+ if not isinstance(raw, dict):
126
+ raise OperationError("invalid_request", "Request must be an object.", "needs_input")
127
+ if type(raw.get("version")) is not int or raw["version"] != VERSION:
128
+ raise OperationError("unsupported_version", "Request version must be 1.", "needs_input")
129
+ op = raw.get("operation")
130
+ if not isinstance(op, str) or op not in FIELDS:
131
+ raise OperationError("unsupported_operation", "Use commit, push or pr-create.", "needs_input")
132
+ if set(raw) - {"version", "operation", "repo", *FIELDS[op]}:
133
+ raise OperationError("invalid_request", "Unknown or inapplicable request field.", "needs_input")
134
+ for key in ("repo", *REQUIRED[op]):
135
+ if key not in raw:
136
+ raise OperationError("missing_input", f"Supply {key}.", "needs_input")
137
+ for key, value in raw.items():
138
+ if key in ("version", "operation"):
139
+ continue
140
+ if key == "draft":
141
+ if type(value) is not bool:
142
+ raise OperationError("invalid_request", "draft must be boolean.", "needs_input")
143
+ elif not isinstance(value, str) or "\0" in value or len(value) > 65536 or (not value.strip() and key != "body"):
144
+ raise OperationError("invalid_request", f"Invalid {key}.", "needs_input")
145
+ if len(json.dumps(raw, ensure_ascii=False).encode("utf-8")) > MAX_REQUEST:
146
+ raise OperationError("invalid_request", "Request exceeds 128 KiB.", "needs_input")
147
+ if ("author_name" in raw) != ("author_email" in raw):
148
+ raise OperationError("missing_input", "Supply both author_name and author_email.", "needs_input")
149
+ for key in ("author_name", "author_email", "remote", "branch", "base", "head", "title"):
150
+ if key in raw and (any(ord(c) < 32 or ord(c) == 127 for c in raw[key]) or
151
+ (key != "title" and raw[key].startswith("-"))):
152
+ raise OperationError("invalid_request", f"Invalid {key}.", "needs_input")
153
+ repo = Path(raw["repo"])
154
+ if not repo.is_absolute():
155
+ raise OperationError("invalid_request", "repo must be an absolute path.", "needs_input")
156
+ values = dict(raw, repo=str(repo.resolve()))
157
+ if op == "pr-create":
158
+ values.setdefault("draft", False)
159
+ return cls(op, repo.resolve(), values)
160
+
161
+ @property
162
+ def fingerprint(self) -> str:
163
+ return digest(self.values)
164
+
165
+
166
+ def request_key(value: str) -> str:
167
+ if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", value):
168
+ raise OperationError("invalid_key", "Use a 1-128 character alphanumeric idempotency key (._:- allowed).",
169
+ "needs_input")
170
+ return value
171
+
172
+
173
+ def failure(command: str, error: OperationError, request: Optional[Request] = None) -> dict:
174
+ return {"version": VERSION, "command": command, "ok": False, "decision": error.decision,
175
+ "operation": request.operation if request else None, "repo": str(request.repo) if request else None,
176
+ "problems": [error.problem()],
177
+ **({"status": "indeterminate" if error.decision == "indeterminate" else "blocked"}
178
+ if command == "run" else {})}
gitpro/operation_io.py ADDED
@@ -0,0 +1,256 @@
1
+ """Bounded child processes and durable, cooperative operation serialization (Linux/macOS)."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import fcntl
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import signal
10
+ import sqlite3
11
+ import subprocess
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Dict, Iterator, Optional, Sequence
18
+ from types import FrameType
19
+
20
+ from .operation_contract import OperationError, digest
21
+
22
+ MAX_OUTPUT = 1024 * 1024
23
+ TIMEOUT = 30
24
+ GRACE = 2
25
+
26
+
27
+ @contextlib.contextmanager
28
+ def termination_signals() -> Iterator[None]:
29
+ """Let a CLI/MCP SIGTERM unwind locks and stop the active child, like Ctrl-C."""
30
+ if threading.current_thread() is not threading.main_thread():
31
+ yield
32
+ return
33
+
34
+ def terminate(signum: int, frame: Optional[FrameType]) -> None:
35
+ raise KeyboardInterrupt
36
+
37
+ old = signal.signal(signal.SIGTERM, terminate)
38
+ try:
39
+ yield
40
+ finally:
41
+ signal.signal(signal.SIGTERM, old)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Process:
46
+ code: int
47
+ out: str
48
+ err: str
49
+ stopped: Optional[str] = None
50
+
51
+
52
+ def _signal_group(pid: int, sig: int) -> None:
53
+ try:
54
+ os.killpg(pid, sig)
55
+ except ProcessLookupError:
56
+ pass
57
+
58
+
59
+ def process(argv: Sequence[str], repo: Path, env: Dict[str, str], *,
60
+ text: Optional[str] = None, timeout: float = TIMEOUT) -> Process:
61
+ """No inherited stdin, shell, or unbounded pipe buffers; output is never logged here."""
62
+ with tempfile.TemporaryFile() as inp, tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
63
+ if text is not None:
64
+ inp.write(text.encode("utf-8"))
65
+ inp.seek(0)
66
+ child = subprocess.Popen(list(argv), cwd=repo, env=env, stdin=inp, stdout=out, stderr=err,
67
+ start_new_session=True)
68
+ stopped = None
69
+ deadline = time.monotonic() + timeout
70
+ try:
71
+ while child.poll() is None:
72
+ if os.fstat(out.fileno()).st_size + os.fstat(err.fileno()).st_size > MAX_OUTPUT:
73
+ stopped = "output_limit"
74
+ break
75
+ if time.monotonic() >= deadline:
76
+ stopped = "timeout"
77
+ break
78
+ time.sleep(0.02)
79
+ except KeyboardInterrupt:
80
+ stopped = "cancelled"
81
+ finally:
82
+ if stopped or child.poll() is None:
83
+ _signal_group(child.pid, signal.SIGTERM)
84
+ try:
85
+ child.wait(timeout=GRACE)
86
+ except subprocess.TimeoutExpired:
87
+ pass
88
+ # A hook/helper may outlive its leader. Never leave it running after this operation.
89
+ _signal_group(child.pid, signal.SIGKILL)
90
+ child.wait()
91
+ if os.fstat(out.fileno()).st_size + os.fstat(err.fileno()).st_size > MAX_OUTPUT:
92
+ stopped = stopped or "output_limit"
93
+ out.seek(0)
94
+ err.seek(0)
95
+ return Process(child.returncode, out.read(MAX_OUTPUT).decode("utf-8", "replace"),
96
+ err.read(MAX_OUTPUT).decode("utf-8", "replace"), stopped)
97
+
98
+
99
+ def file_hash(path: Path) -> Optional[str]:
100
+ try:
101
+ return hashlib.sha256(path.read_bytes()).hexdigest()
102
+ except FileNotFoundError:
103
+ return None
104
+
105
+
106
+ def state_dir() -> Path:
107
+ base = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
108
+ if not base.is_absolute():
109
+ raise OperationError("invalid_state_path", "XDG_STATE_HOME must be an absolute path; "
110
+ "no operation state was opened or created.", "needs_input")
111
+ return base / "gitpro" / "operations"
112
+
113
+
114
+ def sync_path(path: Path, *, directory: bool = False) -> None:
115
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | (os.O_DIRECTORY if directory else 0))
116
+ try:
117
+ os.fsync(fd)
118
+ finally:
119
+ os.close(fd)
120
+
121
+
122
+ def _private_dir(path: Path) -> None:
123
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
124
+ st = path.lstat()
125
+ if path.is_symlink() or st.st_uid != os.getuid() or st.st_mode & 0o077:
126
+ raise OperationError("unsafe_state", "The operation state directory must be owned by you and mode 0700.")
127
+
128
+
129
+ @contextlib.contextmanager
130
+ def repository_lock(common: Path) -> Iterator[None]:
131
+ with state_lock(digest(str(common.resolve()))):
132
+ yield
133
+
134
+
135
+ @contextlib.contextmanager
136
+ def configuration_lock(*, exclusive: bool = False) -> Iterator[None]:
137
+ """Shared during scoped execution; exclusive during approved configuration changes."""
138
+ with state_lock("configuration", shared=not exclusive):
139
+ yield
140
+
141
+
142
+ @contextlib.contextmanager
143
+ def state_lock(name: str, *, shared: bool = False) -> Iterator[None]:
144
+ root = state_dir()
145
+ _private_dir(root)
146
+ path = root / (name + ".lock")
147
+ fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
148
+ try:
149
+ try:
150
+ fcntl.flock(fd, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB)
151
+ except BlockingIOError:
152
+ raise OperationError("operation_busy", "Another gitpro operation holds the required lock.",
153
+ "blocked") from None
154
+ yield
155
+ finally:
156
+ os.close(fd) # Do not unlink: waiting callers must continue to lock the same inode.
157
+
158
+
159
+ @contextlib.contextmanager
160
+ def locked_index(index: Path, expected_hash: Optional[str]) -> Iterator[Path]:
161
+ """Freeze the ordinary index; Git and its hooks operate on a private copy."""
162
+ lock = index.with_name(index.name + ".lock")
163
+ try:
164
+ fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
165
+ except FileExistsError:
166
+ raise OperationError("index_busy", "Git's index is locked; no operation was started.") from None
167
+ st = os.fstat(fd)
168
+ os.close(fd)
169
+ try:
170
+ if file_hash(index) != expected_hash:
171
+ raise OperationError("stale_plan", "The staged index changed; run preflight again.", "blocked")
172
+ with tempfile.TemporaryDirectory(prefix="gitpro-index-") as tmp:
173
+ private = Path(tmp) / "index"
174
+ if index.exists():
175
+ private.write_bytes(index.read_bytes())
176
+ yield private
177
+ finally:
178
+ try:
179
+ current = lock.lstat()
180
+ except FileNotFoundError:
181
+ current = None
182
+ if current and (current.st_dev, current.st_ino) == (st.st_dev, st.st_ino):
183
+ lock.unlink()
184
+
185
+
186
+ class Ledger:
187
+ """A committed 'running' row precedes every write; unfinished rows are never auto-retried."""
188
+
189
+ def __init__(self, readonly: bool = False) -> None:
190
+ root = state_dir()
191
+ path = root / "ledger.sqlite3"
192
+ if readonly:
193
+ if not path.exists():
194
+ raise OperationError("unknown_request", "No operation has been recorded.", "needs_input")
195
+ self.db = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True, timeout=5)
196
+ else:
197
+ _private_dir(root)
198
+ fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
199
+ os.close(fd)
200
+ self.db = sqlite3.connect(path, timeout=5)
201
+ try:
202
+ self.db.execute("PRAGMA synchronous=FULL")
203
+ with self.db:
204
+ self.db.execute("CREATE TABLE IF NOT EXISTS operations "
205
+ "(key TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, "
206
+ "state TEXT NOT NULL, result TEXT)")
207
+ self.db.execute("CREATE TABLE IF NOT EXISTS operation_intents "
208
+ "(key TEXT PRIMARY KEY, intent TEXT NOT NULL, checksum TEXT NOT NULL)")
209
+ # Locks may have created this directory chain before the ledger was opened.
210
+ for parent in (root, *root.parents):
211
+ sync_path(parent, directory=True)
212
+ except (OSError, sqlite3.Error):
213
+ self.db.close()
214
+ raise
215
+
216
+ def close(self) -> None:
217
+ self.db.close()
218
+
219
+ def get(self, key: str, fingerprint: Optional[str] = None) -> Optional[dict]:
220
+ row = self.db.execute("SELECT fingerprint, state, result FROM operations WHERE key = ?", (key,)).fetchone()
221
+ if row is None:
222
+ return None
223
+ if fingerprint is not None and row[0] != fingerprint:
224
+ raise OperationError("idempotency_conflict", "This key belongs to a different request or plan.", "blocked")
225
+ if row[1] == "running":
226
+ raise OperationError("outcome_unknown", "This request is running or was interrupted. "
227
+ "Inspect its effects; do not retry it under a new key.")
228
+ result = json.loads(row[2])
229
+ return dict(result, replayed=True)
230
+
231
+ def claim(self, key: str, fingerprint: str, *, intent: Optional[dict] = None) -> None:
232
+ try:
233
+ with self.db:
234
+ self.db.execute("INSERT INTO operations VALUES (?, ?, 'running', NULL)", (key, fingerprint))
235
+ if intent is not None:
236
+ self.db.execute("INSERT INTO operation_intents VALUES (?, ?, ?)",
237
+ (key, json.dumps(intent), digest(intent)))
238
+ except sqlite3.IntegrityError:
239
+ raise OperationError("operation_busy", "This idempotency key was already claimed; query its status.") from None
240
+
241
+ def inspect(self, key: str) -> dict:
242
+ row = self.db.execute("SELECT fingerprint, state, result FROM operations WHERE key=?", (key,)).fetchone()
243
+ if row is None:
244
+ raise OperationError("unknown_request", "No operation has this key.", "needs_input")
245
+ exists = self.db.execute("SELECT 1 FROM sqlite_master WHERE name='operation_intents'").fetchone()
246
+ saved = self.db.execute("SELECT intent, checksum FROM operation_intents WHERE key=?", (key,)).fetchone() if exists else None
247
+ intent = json.loads(saved[0]) if saved else None
248
+ if saved and (not isinstance(intent, dict) or digest(intent) != saved[1]):
249
+ raise OperationError("journal_corrupt", "The recorded operation intent failed its integrity check.")
250
+ return {"fingerprint": row[0], "state": row[1],
251
+ "result": json.loads(row[2]) if row[2] is not None else None, "intent": intent}
252
+
253
+ def finish(self, key: str, result: dict) -> None:
254
+ with self.db:
255
+ self.db.execute("UPDATE operations SET state = ?, result = ? WHERE key = ?",
256
+ (result["status"], json.dumps(result), key))
@@ -0,0 +1,191 @@
1
+ """Read-only postcondition evidence, never a remote-write retry or proof of attribution."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import dataclasses
6
+ import sqlite3
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from . import config, operations as op, policy, resolve
11
+ from .operation_contract import OperationError, Request, digest, failure, request_key
12
+ from .operation_io import Ledger, configuration_lock, file_hash, repository_lock
13
+
14
+ SCHEMA = {
15
+ "type": "object", "additionalProperties": False,
16
+ "required": ["version", "command", "ok", "decision", "problems", "status", "retry_safe", "attribution"],
17
+ "properties": {
18
+ "version": {"const": 1}, "command": {"const": "operation-reconcile"},
19
+ "ok": {"type": "boolean"}, "decision": {"enum": ["ready", "blocked", "needs_input",
20
+ "needs_approval", "indeterminate"]},
21
+ "problems": {"type": "array", "items": {"type": "object"}},
22
+ "operation": {"type": ["string", "null"]}, "repo": {"type": ["string", "null"]},
23
+ "idempotency_key": {"type": "string"}, "ledger_status": {"type": "string"},
24
+ "status": {"enum": ["observed", "indeterminate"]},
25
+ "observation": {"type": "object", "additionalProperties": False, "properties": {
26
+ "commit": {"type": "string"}, "remote_oid": {"type": ["string", "null"]},
27
+ "pr_url": {"type": "string"}, "desired_state_observed": {"type": "boolean"}}},
28
+ "retry_safe": {"const": False}, "attribution": {"const": "not_proven"},
29
+ },
30
+ }
31
+
32
+
33
+ def capture(plan: op.Plan) -> dict:
34
+ return {"version": 1, "request": plan.request.values, "plan_token": plan.token,
35
+ "config_path": str(plan.cfg.path), "config_how": plan.cfg.how, "snapshot": plan.snapshot,
36
+ "common_dir": str(plan.common), "index": str(plan.index),
37
+ "destination": plan.destination, "expected_digest": digest(dataclasses.asdict(plan.report.expected)),
38
+ "account": dataclasses.asdict(plan.account) if plan.account else None, "project": plan.project}
39
+
40
+
41
+ def _context(intent: dict) -> op.Plan:
42
+ req = Request.parse(intent["request"])
43
+ env = op.child_environment()
44
+ path = Path(intent["config_path"])
45
+ cfg = config.load_all(path, intent["config_how"])
46
+ env["GITPRO_CONFIG"] = str(path)
47
+ index = Path(op.git(req.repo, env, "rev-parse", "--path-format=absolute", "--git-path", "index").strip())
48
+ common = Path(op.git(req.repo, env, "rev-parse", "--path-format=absolute", "--git-common-dir").strip())
49
+ top = op.git(req.repo, env, "rev-parse", "--show-toplevel").strip()
50
+ if common != Path(intent["common_dir"]) or Path(top).resolve() != req.repo or index != Path(intent["index"]):
51
+ raise OperationError("reconciliation_context_changed", "Repository layout changed; inspect the original destination.")
52
+ destination = op._destination(req, env)
53
+ report = resolve.compare(req.repo, cfg, remote=req.values.get("remote"), remote_url=destination,
54
+ env=env, git_env=env, extra_checks=False)
55
+ snapshot = op._snapshot(req, cfg, env, index)
56
+ for key in ("git_config", "config", "config_file", "git_trust"):
57
+ if snapshot[key] != intent["snapshot"][key]:
58
+ raise OperationError("reconciliation_context_changed", "Configuration or trust changed; automatic observation "
59
+ "will not borrow another identity.")
60
+ if destination != intent["destination"] or digest(dataclasses.asdict(report.expected)) != intent["expected_digest"]:
61
+ raise OperationError("reconciliation_context_changed", "The destination or configured identity changed.")
62
+ plan = op.Plan(req, cfg, env, report, common, index, dict(intent["snapshot"]), destination)
63
+ op._policies(plan)
64
+ for key in ("policies", "signers"):
65
+ if plan.snapshot[key] != intent["snapshot"][key]:
66
+ raise OperationError("reconciliation_context_changed", "Applicable policy/trust files changed.")
67
+ key_stat = intent["snapshot"].get("ssh_key_stat")
68
+ if key_stat and op._key_stat(Path(key_stat[0])) != key_stat:
69
+ raise OperationError("reconciliation_context_changed", "The selected SSH key changed.")
70
+ if req.operation != "commit" and intent["account"] is not None:
71
+ op._account(plan)
72
+ if plan.account is None or dataclasses.asdict(plan.account) != intent["account"] or plan.project != intent["project"]:
73
+ raise OperationError("reconciliation_context_changed", "The selected forge account changed.")
74
+ return plan
75
+
76
+
77
+ def _commit(plan: op.Plan) -> dict:
78
+ req, env, exp = plan.request, plan.env, plan.report.expected
79
+ head = op.git(req.repo, env, "rev-parse", "--verify", "--quiet", "HEAD", codes=(0, 1)).strip()
80
+ if not head:
81
+ return {"desired_state_observed": False}
82
+ op._oid(head)
83
+ observation = {"commit": head, "desired_state_observed": False}
84
+ if head == plan.snapshot["head"] or file_hash(plan.index) != plan.snapshot["index"]:
85
+ return observation
86
+ author_name = req.values.get("author_name", exp.name)
87
+ author_email = req.values.get("author_email", exp.email)
88
+ meta = op.git(req.repo, env, "show", "-s", "--format=%P%x00%cn%x00%ce%x00%an%x00%ae", head).rstrip("\n").split("\0")
89
+ if meta != [plan.snapshot["head"] or "", exp.name, exp.email, author_name, author_email]:
90
+ return observation
91
+ diff = op.process(["git", "diff", "--cached", "--quiet", "--no-ext-diff", head, "--"], req.repo, env)
92
+ op._checked(diff, "Reviewed index comparison", (0, 1))
93
+ if diff.code:
94
+ return observation
95
+ body = op.git(req.repo, env, "cat-file", "commit", head).encode()
96
+ message = body.split(b"\n\n", 1)[1].decode("utf-8")
97
+ if message.rstrip("\n") != req.values["message"].rstrip("\n"):
98
+ return observation
99
+ fmt = policy._sig_format(body)
100
+ if (fmt is not None) != exp.sign:
101
+ return observation
102
+ op._message_problems(plan, message, author_email or "", exp.email or "", signed=fmt is not None,
103
+ signing_format=fmt)
104
+ if exp.sign:
105
+ op._verify_signature(plan, head, fmt, exp.email or "")
106
+ observation["desired_state_observed"] = not plan.problems
107
+ return observation
108
+
109
+
110
+ def _pr(plan: op.Plan) -> dict:
111
+ req = plan.request.values
112
+ op._account(plan)
113
+ op._authenticate(plan)
114
+ owner = str(plan.project).split("/")[0]
115
+ candidates = []
116
+ for page in range(1, 11):
117
+ rows = op._api(plan, "repos/" + str(plan.project) + "/pulls",
118
+ params={"state": "all", "head": owner + ":" + req["head"], "base": req["base"],
119
+ "per_page": "100", "page": str(page)})
120
+ if not isinstance(rows, list) or len(rows) > 100 or any(not isinstance(row, dict) for row in rows):
121
+ raise OperationError("invalid_response", "GitHub returned an invalid PR listing.")
122
+ for row in rows:
123
+ head, base = row.get("head"), row.get("base")
124
+ if not isinstance(head, dict) or not isinstance(base, dict):
125
+ raise OperationError("invalid_response", "A PR is missing branch metadata.")
126
+ if any(not isinstance(side.get("repo"), dict) for side in (head, base)):
127
+ raise OperationError("invalid_response", "A PR is missing repository metadata.")
128
+ matches = (head.get("ref") == req["head"] and base.get("ref") == req["base"] and
129
+ head["repo"].get("full_name") == plan.project and base["repo"].get("full_name") == plan.project and
130
+ head.get("sha") == plan.snapshot["remote_refs"][req["head"]] and
131
+ base.get("sha") == plan.snapshot["remote_refs"][req["base"]])
132
+ if not matches:
133
+ continue
134
+ url = op._pr_url(plan, row)
135
+ if url == plan.snapshot.get("existing_pr") or (
136
+ row.get("title") == req["title"] and (row.get("body") or "") == req["body"] and
137
+ row.get("draft") is req["draft"]):
138
+ candidates.append(url)
139
+ if len(rows) < 100:
140
+ break
141
+ else:
142
+ raise OperationError("reconciliation_limit", "More than 1000 PRs require separate inspection.")
143
+ if len(candidates) != 1:
144
+ return {"desired_state_observed": False}
145
+ return {"pr_url": candidates[0], "desired_state_observed": True}
146
+
147
+
148
+ def reconcile(key: str, *, online: bool = False) -> dict:
149
+ req: Optional[Request] = None
150
+ try:
151
+ request_key(key)
152
+ with contextlib.closing(Ledger(readonly=True)) as ledger:
153
+ saved = ledger.inspect(key)
154
+ intent = saved["intent"]
155
+ if intent is None:
156
+ raise OperationError("intent_unavailable", "This older record has no durable intent; inspect its effects "
157
+ "manually. Do not repeat the write.", "needs_input")
158
+ req = Request.parse(intent["request"])
159
+ if saved["fingerprint"] != digest({"request": req.fingerprint, "plan": intent["plan_token"]}):
160
+ raise OperationError("journal_corrupt", "Recorded request and plan do not match the operation key.")
161
+ if req.operation != "commit" and not online:
162
+ raise OperationError("network_approval", "Remote observation requires explicit --online.", "needs_approval")
163
+ with configuration_lock(), repository_lock(Path(intent["common_dir"])):
164
+ plan = _context(intent)
165
+ if req.operation == "commit":
166
+ observation = _commit(plan)
167
+ elif req.operation == "push":
168
+ op._transport(plan, True)
169
+ current = op._remote_head(plan)
170
+ observation = {"remote_oid": current, "desired_state_observed": current == plan.snapshot["head"]}
171
+ else:
172
+ observation = _pr(plan)
173
+ # Account selection is derived from the unchanged config/destination, never caller-supplied credentials.
174
+ if req.operation != "commit" and (
175
+ (dataclasses.asdict(plan.account) if plan.account else None) != intent["account"] or
176
+ plan.project != intent["project"]):
177
+ raise OperationError("reconciliation_context_changed", "Forge account/project changed.")
178
+ observed = observation["desired_state_observed"]
179
+ return {"version": 1, "command": "operation-reconcile", "ok": observed,
180
+ "decision": "ready" if observed else "indeterminate",
181
+ "operation": req.operation, "repo": str(req.repo), "idempotency_key": key,
182
+ "status": "observed" if observed else "indeterminate", "ledger_status": saved["state"],
183
+ "observation": observation, "retry_safe": False, "attribution": "not_proven",
184
+ "problems": [] if observed else [OperationError("outcome_unconfirmed",
185
+ "The reviewed postcondition is not uniquely observable. Inspect effects; absence is not retry permission.").problem()]}
186
+ except OperationError as exc:
187
+ result = failure("operation-reconcile", exc, req)
188
+ except (OSError, ValueError, KeyError, TypeError, config.ConfigError, sqlite3.Error, KeyboardInterrupt):
189
+ result = failure("operation-reconcile", OperationError("reconciliation_failed", "Observation did not complete; "
190
+ "the operation remains unconfirmed. No write was retried."), req)
191
+ return dict(result, status="indeterminate", retry_safe=False, attribution="not_proven")