cfgit 0.1.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.
- cfg/__init__.py +13 -0
- cfg/adapters/__init__.py +25 -0
- cfg/adapters/base.py +127 -0
- cfg/adapters/mongo.py +570 -0
- cfg/adapters/postgres.py +756 -0
- cfg/approval/__init__.py +5 -0
- cfg/approval/base.py +29 -0
- cfg/cli/__init__.py +2 -0
- cfg/cli/main.py +665 -0
- cfg/core/__init__.py +13 -0
- cfg/core/authz.py +58 -0
- cfg/core/config.py +324 -0
- cfg/core/diff.py +43 -0
- cfg/core/engine.py +1388 -0
- cfg/core/hashing.py +102 -0
- cfg/core/identity.py +213 -0
- cfg/interfaces/__init__.py +2 -0
- cfg/interfaces/actions.py +598 -0
- cfg/mcp/__init__.py +10 -0
- cfg/mcp/server.py +452 -0
- cfg/ui/__init__.py +2 -0
- cfg/ui/server.py +1066 -0
- cfgit-0.1.0.dist-info/METADATA +744 -0
- cfgit-0.1.0.dist-info/RECORD +28 -0
- cfgit-0.1.0.dist-info/WHEEL +4 -0
- cfgit-0.1.0.dist-info/entry_points.txt +3 -0
- cfgit-0.1.0.dist-info/licenses/LICENSE +201 -0
- cfgit-0.1.0.dist-info/licenses/NOTICE +10 -0
cfg/core/engine.py
ADDED
|
@@ -0,0 +1,1388 @@
|
|
|
1
|
+
# Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""DB-neutral cfgit engine."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, time, timezone
|
|
7
|
+
import fnmatch
|
|
8
|
+
import re
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from cfg.adapters.base import AtomicityUnavailable, NoSuchConfig, StorageAdapter
|
|
13
|
+
from cfg.core.authz import authorize_mutation
|
|
14
|
+
from cfg.core.config import CollectionConfig, ProjectConfig
|
|
15
|
+
from cfg.core.diff import diff_values
|
|
16
|
+
from cfg.core.hashing import hash_doc, stored_doc, strip_for_hash
|
|
17
|
+
from cfg.core.identity import Identity, self_asserted_identity
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RecordRef:
|
|
22
|
+
collection: str
|
|
23
|
+
record_id: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class StatusRow:
|
|
28
|
+
collection: str
|
|
29
|
+
record_id: str
|
|
30
|
+
state: str
|
|
31
|
+
live_oid: str | None
|
|
32
|
+
head_oid: str | None
|
|
33
|
+
head_seq: int | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SecretBlocked(ValueError):
|
|
37
|
+
"""A configured secret deny-list matched a document about to enter history."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BranchingDisabled(ValueError):
|
|
41
|
+
"""Branch/PR commands require [branches] enabled = true."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Engine:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
config: ProjectConfig,
|
|
48
|
+
adapter: StorageAdapter,
|
|
49
|
+
*,
|
|
50
|
+
env: str,
|
|
51
|
+
author: str | None = None,
|
|
52
|
+
identity: Identity | None = None,
|
|
53
|
+
):
|
|
54
|
+
self.config = config
|
|
55
|
+
self.adapter = adapter
|
|
56
|
+
self.env = env
|
|
57
|
+
env_cfg = config.envs[env]
|
|
58
|
+
self.identity = identity or self_asserted_identity(author or "", cfg=env_cfg.identity)
|
|
59
|
+
self.author = self.identity.author
|
|
60
|
+
|
|
61
|
+
def init(self) -> dict[str, Any]:
|
|
62
|
+
self._authorize("init")
|
|
63
|
+
self.adapter.ensure_schema()
|
|
64
|
+
return {
|
|
65
|
+
"atomic": self.adapter.check_atomicity_scope(),
|
|
66
|
+
"invariant_violations": self.adapter.check_runtime_invariant(),
|
|
67
|
+
"branches": {
|
|
68
|
+
"enabled": self.config.branches.enabled,
|
|
69
|
+
"refs_collection": self.config.branches.refs_collection if self.config.branches.enabled else None,
|
|
70
|
+
"default_branch": self.config.branches.default_branch,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def branch_list(self) -> list[dict[str, Any]]:
|
|
75
|
+
self._ensure_branching()
|
|
76
|
+
default = self.config.branches.default_branch
|
|
77
|
+
rows = [
|
|
78
|
+
{
|
|
79
|
+
"type": "branch",
|
|
80
|
+
"id": default,
|
|
81
|
+
"name": default,
|
|
82
|
+
"base_branch": None,
|
|
83
|
+
"head_commit_id": None,
|
|
84
|
+
"created_at": None,
|
|
85
|
+
"updated_at": None,
|
|
86
|
+
"author": None,
|
|
87
|
+
"message": "runtime branch",
|
|
88
|
+
"runtime_mutated": False,
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
rows.extend(self.adapter.list_refs("branch"))
|
|
92
|
+
return sorted(rows, key=lambda row: (row["name"] != default, row["name"]))
|
|
93
|
+
|
|
94
|
+
def branch_create(self, name: str, *, from_branch: str | None = None, message: str | None = None) -> dict[str, Any]:
|
|
95
|
+
self._authorize("branch")
|
|
96
|
+
self._ensure_branching()
|
|
97
|
+
name = _clean_branch_name(name)
|
|
98
|
+
default = self.config.branches.default_branch
|
|
99
|
+
base = _clean_branch_name(from_branch or default)
|
|
100
|
+
if name == default:
|
|
101
|
+
raise ValueError(f"{default!r} is the runtime branch and cannot be created")
|
|
102
|
+
if self.adapter.get_ref("branch", name):
|
|
103
|
+
raise ValueError(f"branch already exists: {name}")
|
|
104
|
+
if base != default and not self.adapter.get_ref("branch", base):
|
|
105
|
+
raise NoSuchConfig(f"base branch not found: {base}")
|
|
106
|
+
now = self.adapter.now()
|
|
107
|
+
base_ref = None if base == default else self.adapter.get_ref("branch", base)
|
|
108
|
+
doc = {
|
|
109
|
+
"type": "branch",
|
|
110
|
+
"id": name,
|
|
111
|
+
"name": name,
|
|
112
|
+
"base_branch": base,
|
|
113
|
+
"base_commit_id": base_ref.get("head_commit_id") if base_ref else None,
|
|
114
|
+
"head_commit_id": None,
|
|
115
|
+
"author": self.author,
|
|
116
|
+
"message": str(message or f"create branch {name}"),
|
|
117
|
+
"created_at": now,
|
|
118
|
+
"updated_at": now,
|
|
119
|
+
"runtime_mutated": False,
|
|
120
|
+
}
|
|
121
|
+
self.adapter.put_ref(doc)
|
|
122
|
+
return doc
|
|
123
|
+
|
|
124
|
+
def branch_delete(self, name: str) -> dict[str, Any]:
|
|
125
|
+
self._authorize("branch")
|
|
126
|
+
self._ensure_branching()
|
|
127
|
+
name = _clean_branch_name(name)
|
|
128
|
+
default = self.config.branches.default_branch
|
|
129
|
+
if name == default:
|
|
130
|
+
raise ValueError(f"{default!r} is the runtime branch and cannot be deleted")
|
|
131
|
+
if not self.adapter.get_ref("branch", name):
|
|
132
|
+
raise NoSuchConfig(f"branch not found: {name}")
|
|
133
|
+
open_prs = self.adapter.list_refs("pr", head_branch=name, status="open")
|
|
134
|
+
if open_prs:
|
|
135
|
+
ids = ", ".join(str(pr["id"]) for pr in open_prs)
|
|
136
|
+
raise ValueError(f"branch has open PR(s): {ids}")
|
|
137
|
+
self.adapter.delete_ref("branch", name)
|
|
138
|
+
return {"state": "deleted", "branch": name, "runtime_mutated": False}
|
|
139
|
+
|
|
140
|
+
def branch_current(self, name: str | None) -> dict[str, Any]:
|
|
141
|
+
self._ensure_branching()
|
|
142
|
+
branch = _clean_branch_name(name or self.config.branches.default_branch)
|
|
143
|
+
self._require_branch(branch)
|
|
144
|
+
return {"branch": branch, "runtime_mutated": False}
|
|
145
|
+
|
|
146
|
+
def branch_commit(
|
|
147
|
+
self,
|
|
148
|
+
branch: str,
|
|
149
|
+
ref: RecordRef,
|
|
150
|
+
doc: dict[str, Any],
|
|
151
|
+
*,
|
|
152
|
+
message: str,
|
|
153
|
+
allow_secret: bool = False,
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
self._authorize("commit")
|
|
156
|
+
self._ensure_branching()
|
|
157
|
+
branch_ref = self._require_branch(_clean_branch_name(branch))
|
|
158
|
+
if branch_ref is None:
|
|
159
|
+
raise ValueError("committing to main mutates runtime; omit --branch or use cfg commit on main")
|
|
160
|
+
plan = self._branch_commit_plan(
|
|
161
|
+
branch_ref,
|
|
162
|
+
ref,
|
|
163
|
+
doc,
|
|
164
|
+
message=message,
|
|
165
|
+
allow_secret=allow_secret,
|
|
166
|
+
)
|
|
167
|
+
if plan["state"] != "ready":
|
|
168
|
+
return {key: value for key, value in plan.items() if key != "branch_ref"}
|
|
169
|
+
self.adapter.put_ref(plan["commit"])
|
|
170
|
+
updated_branch = {**branch_ref, "head_commit_id": plan["commit"]["id"], "updated_at": plan["commit"]["created_at"]}
|
|
171
|
+
self.adapter.put_ref(updated_branch)
|
|
172
|
+
return {
|
|
173
|
+
"state": "committed",
|
|
174
|
+
"branch": branch_ref["name"],
|
|
175
|
+
"commit_id": plan["commit"]["id"],
|
|
176
|
+
"collection": ref.collection,
|
|
177
|
+
"record_id": ref.record_id,
|
|
178
|
+
"oid": plan["commit"]["oid"],
|
|
179
|
+
"runtime_mutated": False,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
def branch_commit_many(
|
|
183
|
+
self,
|
|
184
|
+
branch: str,
|
|
185
|
+
items: list[tuple[RecordRef, dict[str, Any]]],
|
|
186
|
+
*,
|
|
187
|
+
message: str,
|
|
188
|
+
allow_secret: bool = False,
|
|
189
|
+
) -> dict[str, Any]:
|
|
190
|
+
self._authorize("commit")
|
|
191
|
+
self._ensure_branching()
|
|
192
|
+
branch_ref = self._require_branch(_clean_branch_name(branch))
|
|
193
|
+
if branch_ref is None:
|
|
194
|
+
raise ValueError("bulk branch commit needs a non-main branch")
|
|
195
|
+
message = _require_message(message)
|
|
196
|
+
if not items:
|
|
197
|
+
raise ValueError("bulk branch commit needs at least one item")
|
|
198
|
+
|
|
199
|
+
seen: set[tuple[str, str]] = set()
|
|
200
|
+
plans: list[dict[str, Any]] = []
|
|
201
|
+
blocked: list[dict[str, Any]] = []
|
|
202
|
+
for index, (ref, doc) in enumerate(items, start=1):
|
|
203
|
+
key = (ref.collection, ref.record_id)
|
|
204
|
+
if key in seen:
|
|
205
|
+
raise ValueError(f"duplicate record in bulk branch commit: {ref.collection}:{ref.record_id}")
|
|
206
|
+
seen.add(key)
|
|
207
|
+
plan = self._branch_commit_plan(
|
|
208
|
+
branch_ref,
|
|
209
|
+
ref,
|
|
210
|
+
doc,
|
|
211
|
+
message=message,
|
|
212
|
+
allow_secret=allow_secret,
|
|
213
|
+
bulk_index=index,
|
|
214
|
+
bulk_count=len(items),
|
|
215
|
+
)
|
|
216
|
+
if plan["state"] in {"missing", "changed_outside_cfgit"}:
|
|
217
|
+
blocked.append(_branch_plan_result(plan))
|
|
218
|
+
else:
|
|
219
|
+
plans.append(plan)
|
|
220
|
+
if blocked:
|
|
221
|
+
return {"state": "blocked", "branch": branch_ref["name"], "results": [], "failed": blocked, "runtime_mutated": False}
|
|
222
|
+
|
|
223
|
+
results = []
|
|
224
|
+
last_commit_id = branch_ref.get("head_commit_id")
|
|
225
|
+
updated_at = branch_ref.get("updated_at")
|
|
226
|
+
for plan in plans:
|
|
227
|
+
if plan["state"] == "noop":
|
|
228
|
+
results.append(_branch_plan_result(plan))
|
|
229
|
+
continue
|
|
230
|
+
self.adapter.put_ref(plan["commit"])
|
|
231
|
+
last_commit_id = plan["commit"]["id"]
|
|
232
|
+
updated_at = plan["commit"]["created_at"]
|
|
233
|
+
results.append(_branch_plan_result(plan, state="committed"))
|
|
234
|
+
if last_commit_id != branch_ref.get("head_commit_id"):
|
|
235
|
+
self.adapter.put_ref({**branch_ref, "head_commit_id": last_commit_id, "updated_at": updated_at})
|
|
236
|
+
state = "noop" if all(item["state"] == "noop" for item in results) else "committed"
|
|
237
|
+
return {"state": state, "branch": branch_ref["name"], "results": results, "runtime_mutated": False}
|
|
238
|
+
|
|
239
|
+
def branch_log(self, branch: str, *, limit: int | None = 20) -> list[dict[str, Any]]:
|
|
240
|
+
self._ensure_branching()
|
|
241
|
+
branch_name = _clean_branch_name(branch)
|
|
242
|
+
self._require_branch(branch_name)
|
|
243
|
+
rows = self.adapter.list_refs("branch_commit", branch=branch_name)
|
|
244
|
+
rows = sorted(rows, key=lambda row: row["created_at"], reverse=True)
|
|
245
|
+
if limit is not None:
|
|
246
|
+
rows = rows[:limit]
|
|
247
|
+
return rows
|
|
248
|
+
|
|
249
|
+
def branch_diff(self, range_expr: str) -> dict[str, Any]:
|
|
250
|
+
self._ensure_branching()
|
|
251
|
+
base, head = _parse_branch_range(range_expr, self.config.branches.default_branch)
|
|
252
|
+
if base != self.config.branches.default_branch:
|
|
253
|
+
raise ValueError("v1 branch diff supports only main..<branch>")
|
|
254
|
+
self._require_branch(head)
|
|
255
|
+
latest = self._branch_latest_by_record(head)
|
|
256
|
+
records = []
|
|
257
|
+
for ref, commit in sorted(latest.items(), key=lambda item: (item[0].collection, item[0].record_id)):
|
|
258
|
+
coll = self.config.collection(ref.collection)
|
|
259
|
+
base_doc = self._main_doc(ref)
|
|
260
|
+
left = strip_for_hash(base_doc or {}, coll)
|
|
261
|
+
right = strip_for_hash(commit["doc"], coll)
|
|
262
|
+
changes = diff_values(left, right)
|
|
263
|
+
if changes:
|
|
264
|
+
records.append(
|
|
265
|
+
{
|
|
266
|
+
"collection": ref.collection,
|
|
267
|
+
"record_id": ref.record_id,
|
|
268
|
+
"base_oid": hash_doc(base_doc, coll) if base_doc else None,
|
|
269
|
+
"branch_oid": commit["oid"],
|
|
270
|
+
"commit_id": commit["id"],
|
|
271
|
+
"changes": changes,
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
return {"range": f"{base}..{head}", "records": records, "runtime_mutated": False}
|
|
275
|
+
|
|
276
|
+
def pr_create(
|
|
277
|
+
self,
|
|
278
|
+
*,
|
|
279
|
+
base: str,
|
|
280
|
+
head: str,
|
|
281
|
+
message: str,
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
self._authorize("pr")
|
|
284
|
+
self._ensure_branching()
|
|
285
|
+
default = self.config.branches.default_branch
|
|
286
|
+
base = _clean_branch_name(base)
|
|
287
|
+
head = _clean_branch_name(head)
|
|
288
|
+
if base != default:
|
|
289
|
+
raise ValueError("v1 PRs can target only main")
|
|
290
|
+
branch_ref = self._require_branch(head)
|
|
291
|
+
if branch_ref is None:
|
|
292
|
+
raise ValueError("PR head must be a non-main branch")
|
|
293
|
+
commits = self.branch_log(head, limit=None)
|
|
294
|
+
if not commits:
|
|
295
|
+
raise ValueError(f"branch has no commits: {head}")
|
|
296
|
+
latest = self._branch_latest_by_record(head)
|
|
297
|
+
now = self.adapter.now()
|
|
298
|
+
pr_id = f"pr_{uuid.uuid4().hex[:12]}"
|
|
299
|
+
doc = {
|
|
300
|
+
"type": "pr",
|
|
301
|
+
"id": pr_id,
|
|
302
|
+
"status": "open",
|
|
303
|
+
"base_branch": base,
|
|
304
|
+
"head_branch": head,
|
|
305
|
+
"head_commit_id": branch_ref.get("head_commit_id"),
|
|
306
|
+
"commit_ids": [row["id"] for row in commits],
|
|
307
|
+
"records": [
|
|
308
|
+
{
|
|
309
|
+
"collection": ref.collection,
|
|
310
|
+
"record_id": ref.record_id,
|
|
311
|
+
"commit_id": commit["id"],
|
|
312
|
+
"oid": commit["oid"],
|
|
313
|
+
}
|
|
314
|
+
for ref, commit in sorted(latest.items(), key=lambda item: (item[0].collection, item[0].record_id))
|
|
315
|
+
],
|
|
316
|
+
"message": _require_message(message),
|
|
317
|
+
"author": self.author,
|
|
318
|
+
"created_at": now,
|
|
319
|
+
"updated_at": now,
|
|
320
|
+
"runtime_mutated": False,
|
|
321
|
+
}
|
|
322
|
+
self.adapter.put_ref(doc)
|
|
323
|
+
return doc
|
|
324
|
+
|
|
325
|
+
def pr_list(self, *, status: str | None = None) -> list[dict[str, Any]]:
|
|
326
|
+
self._ensure_branching()
|
|
327
|
+
filters = {"status": status} if status else {}
|
|
328
|
+
return self.adapter.list_refs("pr", **filters)
|
|
329
|
+
|
|
330
|
+
def pr_show(self, pr_id: str) -> dict[str, Any]:
|
|
331
|
+
self._ensure_branching()
|
|
332
|
+
pr = self.adapter.get_ref("pr", pr_id)
|
|
333
|
+
if not pr:
|
|
334
|
+
raise NoSuchConfig(f"PR not found: {pr_id}")
|
|
335
|
+
return pr
|
|
336
|
+
|
|
337
|
+
def pr_close(self, pr_id: str) -> dict[str, Any]:
|
|
338
|
+
self._authorize("pr")
|
|
339
|
+
self._ensure_branching()
|
|
340
|
+
pr = self.pr_show(pr_id)
|
|
341
|
+
if pr["status"] != "open":
|
|
342
|
+
raise ValueError(f"PR is not open: {pr_id}")
|
|
343
|
+
now = self.adapter.now()
|
|
344
|
+
closed = {**pr, "status": "closed", "closed_at": now, "updated_at": now, "runtime_mutated": False}
|
|
345
|
+
self.adapter.put_ref(closed)
|
|
346
|
+
return closed
|
|
347
|
+
|
|
348
|
+
def pr_merge(self, pr_id: str, *, message: str | None = None) -> dict[str, Any]:
|
|
349
|
+
self._authorize("merge")
|
|
350
|
+
self._ensure_branching()
|
|
351
|
+
self._ensure_atomic("merge")
|
|
352
|
+
pr = self.pr_show(pr_id)
|
|
353
|
+
if pr["status"] != "open":
|
|
354
|
+
raise ValueError(f"PR is not open: {pr_id}")
|
|
355
|
+
if pr["base_branch"] != self.config.branches.default_branch:
|
|
356
|
+
raise ValueError("v1 PR merge supports only main as base")
|
|
357
|
+
branch_ref = self._require_branch(pr["head_branch"])
|
|
358
|
+
if branch_ref is None:
|
|
359
|
+
raise ValueError("PR head branch is main")
|
|
360
|
+
if branch_ref.get("head_commit_id") != pr.get("head_commit_id"):
|
|
361
|
+
raise ValueError("PR is stale: head branch moved after PR creation")
|
|
362
|
+
latest = self._branch_latest_by_record(pr["head_branch"])
|
|
363
|
+
if len(latest) > 1:
|
|
364
|
+
raise AtomicityUnavailable(
|
|
365
|
+
"multi-record PR merge needs adapter-level batch atomicity; split into one-record PRs for v1"
|
|
366
|
+
)
|
|
367
|
+
if not latest:
|
|
368
|
+
raise ValueError("PR has no records to merge")
|
|
369
|
+
|
|
370
|
+
ref, commit = next(iter(latest.items()))
|
|
371
|
+
coll = self.config.collection(ref.collection)
|
|
372
|
+
live = self.adapter.get_record(ref.collection, ref.record_id)
|
|
373
|
+
if live is None:
|
|
374
|
+
raise NoSuchConfig(f"{ref.collection}:{ref.record_id}")
|
|
375
|
+
head = self.adapter.get_head(ref.collection, ref.record_id)
|
|
376
|
+
expected_head = head.get("oid") if head else None
|
|
377
|
+
expected_live = hash_doc(live, coll)
|
|
378
|
+
base_head = (commit.get("meta") or {}).get("base_head_oid")
|
|
379
|
+
if expected_head != base_head:
|
|
380
|
+
return {
|
|
381
|
+
"state": "stale",
|
|
382
|
+
"reason": "main moved since branch commit",
|
|
383
|
+
"collection": ref.collection,
|
|
384
|
+
"record_id": ref.record_id,
|
|
385
|
+
"head_oid": expected_head,
|
|
386
|
+
"branch_base_oid": base_head,
|
|
387
|
+
"runtime_mutated": False,
|
|
388
|
+
}
|
|
389
|
+
if head and expected_live != expected_head:
|
|
390
|
+
return {
|
|
391
|
+
"state": "changed_outside_cfgit",
|
|
392
|
+
"collection": ref.collection,
|
|
393
|
+
"record_id": ref.record_id,
|
|
394
|
+
"live_oid": expected_live,
|
|
395
|
+
"head_oid": expected_head,
|
|
396
|
+
"runtime_mutated": False,
|
|
397
|
+
}
|
|
398
|
+
entry = self._entry(
|
|
399
|
+
ref,
|
|
400
|
+
commit["doc"],
|
|
401
|
+
coll,
|
|
402
|
+
message=message or pr["message"],
|
|
403
|
+
op="merge",
|
|
404
|
+
parent_oid=expected_head,
|
|
405
|
+
meta={
|
|
406
|
+
"source_pr_id": pr_id,
|
|
407
|
+
"source_branch": pr["head_branch"],
|
|
408
|
+
"source_branch_commit_id": commit["id"],
|
|
409
|
+
"source_branch_oid": commit["oid"],
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
result = self.adapter.apply(
|
|
413
|
+
collection=ref.collection,
|
|
414
|
+
record_id=ref.record_id,
|
|
415
|
+
new_doc=commit["doc"],
|
|
416
|
+
entry=entry,
|
|
417
|
+
expected_head_oid=expected_head,
|
|
418
|
+
expected_live_oid=expected_live,
|
|
419
|
+
make_head=True,
|
|
420
|
+
)
|
|
421
|
+
now = self.adapter.now()
|
|
422
|
+
merged_pr = {
|
|
423
|
+
**pr,
|
|
424
|
+
"status": "merged",
|
|
425
|
+
"merged_at": now,
|
|
426
|
+
"updated_at": now,
|
|
427
|
+
"merge_result": {
|
|
428
|
+
"collection": result.collection,
|
|
429
|
+
"record_id": result.record_id,
|
|
430
|
+
"seq": result.seq,
|
|
431
|
+
"oid": result.oid,
|
|
432
|
+
},
|
|
433
|
+
"runtime_mutated": True,
|
|
434
|
+
}
|
|
435
|
+
self.adapter.put_ref(merged_pr)
|
|
436
|
+
return {"state": "merged", "pr": pr_id, **merged_pr["merge_result"], "runtime_mutated": True}
|
|
437
|
+
|
|
438
|
+
def status(self, ref: RecordRef | None = None) -> list[StatusRow]:
|
|
439
|
+
refs = [ref] if ref else self._all_refs(include_history=True)
|
|
440
|
+
rows: list[StatusRow] = []
|
|
441
|
+
for item in refs:
|
|
442
|
+
coll = self.config.collection(item.collection)
|
|
443
|
+
live = self.adapter.get_record(item.collection, item.record_id)
|
|
444
|
+
head = self.adapter.get_head(item.collection, item.record_id)
|
|
445
|
+
live_oid = hash_doc(live, coll) if live else None
|
|
446
|
+
head_oid = head.get("oid") if head else None
|
|
447
|
+
head_seq = head.get("seq") if head else None
|
|
448
|
+
if live is None and head is None:
|
|
449
|
+
state = "not_found"
|
|
450
|
+
elif live is None:
|
|
451
|
+
state = "missing"
|
|
452
|
+
elif head is None:
|
|
453
|
+
state = "new"
|
|
454
|
+
elif live_oid != head_oid:
|
|
455
|
+
state = "changed_outside_cfgit"
|
|
456
|
+
else:
|
|
457
|
+
state = "clean"
|
|
458
|
+
rows.append(StatusRow(item.collection, item.record_id, state, live_oid, head_oid, head_seq))
|
|
459
|
+
return sorted(rows, key=lambda r: (r.collection, r.record_id))
|
|
460
|
+
|
|
461
|
+
def import_records(
|
|
462
|
+
self,
|
|
463
|
+
ref: RecordRef | None,
|
|
464
|
+
*,
|
|
465
|
+
message: str,
|
|
466
|
+
allow_secret: bool = False,
|
|
467
|
+
) -> list[dict[str, Any]]:
|
|
468
|
+
self._authorize("import")
|
|
469
|
+
self._ensure_atomic("import")
|
|
470
|
+
refs = [ref] if ref else self._all_refs(include_history=False)
|
|
471
|
+
results: list[dict[str, Any]] = []
|
|
472
|
+
for item in refs:
|
|
473
|
+
if self.adapter.get_head(item.collection, item.record_id):
|
|
474
|
+
results.append({"collection": item.collection, "record_id": item.record_id, "state": "exists"})
|
|
475
|
+
continue
|
|
476
|
+
live = self.adapter.get_record(item.collection, item.record_id)
|
|
477
|
+
if live is None:
|
|
478
|
+
results.append({"collection": item.collection, "record_id": item.record_id, "state": "missing"})
|
|
479
|
+
continue
|
|
480
|
+
coll = self.config.collection(item.collection)
|
|
481
|
+
meta = self._secret_meta(live, coll, allow_secret=allow_secret, message=message)
|
|
482
|
+
entry = self._entry(item, live, coll, message=message, op="import", parent_oid=None, meta=meta)
|
|
483
|
+
result = self.adapter.apply(
|
|
484
|
+
collection=item.collection,
|
|
485
|
+
record_id=item.record_id,
|
|
486
|
+
new_doc=None,
|
|
487
|
+
entry=entry,
|
|
488
|
+
expected_head_oid=None,
|
|
489
|
+
expected_live_oid=None,
|
|
490
|
+
make_head=True,
|
|
491
|
+
)
|
|
492
|
+
results.append({"collection": item.collection, "record_id": item.record_id, "state": "imported", "seq": result.seq, "oid": result.oid})
|
|
493
|
+
return results
|
|
494
|
+
|
|
495
|
+
def doctor(self, ref: RecordRef | None = None, *, large_field_bytes: int = 20000) -> dict[str, Any]:
|
|
496
|
+
"""Read-only preflight. Walks live records and reports what would trip an
|
|
497
|
+
import/commit BEFORE anything is written: secret-deny matches (grouped by
|
|
498
|
+
field path), oversized fields, and id values that are not unique under
|
|
499
|
+
live_when. Writes nothing. Returns a structured report plus paste-ready
|
|
500
|
+
config snippets so the user can fix .cfg.toml in one pass.
|
|
501
|
+
"""
|
|
502
|
+
refs = [ref] if ref else self._all_refs(include_history=False)
|
|
503
|
+
# group secret hits by (collection, field path) so 200 docs with the same
|
|
504
|
+
# schema field collapse to one actionable line.
|
|
505
|
+
secret_groups: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
506
|
+
large_groups: dict[tuple[str, str], dict[str, Any]] = {}
|
|
507
|
+
invariant_violations = self.adapter.check_runtime_invariant(ref.collection if ref else None)
|
|
508
|
+
scanned = 0
|
|
509
|
+
for item in refs:
|
|
510
|
+
live = self.adapter.get_record(item.collection, item.record_id)
|
|
511
|
+
if live is None:
|
|
512
|
+
continue
|
|
513
|
+
scanned += 1
|
|
514
|
+
coll = self.config.collection(item.collection)
|
|
515
|
+
scan = stored_doc(live, coll) # same view the secret scan + storage use
|
|
516
|
+
for match in _secret_matches(scan, self.config.secrets.block_fields, self.config.secrets.block_values):
|
|
517
|
+
# normalize list indices so foo[0].bar and foo[3].bar group together
|
|
518
|
+
norm = re.sub(r"\[\d+\]", "[]", match["path"])
|
|
519
|
+
key = (item.collection, norm, match["kind"])
|
|
520
|
+
g = secret_groups.setdefault(key, {"collection": item.collection, "path": norm,
|
|
521
|
+
"kind": match["kind"], "pattern": match["pattern"],
|
|
522
|
+
"count": 0, "example": item.record_id})
|
|
523
|
+
g["count"] += 1
|
|
524
|
+
for path, value in _walk_doc(scan):
|
|
525
|
+
size = len(value) if isinstance(value, (str, bytes)) else 0
|
|
526
|
+
if size >= large_field_bytes:
|
|
527
|
+
norm = re.sub(r"\[\d+\]", "[]", path)
|
|
528
|
+
key = (item.collection, norm)
|
|
529
|
+
g = large_groups.setdefault(key, {"collection": item.collection, "path": norm,
|
|
530
|
+
"count": 0, "max_bytes": 0, "example": item.record_id})
|
|
531
|
+
g["count"] += 1
|
|
532
|
+
g["max_bytes"] = max(g["max_bytes"], size)
|
|
533
|
+
secrets = sorted(secret_groups.values(), key=lambda g: (g["collection"], g["path"]))
|
|
534
|
+
large = sorted(large_groups.values(), key=lambda g: (-g["max_bytes"], g["collection"]))
|
|
535
|
+
# build paste-ready fix snippets per collection. Roll each secret path UP to its
|
|
536
|
+
# secret-bearing container so 9 sub-paths under ...openai_api_key collapse to the
|
|
537
|
+
# one container that, stripped, removes them all. Detailed paths stay in
|
|
538
|
+
# secret_blocks for transparency; suggestions stay short and pasteable.
|
|
539
|
+
suggestions: dict[str, dict[str, list[str]]] = {}
|
|
540
|
+
for g in secrets:
|
|
541
|
+
container = _secret_container(g["path"], self.config.secrets.block_fields)
|
|
542
|
+
sf = suggestions.setdefault(g["collection"], {}).setdefault("secret_fields", [])
|
|
543
|
+
if container not in sf:
|
|
544
|
+
sf.append(container)
|
|
545
|
+
for g in large:
|
|
546
|
+
ig = suggestions.setdefault(g["collection"], {}).setdefault("ignore_fields", [])
|
|
547
|
+
if g["path"] not in ig:
|
|
548
|
+
ig.append(g["path"])
|
|
549
|
+
# drop any suggested secret_field that is a child of another suggested one
|
|
550
|
+
for entry in suggestions.values():
|
|
551
|
+
sf = entry.get("secret_fields")
|
|
552
|
+
if sf:
|
|
553
|
+
entry["secret_fields"] = [p for p in sf
|
|
554
|
+
if not any(p != q and p.startswith(q + ".") for q in sf)]
|
|
555
|
+
ok = not secrets and not large and not invariant_violations
|
|
556
|
+
return {
|
|
557
|
+
"ok": ok,
|
|
558
|
+
"scanned": scanned,
|
|
559
|
+
"secret_blocks": secrets,
|
|
560
|
+
"large_fields": large,
|
|
561
|
+
"key_issues": invariant_violations,
|
|
562
|
+
"suggestions": suggestions,
|
|
563
|
+
"large_field_bytes": large_field_bytes,
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
def adopt(self, ref: RecordRef, *, message: str, allow_secret: bool = False) -> dict[str, Any]:
|
|
567
|
+
self._authorize("adopt")
|
|
568
|
+
self._ensure_atomic("adopt")
|
|
569
|
+
live = self.adapter.get_record(ref.collection, ref.record_id)
|
|
570
|
+
if live is None:
|
|
571
|
+
raise NoSuchConfig(f"{ref.collection}:{ref.record_id}")
|
|
572
|
+
coll = self.config.collection(ref.collection)
|
|
573
|
+
live_oid = hash_doc(live, coll)
|
|
574
|
+
head = self.adapter.get_head(ref.collection, ref.record_id)
|
|
575
|
+
if head and head.get("oid") == live_oid:
|
|
576
|
+
return {"state": "clean", "oid": live_oid, "seq": head.get("seq")}
|
|
577
|
+
meta = {
|
|
578
|
+
"bypass_detected_oid": live_oid,
|
|
579
|
+
**self._secret_meta(live, coll, allow_secret=allow_secret, message=message),
|
|
580
|
+
}
|
|
581
|
+
entry = self._entry(
|
|
582
|
+
ref,
|
|
583
|
+
live,
|
|
584
|
+
coll,
|
|
585
|
+
message=message,
|
|
586
|
+
op="adopt",
|
|
587
|
+
parent_oid=head.get("oid") if head else None,
|
|
588
|
+
meta=meta,
|
|
589
|
+
)
|
|
590
|
+
result = self.adapter.apply(
|
|
591
|
+
collection=ref.collection,
|
|
592
|
+
record_id=ref.record_id,
|
|
593
|
+
new_doc=None,
|
|
594
|
+
entry=entry,
|
|
595
|
+
expected_head_oid=head.get("oid") if head else None,
|
|
596
|
+
expected_live_oid=None,
|
|
597
|
+
make_head=True,
|
|
598
|
+
)
|
|
599
|
+
return {"state": "adopted", "oid": result.oid, "seq": result.seq}
|
|
600
|
+
|
|
601
|
+
def commit(
|
|
602
|
+
self,
|
|
603
|
+
ref: RecordRef,
|
|
604
|
+
doc: dict[str, Any],
|
|
605
|
+
*,
|
|
606
|
+
message: str,
|
|
607
|
+
allow_secret: bool = False,
|
|
608
|
+
) -> dict[str, Any]:
|
|
609
|
+
self._authorize("commit")
|
|
610
|
+
self._ensure_atomic("commit")
|
|
611
|
+
coll = self.config.collection(ref.collection)
|
|
612
|
+
live = self.adapter.get_record(ref.collection, ref.record_id)
|
|
613
|
+
if live is None:
|
|
614
|
+
raise NoSuchConfig(f"{ref.collection}:{ref.record_id}")
|
|
615
|
+
head = self.adapter.get_head(ref.collection, ref.record_id)
|
|
616
|
+
expected_head = head.get("oid") if head else None
|
|
617
|
+
expected_live = hash_doc(live, coll)
|
|
618
|
+
if head and expected_live != expected_head:
|
|
619
|
+
return {"state": "changed_outside_cfgit", "live_oid": expected_live, "head_oid": expected_head}
|
|
620
|
+
new_oid = hash_doc(doc, coll)
|
|
621
|
+
if head and new_oid == expected_head:
|
|
622
|
+
return {"state": "noop", "oid": new_oid, "seq": head.get("seq")}
|
|
623
|
+
meta = self._secret_meta(doc, coll, allow_secret=allow_secret, message=message)
|
|
624
|
+
entry = self._entry(ref, doc, coll, message=message, op="commit", parent_oid=expected_head, meta=meta)
|
|
625
|
+
result = self.adapter.apply(
|
|
626
|
+
collection=ref.collection,
|
|
627
|
+
record_id=ref.record_id,
|
|
628
|
+
new_doc=doc,
|
|
629
|
+
entry=entry,
|
|
630
|
+
expected_head_oid=expected_head,
|
|
631
|
+
expected_live_oid=expected_live,
|
|
632
|
+
make_head=True,
|
|
633
|
+
)
|
|
634
|
+
return {"state": "committed", "oid": result.oid, "seq": result.seq}
|
|
635
|
+
|
|
636
|
+
def commit_many(
|
|
637
|
+
self,
|
|
638
|
+
items: list[tuple[RecordRef, dict[str, Any]]],
|
|
639
|
+
*,
|
|
640
|
+
message: str,
|
|
641
|
+
allow_secret: bool = False,
|
|
642
|
+
) -> dict[str, Any]:
|
|
643
|
+
"""Commit multiple full documents as one operator intent.
|
|
644
|
+
|
|
645
|
+
The current adapter contract is per-record atomic (`apply()`), so this method does
|
|
646
|
+
not pretend the whole batch is one database transaction. It does, however, preflight
|
|
647
|
+
every target before writing anything. Existing drift, missing records, duplicate
|
|
648
|
+
targets, and secret-policy failures block the entire batch up front.
|
|
649
|
+
"""
|
|
650
|
+
self._authorize("commit")
|
|
651
|
+
self._ensure_atomic("commit")
|
|
652
|
+
message = _require_message(message)
|
|
653
|
+
if not items:
|
|
654
|
+
raise ValueError("bulk commit needs at least one item")
|
|
655
|
+
|
|
656
|
+
seen: set[tuple[str, str]] = set()
|
|
657
|
+
plans: list[dict[str, Any]] = []
|
|
658
|
+
blocked: list[dict[str, Any]] = []
|
|
659
|
+
total = len(items)
|
|
660
|
+
for index, (ref, doc) in enumerate(items, start=1):
|
|
661
|
+
key = (ref.collection, ref.record_id)
|
|
662
|
+
if key in seen:
|
|
663
|
+
raise ValueError(f"duplicate record in bulk commit: {ref.collection}:{ref.record_id}")
|
|
664
|
+
seen.add(key)
|
|
665
|
+
plan = self._commit_plan(
|
|
666
|
+
ref,
|
|
667
|
+
doc,
|
|
668
|
+
message=message,
|
|
669
|
+
allow_secret=allow_secret,
|
|
670
|
+
bulk_index=index,
|
|
671
|
+
bulk_count=total,
|
|
672
|
+
)
|
|
673
|
+
if plan["state"] in {"missing", "changed_outside_cfgit"}:
|
|
674
|
+
blocked.append(_plan_result(plan))
|
|
675
|
+
else:
|
|
676
|
+
plans.append(plan)
|
|
677
|
+
|
|
678
|
+
if blocked:
|
|
679
|
+
return {"state": "blocked", "results": [], "failed": blocked}
|
|
680
|
+
|
|
681
|
+
results: list[dict[str, Any]] = []
|
|
682
|
+
for offset, plan in enumerate(plans):
|
|
683
|
+
if plan["state"] == "noop":
|
|
684
|
+
results.append(_plan_result(plan))
|
|
685
|
+
continue
|
|
686
|
+
try:
|
|
687
|
+
result = self.adapter.apply(
|
|
688
|
+
collection=plan["ref"].collection,
|
|
689
|
+
record_id=plan["ref"].record_id,
|
|
690
|
+
new_doc=plan["doc"],
|
|
691
|
+
entry=plan["entry"],
|
|
692
|
+
expected_head_oid=plan["expected_head"],
|
|
693
|
+
expected_live_oid=plan["expected_live"],
|
|
694
|
+
make_head=True,
|
|
695
|
+
)
|
|
696
|
+
except Exception as exc:
|
|
697
|
+
failed = [
|
|
698
|
+
{
|
|
699
|
+
"collection": plan["ref"].collection,
|
|
700
|
+
"record_id": plan["ref"].record_id,
|
|
701
|
+
"state": "failed",
|
|
702
|
+
"error": str(exc),
|
|
703
|
+
"error_type": exc.__class__.__name__,
|
|
704
|
+
}
|
|
705
|
+
]
|
|
706
|
+
pending = [_plan_result(p) for p in plans[offset + 1:]]
|
|
707
|
+
return {
|
|
708
|
+
"state": "partial",
|
|
709
|
+
"results": results,
|
|
710
|
+
"failed": failed,
|
|
711
|
+
"pending": pending,
|
|
712
|
+
}
|
|
713
|
+
results.append(
|
|
714
|
+
{
|
|
715
|
+
"collection": result.collection,
|
|
716
|
+
"record_id": result.record_id,
|
|
717
|
+
"state": "committed",
|
|
718
|
+
"seq": result.seq,
|
|
719
|
+
"oid": result.oid,
|
|
720
|
+
}
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
state = "noop" if all(item["state"] == "noop" for item in results) else "committed"
|
|
724
|
+
return {"state": state, "results": results}
|
|
725
|
+
|
|
726
|
+
def _commit_plan(
|
|
727
|
+
self,
|
|
728
|
+
ref: RecordRef,
|
|
729
|
+
doc: dict[str, Any],
|
|
730
|
+
*,
|
|
731
|
+
message: str,
|
|
732
|
+
allow_secret: bool,
|
|
733
|
+
bulk_index: int | None = None,
|
|
734
|
+
bulk_count: int | None = None,
|
|
735
|
+
) -> dict[str, Any]:
|
|
736
|
+
coll = self.config.collection(ref.collection)
|
|
737
|
+
live = self.adapter.get_record(ref.collection, ref.record_id)
|
|
738
|
+
if live is None:
|
|
739
|
+
return {"ref": ref, "state": "missing"}
|
|
740
|
+
head = self.adapter.get_head(ref.collection, ref.record_id)
|
|
741
|
+
expected_head = head.get("oid") if head else None
|
|
742
|
+
expected_live = hash_doc(live, coll)
|
|
743
|
+
if head and expected_live != expected_head:
|
|
744
|
+
return {
|
|
745
|
+
"ref": ref,
|
|
746
|
+
"state": "changed_outside_cfgit",
|
|
747
|
+
"live_oid": expected_live,
|
|
748
|
+
"head_oid": expected_head,
|
|
749
|
+
}
|
|
750
|
+
new_oid = hash_doc(doc, coll)
|
|
751
|
+
if head and new_oid == expected_head:
|
|
752
|
+
return {"ref": ref, "state": "noop", "oid": new_oid, "seq": head.get("seq")}
|
|
753
|
+
meta = self._secret_meta(doc, coll, allow_secret=allow_secret, message=message)
|
|
754
|
+
if bulk_index is not None and bulk_count is not None:
|
|
755
|
+
meta = {**meta, "bulk_commit": {"index": bulk_index, "count": bulk_count}}
|
|
756
|
+
entry = self._entry(ref, doc, coll, message=message, op="commit", parent_oid=expected_head, meta=meta)
|
|
757
|
+
return {
|
|
758
|
+
"ref": ref,
|
|
759
|
+
"doc": doc,
|
|
760
|
+
"entry": entry,
|
|
761
|
+
"expected_head": expected_head,
|
|
762
|
+
"expected_live": expected_live,
|
|
763
|
+
"state": "ready",
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
def _branch_commit_plan(
|
|
767
|
+
self,
|
|
768
|
+
branch_ref: dict[str, Any],
|
|
769
|
+
ref: RecordRef,
|
|
770
|
+
doc: dict[str, Any],
|
|
771
|
+
*,
|
|
772
|
+
message: str,
|
|
773
|
+
allow_secret: bool,
|
|
774
|
+
bulk_index: int | None = None,
|
|
775
|
+
bulk_count: int | None = None,
|
|
776
|
+
) -> dict[str, Any]:
|
|
777
|
+
coll = self.config.collection(ref.collection)
|
|
778
|
+
live = self.adapter.get_record(ref.collection, ref.record_id)
|
|
779
|
+
if live is None:
|
|
780
|
+
return {"branch_ref": branch_ref, "ref": ref, "state": "missing"}
|
|
781
|
+
head = self.adapter.get_head(ref.collection, ref.record_id)
|
|
782
|
+
expected_head = head.get("oid") if head else None
|
|
783
|
+
expected_seq = head.get("seq") if head else None
|
|
784
|
+
expected_live = hash_doc(live, coll)
|
|
785
|
+
if head and expected_live != expected_head:
|
|
786
|
+
return {
|
|
787
|
+
"branch_ref": branch_ref,
|
|
788
|
+
"ref": ref,
|
|
789
|
+
"state": "changed_outside_cfgit",
|
|
790
|
+
"live_oid": expected_live,
|
|
791
|
+
"head_oid": expected_head,
|
|
792
|
+
}
|
|
793
|
+
latest = self._branch_latest_for_record(branch_ref["name"], ref)
|
|
794
|
+
parent_oid = latest.get("oid") if latest else expected_head
|
|
795
|
+
new_oid = hash_doc(doc, coll)
|
|
796
|
+
if new_oid == parent_oid:
|
|
797
|
+
return {
|
|
798
|
+
"branch_ref": branch_ref,
|
|
799
|
+
"ref": ref,
|
|
800
|
+
"state": "noop",
|
|
801
|
+
"oid": new_oid,
|
|
802
|
+
"commit_id": latest.get("id") if latest else None,
|
|
803
|
+
}
|
|
804
|
+
meta = {
|
|
805
|
+
"identity": self.identity.history_meta(),
|
|
806
|
+
"base_head_oid": expected_head,
|
|
807
|
+
"base_head_seq": expected_seq,
|
|
808
|
+
"base_live_oid": expected_live,
|
|
809
|
+
"runtime_mutated": False,
|
|
810
|
+
**self._secret_meta(doc, coll, allow_secret=allow_secret, message=message),
|
|
811
|
+
}
|
|
812
|
+
if bulk_index is not None and bulk_count is not None:
|
|
813
|
+
meta["bulk_commit"] = {"index": bulk_index, "count": bulk_count}
|
|
814
|
+
now = self.adapter.now()
|
|
815
|
+
commit_id = f"bc_{uuid.uuid4().hex[:16]}"
|
|
816
|
+
return {
|
|
817
|
+
"branch_ref": branch_ref,
|
|
818
|
+
"ref": ref,
|
|
819
|
+
"state": "ready",
|
|
820
|
+
"commit": {
|
|
821
|
+
"type": "branch_commit",
|
|
822
|
+
"id": commit_id,
|
|
823
|
+
"branch": branch_ref["name"],
|
|
824
|
+
"collection": ref.collection,
|
|
825
|
+
"record_id": ref.record_id,
|
|
826
|
+
"oid": new_oid,
|
|
827
|
+
"parent_oid": parent_oid,
|
|
828
|
+
"parent_commit_id": latest.get("id") if latest else None,
|
|
829
|
+
"doc": stored_doc(doc, coll),
|
|
830
|
+
"message": _require_message(message),
|
|
831
|
+
"author": self.author,
|
|
832
|
+
"created_at": now,
|
|
833
|
+
"updated_at": now,
|
|
834
|
+
"meta": meta,
|
|
835
|
+
"runtime_mutated": False,
|
|
836
|
+
},
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
def restore(self, ref: RecordRef, target_ref: str, *, message: str) -> dict[str, Any]:
|
|
840
|
+
self._authorize("restore")
|
|
841
|
+
self._ensure_atomic("restore")
|
|
842
|
+
target = self.resolve_ref(ref, target_ref)
|
|
843
|
+
return self._restore_one(ref, target, message=message, restored_from=target_ref)
|
|
844
|
+
|
|
845
|
+
def restore_system_as_of(
|
|
846
|
+
self,
|
|
847
|
+
when: datetime,
|
|
848
|
+
*,
|
|
849
|
+
message: str,
|
|
850
|
+
dry_run: bool = False,
|
|
851
|
+
) -> dict[str, Any]:
|
|
852
|
+
self._authorize("restore_system")
|
|
853
|
+
if not dry_run:
|
|
854
|
+
self._ensure_atomic("restore_system")
|
|
855
|
+
targets: list[tuple[RecordRef, dict[str, Any]]] = []
|
|
856
|
+
missing: list[dict[str, Any]] = []
|
|
857
|
+
for ref in self._all_refs(include_history=True):
|
|
858
|
+
rows = self.adapter.query_history(
|
|
859
|
+
collection=ref.collection,
|
|
860
|
+
record_id=ref.record_id,
|
|
861
|
+
as_of_valid=when,
|
|
862
|
+
limit=1,
|
|
863
|
+
order="desc",
|
|
864
|
+
with_doc=True,
|
|
865
|
+
)
|
|
866
|
+
if rows:
|
|
867
|
+
targets.append((ref, rows[0]))
|
|
868
|
+
else:
|
|
869
|
+
missing.append({"collection": ref.collection, "record_id": ref.record_id, "state": "did_not_exist"})
|
|
870
|
+
return self._restore_many(
|
|
871
|
+
targets,
|
|
872
|
+
message=message,
|
|
873
|
+
dry_run=dry_run,
|
|
874
|
+
source=f"as-of {when.isoformat()}",
|
|
875
|
+
extra=missing,
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
def restore_system_tag(
|
|
879
|
+
self,
|
|
880
|
+
tag: str,
|
|
881
|
+
*,
|
|
882
|
+
message: str,
|
|
883
|
+
dry_run: bool = False,
|
|
884
|
+
) -> dict[str, Any]:
|
|
885
|
+
self._authorize("restore_system")
|
|
886
|
+
if not dry_run:
|
|
887
|
+
self._ensure_atomic("restore_system")
|
|
888
|
+
rows = self.adapter.query_history(tag=tag, limit=None, order="desc", with_doc=True)
|
|
889
|
+
latest: dict[tuple[str, str], dict[str, Any]] = {}
|
|
890
|
+
for row in rows:
|
|
891
|
+
key = (row["collection"], row["record_id"])
|
|
892
|
+
if key not in latest or int(row["seq"]) > int(latest[key]["seq"]):
|
|
893
|
+
latest[key] = row
|
|
894
|
+
if not latest:
|
|
895
|
+
raise NoSuchConfig(f"tag not found: {tag}")
|
|
896
|
+
targets = [
|
|
897
|
+
(RecordRef(collection, record_id), row)
|
|
898
|
+
for (collection, record_id), row in sorted(latest.items())
|
|
899
|
+
]
|
|
900
|
+
tagged_keys = set(latest)
|
|
901
|
+
known_keys = {(ref.collection, ref.record_id) for ref in self._all_refs(include_history=True)}
|
|
902
|
+
uncovered = [
|
|
903
|
+
{"collection": collection, "record_id": record_id, "state": "not_in_tag", "tag": tag}
|
|
904
|
+
for collection, record_id in sorted(known_keys - tagged_keys)
|
|
905
|
+
]
|
|
906
|
+
return self._restore_many(
|
|
907
|
+
targets,
|
|
908
|
+
message=message,
|
|
909
|
+
dry_run=dry_run,
|
|
910
|
+
source=f"tag:{tag}",
|
|
911
|
+
extra=uncovered,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
def _restore_one(
|
|
915
|
+
self,
|
|
916
|
+
ref: RecordRef,
|
|
917
|
+
target: dict[str, Any],
|
|
918
|
+
*,
|
|
919
|
+
message: str,
|
|
920
|
+
restored_from: str,
|
|
921
|
+
seed_missing: bool = False,
|
|
922
|
+
) -> dict[str, Any]:
|
|
923
|
+
coll = self.config.collection(ref.collection)
|
|
924
|
+
live = self.adapter.get_record(ref.collection, ref.record_id)
|
|
925
|
+
if live is None and not seed_missing:
|
|
926
|
+
raise NoSuchConfig(f"{ref.collection}:{ref.record_id}")
|
|
927
|
+
head = self.adapter.get_head(ref.collection, ref.record_id)
|
|
928
|
+
expected_head = head.get("oid") if head else None
|
|
929
|
+
expected_live = hash_doc(live, coll) if live is not None else None
|
|
930
|
+
if live is not None and head and expected_live != expected_head:
|
|
931
|
+
return {"state": "changed_outside_cfgit", "live_oid": expected_live, "head_oid": expected_head}
|
|
932
|
+
doc = target["doc"]
|
|
933
|
+
entry = self._entry(
|
|
934
|
+
ref,
|
|
935
|
+
doc,
|
|
936
|
+
coll,
|
|
937
|
+
message=message,
|
|
938
|
+
op="restore",
|
|
939
|
+
parent_oid=expected_head,
|
|
940
|
+
meta={"restored_from": restored_from},
|
|
941
|
+
)
|
|
942
|
+
result = self.adapter.apply(
|
|
943
|
+
collection=ref.collection,
|
|
944
|
+
record_id=ref.record_id,
|
|
945
|
+
new_doc=doc,
|
|
946
|
+
entry=entry,
|
|
947
|
+
expected_head_oid=expected_head,
|
|
948
|
+
expected_live_oid=expected_live,
|
|
949
|
+
make_head=True,
|
|
950
|
+
seed_missing=seed_missing,
|
|
951
|
+
)
|
|
952
|
+
return {
|
|
953
|
+
"state": "restored_deleted" if seed_missing else "restored",
|
|
954
|
+
"oid": result.oid,
|
|
955
|
+
"seq": result.seq,
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
def _restore_many(
|
|
959
|
+
self,
|
|
960
|
+
targets: list[tuple[RecordRef, dict[str, Any]]],
|
|
961
|
+
*,
|
|
962
|
+
message: str,
|
|
963
|
+
dry_run: bool,
|
|
964
|
+
source: str,
|
|
965
|
+
extra: list[dict[str, Any]],
|
|
966
|
+
) -> dict[str, Any]:
|
|
967
|
+
blocked: list[dict[str, Any]] = []
|
|
968
|
+
plan: list[tuple[RecordRef, dict[str, Any], str, bool]] = []
|
|
969
|
+
results: list[dict[str, Any]] = list(extra)
|
|
970
|
+
|
|
971
|
+
for ref, target in targets:
|
|
972
|
+
coll = self.config.collection(ref.collection)
|
|
973
|
+
live = self.adapter.get_record(ref.collection, ref.record_id)
|
|
974
|
+
head = self.adapter.get_head(ref.collection, ref.record_id)
|
|
975
|
+
expected_head = head.get("oid") if head else None
|
|
976
|
+
expected_live = hash_doc(live, coll) if live is not None else None
|
|
977
|
+
seed_missing = live is None
|
|
978
|
+
if live is not None and head and expected_live != expected_head:
|
|
979
|
+
blocked.append(
|
|
980
|
+
{
|
|
981
|
+
"collection": ref.collection,
|
|
982
|
+
"record_id": ref.record_id,
|
|
983
|
+
"state": "changed_outside_cfgit",
|
|
984
|
+
"live_oid": expected_live,
|
|
985
|
+
"head_oid": expected_head,
|
|
986
|
+
}
|
|
987
|
+
)
|
|
988
|
+
continue
|
|
989
|
+
if live is not None and expected_head == target["oid"]:
|
|
990
|
+
results.append(
|
|
991
|
+
{
|
|
992
|
+
"collection": ref.collection,
|
|
993
|
+
"record_id": ref.record_id,
|
|
994
|
+
"state": "unchanged",
|
|
995
|
+
"seq": head.get("seq") if head else None,
|
|
996
|
+
"oid": expected_head,
|
|
997
|
+
}
|
|
998
|
+
)
|
|
999
|
+
continue
|
|
1000
|
+
plan.append((ref, target, f"{ref.collection}:{ref.record_id}@{target['seq']}", seed_missing))
|
|
1001
|
+
|
|
1002
|
+
if blocked:
|
|
1003
|
+
return {"state": "blocked", "source": source, "blocked": blocked, "results": results}
|
|
1004
|
+
|
|
1005
|
+
if dry_run:
|
|
1006
|
+
preview = [
|
|
1007
|
+
{
|
|
1008
|
+
"collection": ref.collection,
|
|
1009
|
+
"record_id": ref.record_id,
|
|
1010
|
+
"state": "would_seed" if seed_missing else "would_restore",
|
|
1011
|
+
"from": restored_from,
|
|
1012
|
+
"oid": target["oid"],
|
|
1013
|
+
"seq": target["seq"],
|
|
1014
|
+
}
|
|
1015
|
+
for ref, target, restored_from, seed_missing in plan
|
|
1016
|
+
]
|
|
1017
|
+
return {"state": "dry_run", "source": source, "results": results + preview}
|
|
1018
|
+
|
|
1019
|
+
failed: list[dict[str, Any]] = []
|
|
1020
|
+
for ref, target, restored_from, seed_missing in plan:
|
|
1021
|
+
try:
|
|
1022
|
+
result = self._restore_one(
|
|
1023
|
+
ref,
|
|
1024
|
+
target,
|
|
1025
|
+
message=message,
|
|
1026
|
+
restored_from=restored_from,
|
|
1027
|
+
seed_missing=seed_missing,
|
|
1028
|
+
)
|
|
1029
|
+
results.append({"collection": ref.collection, "record_id": ref.record_id, **result})
|
|
1030
|
+
except Exception as exc:
|
|
1031
|
+
failed.append(
|
|
1032
|
+
{
|
|
1033
|
+
"collection": ref.collection,
|
|
1034
|
+
"record_id": ref.record_id,
|
|
1035
|
+
"state": "failed",
|
|
1036
|
+
"error": str(exc),
|
|
1037
|
+
"error_type": exc.__class__.__name__,
|
|
1038
|
+
"from": restored_from,
|
|
1039
|
+
}
|
|
1040
|
+
)
|
|
1041
|
+
if failed:
|
|
1042
|
+
return {
|
|
1043
|
+
"state": "partial",
|
|
1044
|
+
"source": source,
|
|
1045
|
+
"results": results,
|
|
1046
|
+
"failed": failed,
|
|
1047
|
+
"resume_token": {
|
|
1048
|
+
"source": source,
|
|
1049
|
+
"pending": failed,
|
|
1050
|
+
"hint": "rerun the same restore command; records already at target are skipped",
|
|
1051
|
+
},
|
|
1052
|
+
}
|
|
1053
|
+
return {"state": "restored", "source": source, "results": results}
|
|
1054
|
+
|
|
1055
|
+
def resolve_ref(self, ref: RecordRef, value: str) -> dict[str, Any]:
|
|
1056
|
+
if value in ("=HEAD", "HEAD"):
|
|
1057
|
+
head = self.adapter.get_head(ref.collection, ref.record_id)
|
|
1058
|
+
if not head:
|
|
1059
|
+
raise NoSuchConfig(f"no HEAD for {ref.collection}:{ref.record_id}")
|
|
1060
|
+
return head
|
|
1061
|
+
if value in ("=live", "live"):
|
|
1062
|
+
live = self.adapter.get_record(ref.collection, ref.record_id)
|
|
1063
|
+
if not live:
|
|
1064
|
+
raise NoSuchConfig(f"no live record for {ref.collection}:{ref.record_id}")
|
|
1065
|
+
coll = self.config.collection(ref.collection)
|
|
1066
|
+
return {"doc": live, "oid": hash_doc(live, coll), "seq": None}
|
|
1067
|
+
if value.startswith("@{") and value.endswith("}"):
|
|
1068
|
+
when = _parse_when(value[2:-1])
|
|
1069
|
+
rows = self.adapter.query_history(
|
|
1070
|
+
collection=ref.collection,
|
|
1071
|
+
record_id=ref.record_id,
|
|
1072
|
+
as_of_valid=when,
|
|
1073
|
+
limit=1,
|
|
1074
|
+
order="desc",
|
|
1075
|
+
with_doc=True,
|
|
1076
|
+
)
|
|
1077
|
+
elif value.startswith("@"):
|
|
1078
|
+
seq = int(value[1:])
|
|
1079
|
+
rows = self.adapter.query_history(collection=ref.collection, record_id=ref.record_id, ref=f"@{seq}", with_doc=True)
|
|
1080
|
+
elif value.startswith("tag:"):
|
|
1081
|
+
rows = self.adapter.query_history(collection=ref.collection, record_id=ref.record_id, tag=value[4:], with_doc=True)
|
|
1082
|
+
else:
|
|
1083
|
+
rows = self.adapter.query_history(collection=ref.collection, record_id=ref.record_id, ref=value, with_doc=True)
|
|
1084
|
+
if not rows:
|
|
1085
|
+
raise NoSuchConfig(f"ref not found: {value}")
|
|
1086
|
+
if len(rows) > 1:
|
|
1087
|
+
raise ValueError(f"ambiguous ref: {value}")
|
|
1088
|
+
return rows[0]
|
|
1089
|
+
|
|
1090
|
+
def diff(self, ref: RecordRef, a: str, b: str) -> list[dict[str, Any]]:
|
|
1091
|
+
coll = self.config.collection(ref.collection)
|
|
1092
|
+
left = strip_for_hash(self.resolve_ref(ref, a)["doc"], coll)
|
|
1093
|
+
right = strip_for_hash(self.resolve_ref(ref, b)["doc"], coll)
|
|
1094
|
+
return diff_values(left, right)
|
|
1095
|
+
|
|
1096
|
+
def log(self, ref: RecordRef, *, limit: int | None = None) -> list[dict[str, Any]]:
|
|
1097
|
+
return self.adapter.query_history(
|
|
1098
|
+
collection=ref.collection,
|
|
1099
|
+
record_id=ref.record_id,
|
|
1100
|
+
limit=limit,
|
|
1101
|
+
order="desc",
|
|
1102
|
+
with_doc=False,
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
def tag(self, name: str) -> list[dict[str, Any]]:
|
|
1106
|
+
self._authorize("tag")
|
|
1107
|
+
tagged: list[dict[str, Any]] = []
|
|
1108
|
+
for ref in self._all_refs(include_history=True):
|
|
1109
|
+
head = self.adapter.get_head(ref.collection, ref.record_id)
|
|
1110
|
+
if not head:
|
|
1111
|
+
tagged.append(
|
|
1112
|
+
{
|
|
1113
|
+
"collection": ref.collection,
|
|
1114
|
+
"record_id": ref.record_id,
|
|
1115
|
+
"state": "skipped_no_head",
|
|
1116
|
+
}
|
|
1117
|
+
)
|
|
1118
|
+
continue
|
|
1119
|
+
self.adapter.add_tag(collection=ref.collection, record_id=ref.record_id, seq=head["seq"], tag=name)
|
|
1120
|
+
tagged.append(
|
|
1121
|
+
{
|
|
1122
|
+
"collection": ref.collection,
|
|
1123
|
+
"record_id": ref.record_id,
|
|
1124
|
+
"state": "tagged",
|
|
1125
|
+
"oid": head["oid"],
|
|
1126
|
+
}
|
|
1127
|
+
)
|
|
1128
|
+
return tagged
|
|
1129
|
+
|
|
1130
|
+
def _entry(
|
|
1131
|
+
self,
|
|
1132
|
+
ref: RecordRef,
|
|
1133
|
+
doc: dict[str, Any],
|
|
1134
|
+
coll: CollectionConfig,
|
|
1135
|
+
*,
|
|
1136
|
+
message: str,
|
|
1137
|
+
op: str,
|
|
1138
|
+
parent_oid: str | None,
|
|
1139
|
+
meta: dict[str, Any] | None = None,
|
|
1140
|
+
) -> dict[str, Any]:
|
|
1141
|
+
message = _require_message(message)
|
|
1142
|
+
now = self.adapter.now()
|
|
1143
|
+
stored = stored_doc(doc, coll)
|
|
1144
|
+
oid = hash_doc(stored, coll)
|
|
1145
|
+
return {
|
|
1146
|
+
"collection": ref.collection,
|
|
1147
|
+
"record_id": ref.record_id,
|
|
1148
|
+
"env": self.env,
|
|
1149
|
+
"seq": None,
|
|
1150
|
+
"oid": oid,
|
|
1151
|
+
"parent_oid": parent_oid,
|
|
1152
|
+
"doc": stored,
|
|
1153
|
+
"message": message,
|
|
1154
|
+
"author": self.author,
|
|
1155
|
+
"recorded_at": now,
|
|
1156
|
+
"valid_from": now,
|
|
1157
|
+
"valid_to": None,
|
|
1158
|
+
"valid_from_estimated": False,
|
|
1159
|
+
"op": op,
|
|
1160
|
+
"git_shas": [],
|
|
1161
|
+
"tags": [],
|
|
1162
|
+
"meta": self._history_meta(meta),
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
def _all_refs(self, *, include_history: bool) -> list[RecordRef]:
|
|
1166
|
+
refs: set[tuple[str, str]] = set()
|
|
1167
|
+
for coll in self.config.collections:
|
|
1168
|
+
for record_id in self.adapter.list_record_ids(coll.name):
|
|
1169
|
+
refs.add((coll.name, record_id))
|
|
1170
|
+
if include_history:
|
|
1171
|
+
for row in self.adapter.query_history(limit=None, order="asc", with_doc=False):
|
|
1172
|
+
refs.add((row["collection"], row["record_id"]))
|
|
1173
|
+
return [RecordRef(collection, record_id) for collection, record_id in refs]
|
|
1174
|
+
|
|
1175
|
+
def _main_doc(self, ref: RecordRef) -> dict[str, Any] | None:
|
|
1176
|
+
head = self.adapter.get_head(ref.collection, ref.record_id)
|
|
1177
|
+
if head:
|
|
1178
|
+
return head["doc"]
|
|
1179
|
+
return self.adapter.get_record(ref.collection, ref.record_id)
|
|
1180
|
+
|
|
1181
|
+
def _branch_latest_by_record(self, branch: str) -> dict[RecordRef, dict[str, Any]]:
|
|
1182
|
+
by_record: dict[RecordRef, list[dict[str, Any]]] = {}
|
|
1183
|
+
rows = self.adapter.list_refs("branch_commit", branch=branch)
|
|
1184
|
+
for row in rows:
|
|
1185
|
+
by_record.setdefault(RecordRef(row["collection"], row["record_id"]), []).append(row)
|
|
1186
|
+
latest: dict[RecordRef, dict[str, Any]] = {}
|
|
1187
|
+
for ref, commits in by_record.items():
|
|
1188
|
+
parent_ids = {row.get("parent_commit_id") for row in commits if row.get("parent_commit_id")}
|
|
1189
|
+
candidates = [row for row in commits if row["id"] not in parent_ids]
|
|
1190
|
+
if not candidates:
|
|
1191
|
+
candidates = commits
|
|
1192
|
+
latest[ref] = sorted(candidates, key=lambda row: (row["created_at"], row["id"]))[-1]
|
|
1193
|
+
return latest
|
|
1194
|
+
|
|
1195
|
+
def _branch_latest_for_record(self, branch: str, ref: RecordRef) -> dict[str, Any] | None:
|
|
1196
|
+
return self._branch_latest_by_record(branch).get(ref)
|
|
1197
|
+
|
|
1198
|
+
def _authorize(self, action: str) -> None:
|
|
1199
|
+
authorize_mutation(self.config.envs[self.env], identity=self.identity, action=action)
|
|
1200
|
+
|
|
1201
|
+
def _ensure_atomic(self, action: str) -> None:
|
|
1202
|
+
report = self.adapter.check_atomicity_scope()
|
|
1203
|
+
if report.atomic:
|
|
1204
|
+
return
|
|
1205
|
+
raise AtomicityUnavailable(
|
|
1206
|
+
f"{self.adapter.backend_name()} cannot safely run {action}: {report.reason}. "
|
|
1207
|
+
"Use a transactional deployment with runtime and cfgit history co-located."
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
def _ensure_branching(self) -> None:
|
|
1211
|
+
if not self.config.branches.enabled:
|
|
1212
|
+
raise BranchingDisabled(
|
|
1213
|
+
"branching is not enabled. Add [branches] enabled = true and run cfg init."
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
def _require_branch(self, name: str) -> dict[str, Any] | None:
|
|
1217
|
+
default = self.config.branches.default_branch
|
|
1218
|
+
if name == default:
|
|
1219
|
+
return None
|
|
1220
|
+
branch = self.adapter.get_ref("branch", name)
|
|
1221
|
+
if not branch:
|
|
1222
|
+
raise NoSuchConfig(f"branch not found: {name}")
|
|
1223
|
+
return branch
|
|
1224
|
+
|
|
1225
|
+
def _secret_meta(
|
|
1226
|
+
self,
|
|
1227
|
+
doc: dict[str, Any],
|
|
1228
|
+
coll: CollectionConfig,
|
|
1229
|
+
*,
|
|
1230
|
+
allow_secret: bool,
|
|
1231
|
+
message: str,
|
|
1232
|
+
) -> dict[str, Any]:
|
|
1233
|
+
scan_doc = stored_doc(doc, coll)
|
|
1234
|
+
matches = _secret_matches(scan_doc, self.config.secrets.block_fields, self.config.secrets.block_values)
|
|
1235
|
+
if not matches:
|
|
1236
|
+
return {}
|
|
1237
|
+
if self.config.secrets.on_match == "refuse" and not allow_secret:
|
|
1238
|
+
# group by normalized path so list-index/duplicate hits collapse, and
|
|
1239
|
+
# give the exact secret_fields lines to paste — one fix pass, not N.
|
|
1240
|
+
seen: dict[str, str] = {}
|
|
1241
|
+
for item in matches:
|
|
1242
|
+
norm = re.sub(r"\[\d+\]", "[]", item["path"])
|
|
1243
|
+
seen.setdefault(norm, item["kind"])
|
|
1244
|
+
paths = sorted(seen)
|
|
1245
|
+
shown = ", ".join(f"{p} ({seen[p]})" for p in paths[:8])
|
|
1246
|
+
more = f" and {len(paths) - 8} more" if len(paths) > 8 else ""
|
|
1247
|
+
snippet = ", ".join(f'"{p}"' for p in paths)
|
|
1248
|
+
raise SecretBlocked(
|
|
1249
|
+
f"secret-like content refused in {len(paths)} field(s): {shown}{more}. "
|
|
1250
|
+
f"These are stripped from history if you add them to this collection's "
|
|
1251
|
+
f"secret_fields:\n secret_fields = [{snippet}]\n"
|
|
1252
|
+
f"Run `cfg doctor` to see every collection at once, or rerun with "
|
|
1253
|
+
f"--allow-secret if this is intentional."
|
|
1254
|
+
)
|
|
1255
|
+
return {
|
|
1256
|
+
"allow_secret": bool(allow_secret),
|
|
1257
|
+
"allow_secret_author": self.author if allow_secret else None,
|
|
1258
|
+
"allow_secret_identity": self.identity.history_meta() if allow_secret else None,
|
|
1259
|
+
"allow_secret_reason": _require_message(message) if allow_secret else None,
|
|
1260
|
+
"secret_matches": matches,
|
|
1261
|
+
"secret_policy": self.config.secrets.on_match,
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
def _history_meta(self, meta: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
1265
|
+
out = {"identity": self.identity.history_meta()}
|
|
1266
|
+
out.update(meta or {})
|
|
1267
|
+
return out
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def _require_message(message: str) -> str:
|
|
1271
|
+
text = str(message or "").strip()
|
|
1272
|
+
if not text:
|
|
1273
|
+
raise ValueError("message must be non-empty")
|
|
1274
|
+
return text
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
_BRANCH_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def _clean_branch_name(name: str) -> str:
|
|
1281
|
+
value = str(name or "").strip()
|
|
1282
|
+
if not value:
|
|
1283
|
+
raise ValueError("branch name must be non-empty")
|
|
1284
|
+
if value in {".", ".."} or value.endswith("/") or value.endswith("."):
|
|
1285
|
+
raise ValueError(f"invalid branch name: {value}")
|
|
1286
|
+
if ".." in value or "//" in value or "@{" in value:
|
|
1287
|
+
raise ValueError(f"invalid branch name: {value}")
|
|
1288
|
+
if not _BRANCH_NAME_RE.match(value):
|
|
1289
|
+
raise ValueError(f"invalid branch name: {value}")
|
|
1290
|
+
return value
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _secret_matches(
|
|
1294
|
+
doc: dict[str, Any],
|
|
1295
|
+
field_patterns: tuple[str, ...],
|
|
1296
|
+
value_patterns: tuple[str, ...],
|
|
1297
|
+
) -> list[dict[str, str]]:
|
|
1298
|
+
if not field_patterns and not value_patterns:
|
|
1299
|
+
return []
|
|
1300
|
+
matches: list[dict[str, str]] = []
|
|
1301
|
+
compiled = [(pattern, re.compile(pattern)) for pattern in value_patterns]
|
|
1302
|
+
for path, value in _walk_doc(doc):
|
|
1303
|
+
basename = path.rsplit(".", 1)[-1].split("[", 1)[0]
|
|
1304
|
+
for pattern in field_patterns:
|
|
1305
|
+
if fnmatch.fnmatchcase(path, pattern) or fnmatch.fnmatchcase(basename, pattern):
|
|
1306
|
+
matches.append({"path": path, "kind": "field", "pattern": pattern})
|
|
1307
|
+
break
|
|
1308
|
+
if isinstance(value, str):
|
|
1309
|
+
for pattern, regex in compiled:
|
|
1310
|
+
if regex.search(value):
|
|
1311
|
+
matches.append({"path": path, "kind": "value", "pattern": pattern})
|
|
1312
|
+
break
|
|
1313
|
+
return matches
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _secret_container(path: str, field_patterns: tuple[str, ...]) -> str:
|
|
1317
|
+
"""Roll a dotted path up to its shallowest secret-bearing segment, so a stripping
|
|
1318
|
+
suggestion covers the whole subtree. For
|
|
1319
|
+
`cached_schema...properties.openai_api_key.type` with a `*api_key*` pattern, returns
|
|
1320
|
+
`cached_schema...properties.openai_api_key` (stripping that removes all its children).
|
|
1321
|
+
If no segment matches a field pattern (e.g. a value-only match), returns path as-is."""
|
|
1322
|
+
segments = path.split(".")
|
|
1323
|
+
for i, seg in enumerate(segments):
|
|
1324
|
+
base = seg.split("[", 1)[0]
|
|
1325
|
+
if any(fnmatch.fnmatchcase(base, pat) for pat in field_patterns):
|
|
1326
|
+
return ".".join(segments[: i + 1])
|
|
1327
|
+
return path
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _plan_result(plan: dict[str, Any]) -> dict[str, Any]:
|
|
1331
|
+
ref = plan["ref"]
|
|
1332
|
+
result = {"collection": ref.collection, "record_id": ref.record_id, "state": plan["state"]}
|
|
1333
|
+
for key in ("oid", "seq", "live_oid", "head_oid"):
|
|
1334
|
+
if key in plan:
|
|
1335
|
+
result[key] = plan[key]
|
|
1336
|
+
return result
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _branch_plan_result(plan: dict[str, Any], *, state: str | None = None) -> dict[str, Any]:
|
|
1340
|
+
ref = plan["ref"]
|
|
1341
|
+
result = {
|
|
1342
|
+
"collection": ref.collection,
|
|
1343
|
+
"record_id": ref.record_id,
|
|
1344
|
+
"state": state or plan["state"],
|
|
1345
|
+
"runtime_mutated": False,
|
|
1346
|
+
}
|
|
1347
|
+
if plan.get("commit"):
|
|
1348
|
+
result["commit_id"] = plan["commit"]["id"]
|
|
1349
|
+
result["oid"] = plan["commit"]["oid"]
|
|
1350
|
+
for key in ("oid", "commit_id", "live_oid", "head_oid"):
|
|
1351
|
+
if key in plan and key not in result:
|
|
1352
|
+
result[key] = plan[key]
|
|
1353
|
+
return result
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def _parse_branch_range(value: str, default_branch: str) -> tuple[str, str]:
|
|
1357
|
+
raw = str(value or "").strip()
|
|
1358
|
+
if ".." not in raw:
|
|
1359
|
+
raise ValueError("branch range must look like main..<branch>")
|
|
1360
|
+
base, head = raw.split("..", 1)
|
|
1361
|
+
return _clean_branch_name(base or default_branch), _clean_branch_name(head)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _walk_doc(value: Any, prefix: str = "") -> list[tuple[str, Any]]:
|
|
1365
|
+
if isinstance(value, dict):
|
|
1366
|
+
out: list[tuple[str, Any]] = []
|
|
1367
|
+
for key, child in value.items():
|
|
1368
|
+
path = f"{prefix}.{key}" if prefix else str(key)
|
|
1369
|
+
out.extend(_walk_doc(child, path))
|
|
1370
|
+
return out
|
|
1371
|
+
if isinstance(value, list):
|
|
1372
|
+
out = []
|
|
1373
|
+
for index, child in enumerate(value):
|
|
1374
|
+
out.extend(_walk_doc(child, f"{prefix}[{index}]"))
|
|
1375
|
+
return out
|
|
1376
|
+
return [(prefix, value)]
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _parse_when(raw: str) -> datetime:
|
|
1380
|
+
value = raw.strip()
|
|
1381
|
+
date_only = len(value) == 10 and value[4] == "-" and value[7] == "-"
|
|
1382
|
+
if date_only:
|
|
1383
|
+
dt = datetime.combine(datetime.fromisoformat(value).date(), time.max)
|
|
1384
|
+
else:
|
|
1385
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
1386
|
+
if dt.tzinfo is None:
|
|
1387
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
1388
|
+
return dt.astimezone(timezone.utc)
|