Skip to content

graflo.architecture.evolution.history

The commit DAG, and commits on disk.

RevisionChain could only be a line, and it raised when two commits shared a parent. That is the wrong response: a fork is something that happened, and a history that refuses to represent it cannot be the record of what happened. The server had the same defect from the other direction -- it silently dropped a recording that did not extend the head, so a fork was simply lost.

Here a fork is a recorded fact. :class:History validates the things that are genuinely broken -- duplicate ids, a parent that does not exist, a first-parent edge whose trees do not line up -- and represents everything else, including multiple heads.

Linear histories stay easy: :meth:History.linearize returns the single path when there is one, and checkout replays first-parent edges, which is the same walk the old chain did.

Attributes

__all__ = ['FileCommitStore', 'History', 'checkout', 'verify_history'] module-attribute

logger = logging.getLogger(__name__) module-attribute

Classes

FileCommitStore

Commits on disk, one YAML file per commit.

Filenames carry a topological index so a directory listing reads roughly in order, but the index is not authoritative: the loader rebuilds the DAG from the recorded parent ids. That is the difference from the old store, which reconstructed a chain by walking a linear parent map and raised the moment two commits shared a parent.

Source code in graflo/architecture/evolution/history.py
class FileCommitStore:
    """Commits on disk, one YAML file per commit.

    Filenames carry a topological index so a directory listing reads roughly in
    order, but the index is **not** authoritative: the loader rebuilds the DAG
    from the recorded parent ids. That is the difference from the old store,
    which reconstructed a chain by walking a linear parent map and raised the
    moment two commits shared a parent.
    """

    def __init__(self, root: str | Path = ".graflo/commits") -> None:
        self.root = Path(root)

    def load(self) -> History:
        """Read every stored commit and assemble the DAG."""
        if not self.root.exists():
            return History()
        payloads = [FileHandle.load(path) for path in sorted(self.root.glob("*.yaml"))]
        return History(commits=[_commit_from_dict(payload) for payload in payloads])

    def save(self, history: History) -> list[Path]:
        """Write *history*, replacing whatever was there."""
        self.root.mkdir(parents=True, exist_ok=True)
        for stale in self.root.glob("*.yaml"):
            stale.unlink()
        written: list[Path] = []
        for index, commit in enumerate(history.topological()):
            path = self.root / f"{index:04d}_{commit.id}_{commit.slug}.yaml"
            FileHandle.dump(_commit_to_dict(commit), path)
            written.append(path)
        return written

    def append(self, commit: Commit) -> Path:
        """Add one commit to the stored history, validating the DAG."""
        history = self.load().extend(commit)
        self.save(history)
        return next(path for path in self.root.glob("*.yaml") if commit.id in path.name)

Attributes

root = Path(root) instance-attribute

Methods:

__init__(root='.graflo/commits')
Source code in graflo/architecture/evolution/history.py
def __init__(self, root: str | Path = ".graflo/commits") -> None:
    self.root = Path(root)
append(commit)

Add one commit to the stored history, validating the DAG.

Source code in graflo/architecture/evolution/history.py
def append(self, commit: Commit) -> Path:
    """Add one commit to the stored history, validating the DAG."""
    history = self.load().extend(commit)
    self.save(history)
    return next(path for path in self.root.glob("*.yaml") if commit.id in path.name)
load()

Read every stored commit and assemble the DAG.

Source code in graflo/architecture/evolution/history.py
def load(self) -> History:
    """Read every stored commit and assemble the DAG."""
    if not self.root.exists():
        return History()
    payloads = [FileHandle.load(path) for path in sorted(self.root.glob("*.yaml"))]
    return History(commits=[_commit_from_dict(payload) for payload in payloads])
save(history)

Write history, replacing whatever was there.

Source code in graflo/architecture/evolution/history.py
def save(self, history: History) -> list[Path]:
    """Write *history*, replacing whatever was there."""
    self.root.mkdir(parents=True, exist_ok=True)
    for stale in self.root.glob("*.yaml"):
        stale.unlink()
    written: list[Path] = []
    for index, commit in enumerate(history.topological()):
        path = self.root / f"{index:04d}_{commit.id}_{commit.slug}.yaml"
        FileHandle.dump(_commit_to_dict(commit), path)
        written.append(path)
    return written

History

Bases: ConfigBaseModel

A set of commits forming a directed acyclic graph.

Source code in graflo/architecture/evolution/history.py
class History(ConfigBaseModel):
    """A set of commits forming a directed acyclic graph."""

    commits: list[Commit] = PydanticField(default_factory=list)

    @model_validator(mode="after")
    def _validate_dag(self) -> History:
        if not self.commits:
            return self

        ids = [commit.id for commit in self.commits]
        duplicates = sorted({cid for cid in ids if ids.count(cid) > 1})
        if duplicates:
            raise ValueError(f"duplicate commit ids: {duplicates}")

        known = set(ids)
        by_id = {commit.id: commit for commit in self.commits}

        for commit in self.commits:
            missing = [pid for pid in commit.parents if pid not in known]
            if missing:
                raise ValueError(
                    f"commit '{commit.id}' names parents not in this history: {missing}"
                )

        # Cycles first: they are the more fundamental defect, and a cycle makes
        # every other property meaningless (`ancestors` would not terminate, and
        # "the parent's tree" is not well defined when a commit is its own
        # ancestor). Reporting a tree mismatch for what is really a loop sends
        # the reader looking for drift that is not there.
        self._reject_cycles(by_id)

        for commit in self.commits:
            # Tree continuity along the first-parent edge only. Second parents
            # deliberately carry no such constraint -- a merge's ops are the
            # diff from its *first* parent, so the second parent's tree has no
            # reason to line up with anything here.
            parent_id = commit.first_parent
            if parent_id is not None:
                parent = by_id[parent_id]
                if commit.tree_before is not None and commit.tree_before != parent.tree:
                    raise ValueError(
                        f"commit '{commit.id}' expects to start from tree "
                        f"{(commit.tree_before or '')[:12]} but its first parent "
                        f"'{parent_id}' produces {parent.tree[:12]}"
                    )
        return self

    @staticmethod
    def _reject_cycles(by_id: dict[str, Commit]) -> None:
        """A cycle makes 'ancestors of X' non-terminating; refuse to hold one."""
        # Kahn's algorithm: a DAG drains completely, a cycle leaves a residue.
        indegree = {cid: len(commit.parents) for cid, commit in by_id.items()}
        children: dict[str, list[str]] = {cid: [] for cid in by_id}
        for cid, commit in by_id.items():
            for parent in commit.parents:
                children[parent].append(cid)

        queue = deque(cid for cid, degree in indegree.items() if degree == 0)
        drained = 0
        while queue:
            current = queue.popleft()
            drained += 1
            for child in children[current]:
                indegree[child] -= 1
                if indegree[child] == 0:
                    queue.append(child)
        if drained != len(by_id):
            cyclic = sorted(cid for cid, degree in indegree.items() if degree > 0)
            raise ValueError(f"commit history contains a cycle involving: {cyclic}")

    # ── lookup ──────────────────────────────────────────────────────────────

    def get(self, commit_id: str) -> Commit | None:
        """The commit with *commit_id*, or ``None``.

        Accepts an unambiguous prefix, the way git does.
        """
        for commit in self.commits:
            if commit.id == commit_id:
                return commit
        matches = [c for c in self.commits if c.id.startswith(commit_id)]
        if len(matches) == 1:
            return matches[0]
        if len(matches) > 1:
            raise CommitError(
                f"'{commit_id}' is ambiguous: {sorted(c.id for c in matches)}"
            )
        return None

    def require(self, commit_id: str) -> Commit:
        """Like :meth:`get`, but raises when there is no such commit."""
        commit = self.get(commit_id)
        if commit is None:
            raise CommitError(f"unknown commit '{commit_id}'")
        return commit

    def heads(self) -> list[Commit]:
        """Commits no other commit names as a parent, oldest-first by topology.

        More than one head means the history has forked -- a fact to report,
        not an error to raise.
        """
        referenced = {pid for commit in self.commits for pid in commit.parents}
        order = {commit.id: index for index, commit in enumerate(self.topological())}
        heads = [c for c in self.commits if c.id not in referenced]
        return sorted(heads, key=lambda c: order.get(c.id, 0))

    def roots(self) -> list[Commit]:
        """Commits with no parent."""
        return [commit for commit in self.commits if commit.is_root]

    def children_of(self, commit_id: str) -> list[Commit]:
        """Commits naming *commit_id* as any of their parents."""
        return [c for c in self.commits if commit_id in c.parents]

    # ── traversal ───────────────────────────────────────────────────────────

    def ancestors(self, commit_id: str, *, include_self: bool = False) -> set[str]:
        """Every commit reachable by following parents from *commit_id*."""
        start = self.require(commit_id)
        seen: set[str] = {start.id} if include_self else set()
        queue = deque(start.parents)
        while queue:
            current = queue.popleft()
            if current in seen:
                continue
            seen.add(current)
            queue.extend(self.require(current).parents)
        return seen

    def topological(self) -> list[Commit]:
        """All commits, parents before children, deterministic on ties.

        Ties break on commit id, so the same history always linearizes the same
        way -- a log that reorders itself between runs is not a log.
        """
        by_id = {commit.id: commit for commit in self.commits}
        indegree = {cid: len(commit.parents) for cid, commit in by_id.items()}
        children: dict[str, list[str]] = {cid: [] for cid in by_id}
        for cid, commit in by_id.items():
            for parent in commit.parents:
                children[parent].append(cid)

        ready = sorted(cid for cid, degree in indegree.items() if degree == 0)
        ordered: list[Commit] = []
        while ready:
            current = ready.pop(0)
            ordered.append(by_id[current])
            for child in sorted(children[current]):
                indegree[child] -= 1
                if indegree[child] == 0:
                    ready.append(child)
            ready.sort()
        return ordered

    def first_parent_path(self, commit_id: str) -> list[Commit]:
        """Root → *commit_id* along first-parent edges, oldest first.

        This is the walk a checkout replays. Following only first parents is
        what makes it work for merge commits too: their ops were materialized
        against exactly this parent.
        """
        path: list[Commit] = []
        current: str | None = commit_id
        while current is not None:
            commit = self.require(current)
            path.append(commit)
            current = commit.first_parent
        path.reverse()
        return path

    def linearize(self) -> list[Commit]:
        """The single path through a linear history, oldest first.

        Raises:
            CommitError: The history has forked or has several roots, so there
                is no single path. Use :meth:`topological` for the general case.
        """
        heads = self.heads()
        if len(heads) > 1:
            raise CommitError(
                "history has forked into "
                f"{len(heads)} heads ({', '.join(h.short() for h in heads)}); "
                "there is no single path. Use topological() or name a head."
            )
        roots = self.roots()
        if len(roots) > 1:
            raise CommitError(
                f"history has {len(roots)} roots; there is no single path."
            )
        if not heads:
            return []
        return self.first_parent_path(heads[0].id)

    @property
    def reversible(self) -> bool:
        """Whether every commit in the history can be inverted."""
        return all(commit.reversible for commit in self.commits)

    def extend(self, commit: Commit) -> History:
        """A new history with *commit* added; validation re-runs on the copy."""
        return History(commits=[*self.commits, commit])

Attributes

commits = PydanticField(default_factory=list) class-attribute instance-attribute
reversible property

Whether every commit in the history can be inverted.

Methods:

ancestors(commit_id, *, include_self=False)

Every commit reachable by following parents from commit_id.

Source code in graflo/architecture/evolution/history.py
def ancestors(self, commit_id: str, *, include_self: bool = False) -> set[str]:
    """Every commit reachable by following parents from *commit_id*."""
    start = self.require(commit_id)
    seen: set[str] = {start.id} if include_self else set()
    queue = deque(start.parents)
    while queue:
        current = queue.popleft()
        if current in seen:
            continue
        seen.add(current)
        queue.extend(self.require(current).parents)
    return seen
children_of(commit_id)

Commits naming commit_id as any of their parents.

Source code in graflo/architecture/evolution/history.py
def children_of(self, commit_id: str) -> list[Commit]:
    """Commits naming *commit_id* as any of their parents."""
    return [c for c in self.commits if commit_id in c.parents]
extend(commit)

A new history with commit added; validation re-runs on the copy.

Source code in graflo/architecture/evolution/history.py
def extend(self, commit: Commit) -> History:
    """A new history with *commit* added; validation re-runs on the copy."""
    return History(commits=[*self.commits, commit])
first_parent_path(commit_id)

Root → commit_id along first-parent edges, oldest first.

This is the walk a checkout replays. Following only first parents is what makes it work for merge commits too: their ops were materialized against exactly this parent.

Source code in graflo/architecture/evolution/history.py
def first_parent_path(self, commit_id: str) -> list[Commit]:
    """Root → *commit_id* along first-parent edges, oldest first.

    This is the walk a checkout replays. Following only first parents is
    what makes it work for merge commits too: their ops were materialized
    against exactly this parent.
    """
    path: list[Commit] = []
    current: str | None = commit_id
    while current is not None:
        commit = self.require(current)
        path.append(commit)
        current = commit.first_parent
    path.reverse()
    return path
get(commit_id)

The commit with commit_id, or None.

Accepts an unambiguous prefix, the way git does.

Source code in graflo/architecture/evolution/history.py
def get(self, commit_id: str) -> Commit | None:
    """The commit with *commit_id*, or ``None``.

    Accepts an unambiguous prefix, the way git does.
    """
    for commit in self.commits:
        if commit.id == commit_id:
            return commit
    matches = [c for c in self.commits if c.id.startswith(commit_id)]
    if len(matches) == 1:
        return matches[0]
    if len(matches) > 1:
        raise CommitError(
            f"'{commit_id}' is ambiguous: {sorted(c.id for c in matches)}"
        )
    return None
heads()

Commits no other commit names as a parent, oldest-first by topology.

More than one head means the history has forked -- a fact to report, not an error to raise.

Source code in graflo/architecture/evolution/history.py
def heads(self) -> list[Commit]:
    """Commits no other commit names as a parent, oldest-first by topology.

    More than one head means the history has forked -- a fact to report,
    not an error to raise.
    """
    referenced = {pid for commit in self.commits for pid in commit.parents}
    order = {commit.id: index for index, commit in enumerate(self.topological())}
    heads = [c for c in self.commits if c.id not in referenced]
    return sorted(heads, key=lambda c: order.get(c.id, 0))
linearize()

The single path through a linear history, oldest first.

Raises:

Type Description
CommitError

The history has forked or has several roots, so there is no single path. Use :meth:topological for the general case.

Source code in graflo/architecture/evolution/history.py
def linearize(self) -> list[Commit]:
    """The single path through a linear history, oldest first.

    Raises:
        CommitError: The history has forked or has several roots, so there
            is no single path. Use :meth:`topological` for the general case.
    """
    heads = self.heads()
    if len(heads) > 1:
        raise CommitError(
            "history has forked into "
            f"{len(heads)} heads ({', '.join(h.short() for h in heads)}); "
            "there is no single path. Use topological() or name a head."
        )
    roots = self.roots()
    if len(roots) > 1:
        raise CommitError(
            f"history has {len(roots)} roots; there is no single path."
        )
    if not heads:
        return []
    return self.first_parent_path(heads[0].id)
require(commit_id)

Like :meth:get, but raises when there is no such commit.

Source code in graflo/architecture/evolution/history.py
def require(self, commit_id: str) -> Commit:
    """Like :meth:`get`, but raises when there is no such commit."""
    commit = self.get(commit_id)
    if commit is None:
        raise CommitError(f"unknown commit '{commit_id}'")
    return commit
roots()

Commits with no parent.

Source code in graflo/architecture/evolution/history.py
def roots(self) -> list[Commit]:
    """Commits with no parent."""
    return [commit for commit in self.commits if commit.is_root]
topological()

All commits, parents before children, deterministic on ties.

Ties break on commit id, so the same history always linearizes the same way -- a log that reorders itself between runs is not a log.

Source code in graflo/architecture/evolution/history.py
def topological(self) -> list[Commit]:
    """All commits, parents before children, deterministic on ties.

    Ties break on commit id, so the same history always linearizes the same
    way -- a log that reorders itself between runs is not a log.
    """
    by_id = {commit.id: commit for commit in self.commits}
    indegree = {cid: len(commit.parents) for cid, commit in by_id.items()}
    children: dict[str, list[str]] = {cid: [] for cid in by_id}
    for cid, commit in by_id.items():
        for parent in commit.parents:
            children[parent].append(cid)

    ready = sorted(cid for cid, degree in indegree.items() if degree == 0)
    ordered: list[Commit] = []
    while ready:
        current = ready.pop(0)
        ordered.append(by_id[current])
        for child in sorted(children[current]):
            indegree[child] -= 1
            if indegree[child] == 0:
                ready.append(child)
        ready.sort()
    return ordered

Functions:

checkout(base, history, commit_id=None, *, verify=True, finish_init=False)

The manifest as of commit_id, replayed from base.

Replays first-parent edges from the root, verifying every recorded tree on the way. Verification is the point: a history that no longer describes the manifest it was generated from fails here instead of producing a plausible but wrong result.

Parameters:

Name Type Description Default
base GraphManifest

The manifest the root commit starts from.

required
history History

The commit DAG.

required
commit_id str | None

Where to stop. None means the single head, and raises if the history has forked.

None
verify bool

Check each recorded tree hash against the replayed manifest.

True
finish_init bool

Run finish_init after each applied change set.

False

Raises:

Type Description
CommitError

An unknown commit, a forked history with no commit named, or a replay that did not reproduce a recorded tree.

Source code in graflo/architecture/evolution/history.py
def checkout(
    base: GraphManifest,
    history: History,
    commit_id: str | None = None,
    *,
    verify: bool = True,
    finish_init: bool = False,
) -> GraphManifest:
    """The manifest as of *commit_id*, replayed from *base*.

    Replays first-parent edges from the root, verifying every recorded tree on
    the way. Verification is the point: a history that no longer describes the
    manifest it was generated from fails here instead of producing a plausible
    but wrong result.

    Args:
        base: The manifest the root commit starts from.
        history: The commit DAG.
        commit_id: Where to stop. ``None`` means the single head, and raises if
            the history has forked.
        verify: Check each recorded tree hash against the replayed manifest.
        finish_init: Run ``finish_init`` after each applied change set.

    Raises:
        CommitError: An unknown commit, a forked history with no commit named,
            or a replay that did not reproduce a recorded tree.
    """
    from .apply import apply_evolution

    if commit_id is None:
        heads = history.heads()
        if not heads:
            return base
        if len(heads) > 1:
            raise CommitError(
                "history has forked into "
                f"{len(heads)} heads ({', '.join(h.short() for h in heads)}); "
                "name the commit to check out"
            )
        commit_id = heads[0].id

    current = base
    for commit in history.first_parent_path(commit_id):
        if not commit.ops:
            # A naming root asserts a tree rather than deriving one, so there is
            # nothing to replay -- but the assertion is still checked, or a
            # mismatched base would sail through and every later commit would
            # verify against the wrong starting point.
            if verify:
                actual = manifest_hash(current)
                if actual != commit.tree:
                    raise CommitError(
                        f"root commit '{commit.id}' names tree "
                        f"{commit.tree[:12]} but the manifest hashes "
                        f"{actual[:12]}; this base is not that artifact"
                    )
            continue
        if verify:
            actual = manifest_hash(current)
            if actual != commit.tree_before:
                before = (commit.tree_before or "")[:12] or "nothing"
                raise CommitError(
                    f"commit '{commit.id}' expects to start from tree "
                    f"{before} but the manifest hashes "
                    f"{actual[:12]}; the base or an earlier commit has drifted"
                )
        current = apply_evolution(
            current, commit.ops, bump_version=False, finish_init=finish_init
        )
        if verify:
            produced = manifest_hash(current)
            if produced != commit.tree:
                raise CommitError(
                    f"replaying commit '{commit.id}' produced tree "
                    f"{produced[:12]}, not the recorded {commit.tree[:12]}"
                )
    return current

verify_history(base, history)

Replay every head and report what fails, instead of raising on the first.

Returns a list of human-readable problems -- empty when the whole DAG replays cleanly. Useful as a health check over a history that may have several heads, where checkout would refuse to pick one.

A merge joins two lineages, so a history can hold more than one root and one base cannot replay both. A head descending from a different root is reported as not verifiable from this base rather than as a failure -- and reported rather than skipped, because a head nobody checked is exactly what a health check must not pass over in silence.

Source code in graflo/architecture/evolution/history.py
def verify_history(base: GraphManifest, history: History) -> list[str]:
    """Replay every head and report what fails, instead of raising on the first.

    Returns a list of human-readable problems -- empty when the whole DAG
    replays cleanly. Useful as a health check over a history that may have
    several heads, where ``checkout`` would refuse to pick one.

    A merge joins two lineages, so a history can hold more than one root and
    one ``base`` cannot replay both. A head descending from a different root is
    reported as **not verifiable from this base** rather than as a failure --
    and reported rather than skipped, because a head nobody checked is exactly
    what a health check must not pass over in silence.
    """
    problems: list[str] = []
    roots = {commit.id for commit in history.roots()}
    base_root: str | None = None
    if len(roots) > 1:
        base_hash = manifest_hash(base)
        reachable = [
            commit.id
            for commit in history.roots()
            if commit.tree == base_hash or commit.tree_before == base_hash
        ]
        base_root = reachable[0] if len(reachable) == 1 else None

    for head in history.heads():
        if base_root is not None and _first_parent_root(history, head.id) != base_root:
            problems.append(
                f"head {head.short()}: not verifiable from this base; it descends "
                "from another root, so its own base is needed"
            )
            continue
        try:
            checkout(base, history, head.id)
        except CommitError as exc:
            problems.append(f"head {head.short()}: {exc}")
    return problems