Skip to content

memory.sharing.bundle_git

memory.sharing.bundle_git

Bundle-repo history rewrite for retroactive revocation (--purge-history).

Revocation is forward-only by default: re-publishing a bundle excludes the revoked party from the new ciphertext, but the bundle repo's PRIOR git commits still contain the OLD ciphertext that WAS wrapped to the revoked party's key. If they kept (or can re-fetch) an old commit, their key still opens it.

This module closes the re-fetchable-from-remote gap by rewriting the bundle repo's history down to a SINGLE commit whose tree is the current (post-revoke) bundle, then making the superseded ciphertext blobs unreachable so they can be garbage-collected. It is deliberately split into two steps:

  • :func:purge_bundle_history rewrites history LOCALLY (orphan a fresh single-commit history onto the repo's branch, then expire reflogs + git gc --prune=now so old objects are collectible). It never touches any remote, so it works on a repo with no origin at all.
  • :func:force_push_purged propagates that rewrite to a remote. A history rewrite is NOT a fast-forward, so a normal push is rejected — this REQUIRES a force push, which the return value / message makes loud and explicit. It is a separate, opt-in step so the CLI only force-pushes when the user asked for it AND a remote exists.

Mechanism note: we orphan a fresh history rather than shelling out to git-filter-repo. A bundle is fully regenerable from the source .memory tree and carries no authorship value, so collapsing to one commit is simpler, needs no external dependency, and yields the same end state (superseded blobs unreachable). Commits are authored as memory-mcp to match the identity the other bundle commits use (see :mod:memory.commit). All git work goes through subprocess git for consistency with the rest of the repo's git plumbing.

BundleGitError

Bases: RuntimeError

A bundle-repo git operation could not be completed.

Raised (instead of leaking a raw CalledProcessError / traceback) when the target is not a git repo, an empty repo, or a git subprocess fails.

Source code in memory/sharing/bundle_git.py
class BundleGitError(RuntimeError):
    """A bundle-repo git operation could not be completed.

    Raised (instead of leaking a raw ``CalledProcessError`` / traceback) when the
    target is not a git repo, an empty repo, or a git subprocess fails.
    """

PurgeResult dataclass

Outcome of :func:purge_bundle_history.

branch is the branch that now holds the single collapsed commit; new_commit is that commit's full sha; previous_head is the sha HEAD pointed at before the rewrite (now unreachable). had_remote says whether an origin remote exists, and force_push_required is True exactly when a remote exists (a history rewrite can only reach a remote via a force push — a normal push is rejected as non-fast-forward).

Source code in memory/sharing/bundle_git.py
@dataclass(frozen=True)
class PurgeResult:
    """Outcome of :func:`purge_bundle_history`.

    ``branch`` is the branch that now holds the single collapsed commit;
    ``new_commit`` is that commit's full sha; ``previous_head`` is the sha HEAD
    pointed at before the rewrite (now unreachable). ``had_remote`` says whether
    an ``origin`` remote exists, and ``force_push_required`` is ``True`` exactly
    when a remote exists (a history rewrite can only reach a remote via a force
    push — a normal push is rejected as non-fast-forward).
    """

    branch: str
    new_commit: str
    previous_head: str
    had_remote: bool
    force_push_required: bool
    message: str

PushResult dataclass

Outcome of :func:force_push_purged.

pushed is True only when a force push actually ran and succeeded; forced echoes that the push used --force (a rewrite cannot be pushed any other way). When no remote exists pushed/forced are False and message explains that there was nothing to push.

Source code in memory/sharing/bundle_git.py
@dataclass(frozen=True)
class PushResult:
    """Outcome of :func:`force_push_purged`.

    ``pushed`` is ``True`` only when a force push actually ran and succeeded;
    ``forced`` echoes that the push used ``--force`` (a rewrite cannot be pushed
    any other way). When no remote exists ``pushed``/``forced`` are ``False`` and
    ``message`` explains that there was nothing to push.
    """

    pushed: bool
    forced: bool
    remote: str
    branch: str
    message: str

purge_bundle_history

purge_bundle_history(repo_root: str | Path, *, remote: str = 'origin', message: str = DEFAULT_PURGE_MESSAGE, source_repo_root: str | Path | None = None) -> PurgeResult

Collapse the bundle repo's history to one commit of the current tree.

In the bundle repo working tree (which already holds the freshly re-published manifest.json + units/ from the current publish) this:

  1. Orphans a fresh branch (git checkout --orphan), stages the current working-tree contents (git add -A), and commits a single memory-mcp-authored commit — so the new commit has NO parent and its tree is exactly the post-revoke bundle.
  2. Moves that commit onto the repo's real branch (deletes the old branch and renames the orphan onto it), so the branch now has exactly ONE commit.
  3. Expires ALL reflogs and runs git gc --prune=now so the superseded commits/blobs (which held ciphertext wrapped to a revoked key) become unreachable and collectible.

This is purely LOCAL — no remote is contacted. Propagating the rewrite to a remote requires a SEPARATE force push (see :func:force_push_purged); the returned :class:PurgeResult reports whether a remote exists and whether a force push is therefore required.

Raises :class:BundleGitError (not a traceback) when repo_root is not a git repo, the repo has no commits yet, or a git step fails. A dirty working tree is fine — its current contents become the single commit's tree.

repo_root MUST be the bundle repo's own toplevel — a nested/enclosing repo, or one whose toplevel equals source_repo_root (the angelo source/workspace repo), is refused (see :func:_require_standalone_repo) so this can never rewrite the workspace repo it happens to sit inside or point at.

Source code in memory/sharing/bundle_git.py
def purge_bundle_history(
    repo_root: str | Path,
    *,
    remote: str = "origin",
    message: str = DEFAULT_PURGE_MESSAGE,
    source_repo_root: str | Path | None = None,
) -> PurgeResult:
    """Collapse the bundle repo's history to one commit of the current tree.

    In the bundle repo working tree (which already holds the freshly re-published
    ``manifest.json`` + ``units/`` from the current publish) this:

    1. Orphans a fresh branch (``git checkout --orphan``), stages the current
       working-tree contents (``git add -A``), and commits a single
       ``memory-mcp``-authored commit — so the new commit has NO parent and its
       tree is exactly the post-revoke bundle.
    2. Moves that commit onto the repo's real branch (deletes the old branch and
       renames the orphan onto it), so the branch now has exactly ONE commit.
    3. Expires ALL reflogs and runs ``git gc --prune=now`` so the superseded
       commits/blobs (which held ciphertext wrapped to a revoked key) become
       unreachable and collectible.

    This is purely LOCAL — no remote is contacted. Propagating the rewrite to a
    remote requires a SEPARATE force push (see :func:`force_push_purged`); the
    returned :class:`PurgeResult` reports whether a remote exists and whether a
    force push is therefore required.

    Raises :class:`BundleGitError` (not a traceback) when ``repo_root`` is not a
    git repo, the repo has no commits yet, or a git step fails. A dirty working
    tree is fine — its current contents become the single commit's tree.

    ``repo_root`` MUST be the bundle repo's own toplevel — a nested/enclosing
    repo, or one whose toplevel equals ``source_repo_root`` (the angelo
    source/workspace repo), is refused (see :func:`_require_standalone_repo`) so
    this can never rewrite the workspace repo it happens to sit inside or point
    at.
    """
    root = _require_standalone_repo(repo_root, source_repo_root=source_repo_root)
    env = _git_env()

    # No commits yet -> there is no history to purge (and nothing was ever
    # published). Surface a clear error rather than silently committing.
    if _run_git(root, ["rev-parse", "--verify", "--quiet", "HEAD"]).returncode != 0:
        raise BundleGitError(
            f"repo has no commits yet; nothing to purge: {root}"
        )

    previous_head = _run_git(root, ["rev-parse", "HEAD"]).stdout.strip()
    branch = _current_branch(root)

    # A leftover temp branch from a prior interrupted run would make the orphan
    # checkout fail; clear it first (ignore "not found").
    _run_git(root, ["branch", "-D", _TMP_BRANCH])

    def _step(args: list[str], what: str) -> subprocess.CompletedProcess:
        res = _run_git(root, args, env=env)
        if res.returncode != 0:
            raise BundleGitError(
                f"{what} failed: {(res.stderr or res.stdout).strip()}"
            )
        return res

    # 1. Orphan a fresh history and commit the current tree as one commit.
    _step(["checkout", "--orphan", _TMP_BRANCH], "git checkout --orphan")
    _step(["add", "-A"], "git add")
    name, email = _parse_author_identity(MEMORY_AUTHOR)
    _step(
        [
            "-c", f"user.name={name}",
            "-c", f"user.email={email}",
            "-c", "commit.gpgsign=false",
            "commit", "--allow-empty", "--no-verify", "-m", message,
        ],
        "git commit",
    )

    # 2. Move the single commit onto the repo's real branch.
    if branch != _TMP_BRANCH and _branch_exists(root, branch):
        _step(["branch", "-D", branch], "git branch -D")
    _step(["branch", "-m", _TMP_BRANCH, branch], "git branch -m")

    new_commit = _run_git(root, ["rev-parse", "HEAD"]).stdout.strip()

    # 3. Make the superseded objects unreachable AND collectible. Reflogs still
    #    reference the old commits (which would keep gc from pruning them), so
    #    expire every reflog before gc --prune=now.
    _run_git(root, ["reflog", "expire", "--expire=now", "--all"], env=env)
    _step(["gc", "--prune=now", "--quiet"], "git gc")

    had_remote = _remote_url(root, remote) is not None
    if had_remote:
        note = (
            f"History rewritten on '{branch}'. A FORCE push to '{remote}' is "
            "REQUIRED to propagate (a normal push is rejected as non-fast-forward)."
        )
    else:
        note = (
            f"History rewritten on '{branch}'. No '{remote}' remote — the rewrite "
            "is local only; nothing to push."
        )
    logger.info("purge_bundle_history: %s", note)
    return PurgeResult(
        branch=branch,
        new_commit=new_commit,
        previous_head=previous_head,
        had_remote=had_remote,
        force_push_required=had_remote,
        message=note,
    )

force_push_purged

force_push_purged(repo_root: str | Path, *, remote: str = 'origin', branch: str | None = None, source_repo_root: str | Path | None = None) -> PushResult

Force-push a rewritten branch to remote (opt-in, loud, gated).

A history rewrite is not a fast-forward, so this uses git push --force — it OVERWRITES the remote branch with the local (post-purge) single-commit history, discarding the superseded ciphertext commits on the remote. This is the only way to propagate a :func:purge_bundle_history rewrite, and it is intentionally destructive of the remote's old history.

When no remote is configured this is a clean no-op: it returns a :class:PushResult with pushed=False and an explanatory message rather than raising. Raises :class:BundleGitError when repo_root is not a git repo, is a nested/enclosing repo or the source/workspace repo itself (see :func:_require_standalone_repo), or the force push itself fails.

When source_repo_root is given, BOTH the bundle remote's fetch AND push url (remote.<name>.pushurl — what git push actually uses) are compared, after canonicalization, against every remote URL (fetch + push, all remotes) of the source/workspace repo BEFORE pushing; a normalized match on either side is refused with :class:BundleGitError and NO push happens. This closes the "standalone bundle repo whose origin (or pushurl) was cloned from the workspace" gap, where a single-commit force push would clobber the project's own remote — including the scp-form / scheme / userinfo / host-case spellings that a naive compare would miss.

Source code in memory/sharing/bundle_git.py
def force_push_purged(
    repo_root: str | Path,
    *,
    remote: str = "origin",
    branch: str | None = None,
    source_repo_root: str | Path | None = None,
) -> PushResult:
    """Force-push a rewritten branch to ``remote`` (opt-in, loud, gated).

    A history rewrite is not a fast-forward, so this uses ``git push --force`` —
    it OVERWRITES the remote branch with the local (post-purge) single-commit
    history, discarding the superseded ciphertext commits on the remote. This is
    the only way to propagate a :func:`purge_bundle_history` rewrite, and it is
    intentionally destructive of the remote's old history.

    When no ``remote`` is configured this is a clean no-op: it returns a
    :class:`PushResult` with ``pushed=False`` and an explanatory message rather
    than raising. Raises :class:`BundleGitError` when ``repo_root`` is not a git
    repo, is a nested/enclosing repo or the source/workspace repo itself (see
    :func:`_require_standalone_repo`), or the force push itself fails.

    When ``source_repo_root`` is given, BOTH the bundle remote's fetch AND push
    url (``remote.<name>.pushurl`` — what ``git push`` actually uses) are
    compared, after canonicalization, against every remote URL (fetch + push, all
    remotes) of the source/workspace repo BEFORE pushing; a normalized match on
    either side is refused with :class:`BundleGitError` and NO push happens. This
    closes the "standalone bundle repo whose origin (or pushurl) was cloned from
    the workspace" gap, where a single-commit force push would clobber the
    project's own remote — including the scp-form / scheme / userinfo / host-case
    spellings that a naive compare would miss.
    """
    root = _require_standalone_repo(repo_root, source_repo_root=source_repo_root)
    target = branch or _current_branch(root)

    url = _remote_url(root, remote)
    if url is None:
        msg = f"no '{remote}' remote configured; nothing to push"
        logger.info("force_push_purged: %s", msg)
        return PushResult(
            pushed=False, forced=False, remote=remote, branch=target, message=msg
        )

    # Refuse when the bundle's remote (fetch OR push url) points at one of the
    # source repo's remotes: a force push would otherwise overwrite the project's
    # own remote history. ``_require_standalone_repo`` already fail-closed on an
    # unresolvable source, so here it always resolves.
    if source_repo_root is not None:
        source_top = _require_repo_root(source_repo_root)
        source_urls = _all_remote_urls(source_top)
        bundle_urls = _remote_urls(root, remote)
        collision = bundle_urls & source_urls
        if collision:
            raise BundleGitError(
                f"refusing to force-push: the bundle repo's '{remote}' URL "
                f"({url}) matches a remote of the angelo source/workspace "
                "repo. Force-pushing the collapsed single-commit history "
                "would overwrite your project's own remote. Point the bundle "
                "repo's remote at a SEPARATE bundle remote."
            )

    res = _run_git(root, ["push", "--force", remote, target], env=_git_env())
    if res.returncode != 0:
        raise BundleGitError(
            f"force push to {remote}/{target} failed: "
            f"{(res.stderr or res.stdout).strip()}"
        )
    msg = (
        f"FORCE-pushed rewritten history to {remote}/{target} "
        "(overwrote the remote branch; a normal push would have been rejected as "
        "non-fast-forward)."
    )
    logger.info("force_push_purged: %s", msg)
    return PushResult(
        pushed=True, forced=True, remote=remote, branch=target, message=msg
    )