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.
- gitpro/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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")
|