graflo.architecture.evolution.commit¶
This replaces the linear Revision chain. The old model was already "a git
log, not an Alembic script" -- but it still spoke Alembic (down_revision,
upgrade/downgrade) and it could only be a line. A world model that can
be merged needs a DAG: a commit has a list of parents, empty for a root, one
for an ordinary edit, two or more for a three-way merge or a merge.
What carries over unchanged is the property that made the chain worth having: each commit records the content hash before and after it, so replay is verified rather than assumed. A history that no longer describes the manifest it was generated from fails loudly instead of producing a plausible wrong answer.
Merge commits are materialized, git-style¶
A merge commit's ops are the diff from its first parent's state to the
merged result -- not some interleaving of both sides. That single decision is
what keeps everything else simple: first-parent replay and hash verification
work identically for edit and merge commits, so History needs no special
case and neither does the store. The declarative record of how the merge was
resolved rides alongside as a :class:MergeRecipe, which is what a re-merge
reads. Interleaving two sides' ops within one commit is deliberately not
expressible, and is not needed.
Attributes¶
COMMIT_ID_LENGTH = 12
module-attribute
¶
COMMIT_KINDS = frozenset({'root', 'edit', 'merge', 'merge3', 'revert'})
module-attribute
¶
CommitKind = str
module-attribute
¶
__all__ = ['COMMIT_ID_LENGTH', 'COMMIT_KINDS', 'Commit', 'CommitError', 'CommitKind', 'MergeRecipeRef', 'build_commit', 'build_multi_parent_commit', 'build_revert_commit', 'compute_commit_id']
module-attribute
¶
logger = logging.getLogger(__name__)
module-attribute
¶
Classes¶
Commit
¶
Bases: ConfigBaseModel
One change set, its ordered parents, and the trees it moves between.
Source code in graflo/architecture/evolution/commit.py
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | |
Attributes¶
created_at = PydanticField(default=None, description='ISO-8601 timestamp, supplied by the caller.')
class-attribute
instance-attribute
¶
first_parent
property
¶
The parent this commit's ops are materialized against.
id = PydanticField(..., description='Content-derived id of this commit.')
class-attribute
instance-attribute
¶
is_multi_parent
property
¶
Whether this commit joins two or more lineages.
True for both merge and merge3 kinds: joining unrelated lineages
is as multi-parent as reconciling related ones. Not a test for
kind == "merge" -- read kind for that.
is_root
property
¶
Whether this commit has no parent.
kind = PydanticField(default='edit', description='One of root / edit / merge / merge3 / revert.')
class-attribute
instance-attribute
¶
label = PydanticField(default=None, description='Short human-readable name.')
class-attribute
instance-attribute
¶
merge_recipe = PydanticField(default=None, description='How a merge was resolved; present on merge/merge3 commits.')
class-attribute
instance-attribute
¶
notes = None
class-attribute
instance-attribute
¶
ops = PydanticField(default_factory=list, description="Ordered operations, as a diff from the first parent's tree. Empty only on a root, which names a tree rather than deriving one.")
class-attribute
instance-attribute
¶
parents = PydanticField(default_factory=list, description="Parent commit ids in position order: empty for a root, one for an edit, two or more for a merge or merge3. The first parent is the one this commit's ops are materialized against.")
class-attribute
instance-attribute
¶
reversible = PydanticField(default=True, description='Whether every op in this commit has a total inverse.')
class-attribute
instance-attribute
¶
slug
property
¶
Filesystem-safe label fragment used in the stored filename.
tree = PydanticField(..., description='Content hash of the manifest this commit produces.')
class-attribute
instance-attribute
¶
tree_before = PydanticField(default=None, description="Content hash of the first parent's manifest, or ``None`` on a root. Redundant against the parent's own `tree` on purpose: replay drift fails here, loudly, rather than producing a plausible wrong manifest.")
class-attribute
instance-attribute
¶
Methods:¶
CommitError
¶
MergeRecipeRef
¶
Bases: ConfigBaseModel
A pointer to the recorded merge recipe, plus its content hash.
The recipe model itself lives in merge3 (L4, same layer) and is stored
inline on the commit. This indirection keeps commit.py importable
without pulling the merge machinery in, which matters because the store and
the history DAG only ever need the hash.
Source code in graflo/architecture/evolution/commit.py
Attributes¶
hash = PydanticField(..., description='Content hash of the recipe.')
class-attribute
instance-attribute
¶
kind = PydanticField(default='merge3', description='Recipe flavour: merge3 or merge.')
class-attribute
instance-attribute
¶
payload = PydanticField(default_factory=dict, description='The serialized recipe, as produced by MergeRecipe.to_dict().')
class-attribute
instance-attribute
¶
Functions:¶
build_commit(base, ops, *, parents=None, kind='edit', label=None, created_at=None, notes=None, merge_recipe=None)
¶
Apply ops to base and record the result as a :class:Commit.
The ops are applied here rather than trusted, so both trees describe a transition that actually happened. Refuses an empty change set and one that leaves the manifest unchanged -- a commit that moves nothing is a lie about history, not a harmless no-op.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base
|
GraphManifest
|
The first parent's manifest state. |
required |
ops
|
list[ManifestOp]
|
The change set to apply. |
required |
parents
|
list[str] | None
|
Parent commit ids, first parent first. |
None
|
kind
|
str
|
One of |
'edit'
|
label
|
str | None
|
Short human-readable name. |
None
|
created_at
|
str | None
|
ISO-8601 timestamp. |
None
|
notes
|
str | None
|
Free-form annotation. |
None
|
merge_recipe
|
MergeRecipeRef | None
|
Recorded resolution, on merge and merge3 commits. |
None
|
Raises:
| Type | Description |
|---|---|
CommitError
|
The change set is empty, is a no-op, or kind is unknown. |
Source code in graflo/architecture/evolution/commit.py
build_multi_parent_commit(first_parent, merged, *, parents, kind='merge3', label=None, created_at=None, notes=None, merge_recipe=None, lead_ops=None)
¶
Record a merge as the diff from its first parent to merged.
This is what makes a merge commit replayable by exactly the same machinery as an edit: the stored ops move first-parent → result, so first-parent replay and hash verification need no special case anywhere downstream.
The diff is verified before it is stored -- if replaying the derived ops does not reproduce merged, that is raised here rather than written into history.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
first_parent
|
GraphManifest
|
Manifest state of the parent the ops are diffed from. |
required |
merged
|
GraphManifest
|
The merge result. |
required |
parents
|
list[str]
|
All parent commit ids, first parent first (at least two). |
required |
kind
|
str
|
|
'merge3'
|
label
|
str | None
|
Short human-readable name. |
None
|
created_at
|
str | None
|
ISO-8601 timestamp. |
None
|
notes
|
str | None
|
Free-form annotation. |
None
|
merge_recipe
|
MergeRecipeRef | None
|
The recorded resolution, so a re-merge can replay it. |
None
|
lead_ops
|
list[Any] | None
|
Ops the merge itself applied to the first parent, recorded ahead of the derived diff. A merge relabels each side before the union -- renames, and folds of several classes into one -- and a plain diff can see a fold only as an unrelated add and remove. The diff is then taken from the relabelled first parent. |
None
|
Raises:
| Type | Description |
|---|---|
CommitError
|
Fewer than two parents, lead ops that do not apply, or the derived diff does not reproduce merged. |
Source code in graflo/architecture/evolution/commit.py
338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 | |
build_revert_commit(current, commit, *, parents, label=None, created_at=None)
¶
A new commit that undoes commit, applied on top of current.
Git-shaped, and deliberately not a "downgrade": history is append-only, so undoing a change is a new commit that moves forward, never an edit to what was recorded. Everything downstream -- replay, verification, the DAG -- then needs no notion of rewinding at all.
Inversion is exact or it fails. An op with no total inverse, or one whose inverse needs data the current manifest no longer holds (restoring a removed vertex, say), raises rather than producing a manifest that merely resembles the earlier state. When the base manifest is available, checking out the parent commit is always exact and is the better tool.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
current
|
GraphManifest
|
The manifest to apply the reverting ops to. |
required |
commit
|
Commit
|
The commit being undone. |
required |
parents
|
list[str]
|
Parent commit ids for the new commit -- normally the head. |
required |
label
|
str | None
|
Short human-readable name. |
None
|
created_at
|
str | None
|
ISO-8601 timestamp. |
None
|
Raises:
| Type | Description |
|---|---|
CommitError
|
The commit is not invertible from current alone. |
Source code in graflo/architecture/evolution/commit.py
build_root_commit(manifest, *, scope=None, label=None, created_at=None, notes=None)
¶
Record manifest as a root that names its tree.
The counterpart to :func:build_commit for an artifact that was not derived
from anything this history holds -- pushed to a registry, seeded from a
pack, or otherwise simply present. There is no base to diff against, so
there are no ops: the commit asserts the tree and stops.
That is deliberately not "a diff from an empty manifest". An empty
GraphManifest is not constructible (it must carry at least one block),
and the op vocabulary cannot build every block from nothing -- so a
synthesized construction diff would fail for exactly the manifests a
registry most often holds.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
manifest
|
GraphManifest
|
The artifact this root names. |
required |
scope
|
str | None
|
Lineage discriminator folded into the id; see
:func: |
None
|
label
|
str | None
|
Short human-readable name. |
None
|
created_at
|
str | None
|
ISO-8601 timestamp. |
None
|
notes
|
str | None
|
Free-form annotation. |
None
|
Source code in graflo/architecture/evolution/commit.py
compute_commit_id(ops, parents)
¶
Content-derived id: sha256 over the canonical ops plus the parent ids.
Deterministic on purpose -- the same change set on the same parents always yields the same id, so a regenerated commit is recognisably the same one rather than a duplicate under a fresh random name.
Parent order is part of the id, because it is part of the meaning: a merge materialized against A-then-B is not the commit materialized against B-then-A.
Source code in graflo/architecture/evolution/commit.py
compute_root_commit_id(tree, scope=None)
¶
Content-derived id for a root that names tree.
:func:compute_commit_id hashes the ops and the parents, both of which are
empty on a naming root -- so every root in existence would share one id.
The tree is what a root asserts, so the tree is what identifies it.
scope separates two roots that name the same tree but belong to different lineages. A registry keyed by artifact passes the artifact id: without it, two artifacts whose first version has identical content collide, the store dedupes the second against the first, and one of them silently ends up with no root at all.