Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Then initialize the store once:
```bash
$ quorumgit init
store: /home/you/.quorumgit/quorumgit.db
migrations applied: ['001_core.sql', '002_approval_identities.sql', '004_approval_authority.sql', '005_content_governance.sql', '006_ref_updates.sql', '007_ref_update_scope_paths.sql', '008_operation_policies.sql', '009_approval_quorum.sql']
migrations applied: ['001_core.sql', '002_approval_identities.sql', '004_approval_authority.sql', '005_content_governance.sql', '006_ref_updates.sql', '007_ref_update_scope_paths.sql', '008_operation_policies.sql', '009_approval_quorum.sql', '010_protected_ref_names.sql']
contract: ok
```

Expand Down Expand Up @@ -277,6 +277,8 @@ Rules that hold no matter what:

Registering a `reviewer` or `operator`, changing a role (`quorumgit agent role <name> <role>`), and changing repository policy are themselves authority decisions, so each requires an operator acting through `--agent` or `QUORUMGIT_AGENT`, and each is audited. The single exception is bootstrap: while no operator exists, the first one can be designated by anyone. The last remaining operator cannot be demoted, so a store never falls back into bootstrap mode by accident.

> **Upgrading to full protected ref names.** Protected refs are matched against the full names Git hands the hooks, so one registered as a short name (`--protected-ref main`) used to protect nothing. Migration `010_protected_ref_names.sql` rewrites short names to the branch they name (`main` becomes `refs/heads/main`); from then on pushes to those branches need their `protected_ref_update` approval, as intended. Check `quorumgit repo policy <repo>` after running `quorumgit init`.

> **Upgrading to per-operation policy.** Migration `008_operation_policies.sql` adds no overrides, so every operation keeps following the repository default until an operator sets one.

> **Upgrading to content governance.** Migration `005_content_governance.sql` restricts every existing repository to `refs/heads/`. If your agents push tags or other refs, allow those namespaces with `quorumgit repo allow-ref` after running `quorumgit init`. Claimed branches now also enforce their scopes at push time, so a claim declared too narrowly will ask for `out_of_scope_push` approvals.
Expand All @@ -295,10 +297,11 @@ The incumbent's checkout is not duplicated. When a claim supersedes an earlier c
| `quorumgit status` | Store health, contract check, row counts |
| `quorumgit doctor [--repair]` | Detect and conservatively reconcile managed-worktree drift and stuck ref updates |
| `quorumgit destroy --yes` | Delete the database file (managed worktrees under `QUORUMGIT_DATA_DIR/worktrees` are left in place) |
| `quorumgit repo add <name> <path> [--protected-ref <ref>]… [--protected-path <glob>]…` | Register a repository |
| `quorumgit repo add <name> <path> [--protected-ref <ref>]… [--protected-path <glob>]…` | Register a repository (a bare `--protected-ref` name is taken as a branch: `main` is stored as `refs/heads/main`) |
| `quorumgit agent add <name> [--role worker\|reviewer\|operator]` | Register an agent identity (non-workers need an operator once one exists) |
| `quorumgit agent role <name> <role>` | Change an agent's role (operator only, after bootstrap) |
| `quorumgit repo policy <name> [--operation <type> [--inherit]] [--threshold <n>] [--role <role>]… [--[no-]requester-may-vote] [--[no-]quorum]` | Show or change a repository's default approval policy, or its override for one operation type (changes are operator only); also shows protected refs, protected paths, and allowed ref namespaces |
| `quorumgit repo protect-ref <name> <ref> [--remove]` | Require a `protected_ref_update` approval for every update of a ref, such as `refs/heads/main`; a bare name is taken as a branch (operator only) |
| `quorumgit repo protect-path <name> <glob> [--remove]` | Require an approval for any push changing matching paths (operator only) |
| `quorumgit repo allow-ref <name> <prefix> [--remove]` | Allow pushes to a ref namespace such as `refs/tags/` (operator only) |
| `quorumgit task add --repo <name> --title <t> [--objective <o>]` | Create a task |
Expand All @@ -320,7 +323,7 @@ The incumbent's checkout is not duplicated. When a claim supersedes an earlier c
| `quorumgit hook install --repo <name>` | Install the pre-receive and reference-transaction hooks (hub model) |
| `quorumgit audit [--entity <e>] [--entity-id <id>] [--limit <n>]` | Read the audit trail |

`repo list`, `agent list`, and `task list [--repo <name>]` enumerate what's registered. Commands that act as an agent (`claim`, `renew`, `release`, `task done`, `checkpoint`, `handoff create/accept/decline/cancel`, `approve request/vote`, `approve prepare`, and the operator actions `task reopen`, `agent add`, `agent role`, `repo policy`, `repo protect-path`, `repo allow-ref`) also take `--agent <name>`, which overrides `QUORUMGIT_AGENT`. At least one `--scope` is required to claim. Exit codes: `0` success, `1` refused/violation/error, `2` usage error.
`repo list`, `agent list`, and `task list [--repo <name>]` enumerate what's registered. Commands that act as an agent (`claim`, `renew`, `release`, `task done`, `checkpoint`, `handoff create/accept/decline/cancel`, `approve request/vote`, `approve prepare`, and the operator actions `task reopen`, `agent add`, `agent role`, `repo policy`, `repo protect-ref`, `repo protect-path`, `repo allow-ref`) also take `--agent <name>`, which overrides `QUORUMGIT_AGENT`. At least one `--scope` is required to claim. Exit codes: `0` success, `1` refused/violation/error, `2` usage error.

`quorumgit doctor` only checks worktree paths already recorded by QuorumGit. It reports:

Expand Down
25 changes: 24 additions & 1 deletion src/quorumgit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,20 @@ def cmd_repo_policy(args, cfg) -> int:
return 0


def cmd_repo_protect_ref(args, cfg) -> int:
with store.session(cfg) as conn:
refs = registry.set_protected_ref(
conn,
args.name,
args.ref,
actor=getattr(args, "agent", None) or cfg.agent,
remove=args.remove,
)
conn.commit()
print(f"protected refs for {args.name}: {', '.join(refs) or '-'}")
return 0


def cmd_repo_protect_path(args, cfg) -> int:
with store.session(cfg) as conn:
globs = registry.set_protected_path(
Expand Down Expand Up @@ -733,7 +747,9 @@ def add(name, fn, configure=None, parent=None):
add("add", cmd_repo_add, lambda sp: (
sp.add_argument("name"),
sp.add_argument("path"),
sp.add_argument("--protected-ref", action="append", default=[]),
sp.add_argument("--protected-ref", action="append", default=[],
help="ref needing approval for every update, such as "
"refs/heads/main (a bare name is taken as a branch)"),
sp.add_argument("--protected-path", action="append", default=[],
help="path glob whose changes always need an approval"),
), parent=repo)
Expand All @@ -760,6 +776,13 @@ def add(name, fn, configure=None, parent=None):
help="with --operation: remove the override so the "
"type inherits the default again"),
), parent=repo)
add("protect-ref", cmd_repo_protect_ref, lambda sp: (
sp.add_argument("name"),
sp.add_argument("ref", help="ref such as refs/heads/main (a bare name "
"is taken as a branch)"),
sp.add_argument("--remove", action="store_true"),
sp.add_argument("--agent", help="operator making the change"),
), parent=repo)
add("protect-path", cmd_repo_protect_path, lambda sp: (
sp.add_argument("name"),
sp.add_argument("glob"),
Expand Down
121 changes: 91 additions & 30 deletions src/quorumgit/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,32 @@ def _requirement_message(operation: dict, refusal: str | None) -> str:
return message


def update_paths(
objects_dir: str | Path,
hub_dir: str | Path,
repo: dict,
oldrev: str,
newrev: str,
known_tips: list[str] | None = None,
) -> tuple[list[str], list[str]]:
"""(paths, scope_paths) for one ref update, from one walk of its commits.

Objects are read from ``objects_dir`` (the hub inside the hook, the
agent's clone for ``approve prepare``); both baselines come from the
registered hub: every ref tip for ``paths``, the mainline for
``scope_paths`` (see governed_operations).
"""
if known_tips is None:
known_tips = git_objects.ref_tips(hub_dir)
paths, scope_paths = git_objects.changed_paths_against(
objects_dir,
oldrev,
newrev,
[known_tips, git_objects.mainline_tips(hub_dir, repo["protected_refs"])],
)
return paths, scope_paths


def evaluate_ref_update(
conn: Connection,
repository: str,
Expand Down Expand Up @@ -696,17 +722,12 @@ def evaluate_ref_update(
)

try:
if paths is None:
paths = git_objects.changed_paths(
git_dir, oldrev, newrev, git_objects.ref_tips(git_dir)
)
if scope_paths is None:
scope_paths = git_objects.changed_paths(
git_dir,
oldrev,
newrev,
git_objects.mainline_tips(git_dir, repo["protected_refs"]),
if paths is None or scope_paths is None:
derived_paths, derived_scope_paths = update_paths(
git_dir, git_dir, repo, oldrev, newrev
)
paths = derived_paths if paths is None else paths
scope_paths = derived_scope_paths if scope_paths is None else scope_paths
operations = governed_operations(
conn, repo, refname, oldrev, newrev, git_dir, paths, scope_paths
)
Expand Down Expand Up @@ -741,6 +762,8 @@ def check_ref_update(
oldrev: str,
newrev: str,
refname: str,
paths: list[str] | None = None,
scope_paths: list[str] | None = None,
) -> None:
"""pre-receive: validate one ref update and record it for its transaction.

Expand All @@ -749,7 +772,15 @@ def check_ref_update(
once Git has locked the ref, in the reference-transaction hook.
"""
repo, granted, paths, scope_paths = evaluate_ref_update(
conn, repository, git_dir, pusher, oldrev, newrev, refname
conn,
repository,
git_dir,
pusher,
oldrev,
newrev,
refname,
paths=paths,
scope_paths=scope_paths,
)
operations = [operation for _, operation in granted]
row = conn.execute(
Expand Down Expand Up @@ -844,13 +875,9 @@ def prepare_push(
"ref you can push to before preparing this push, so the plan "
"matches what the hook will see."
)
paths = git_objects.changed_paths(local_dir, oldrev, newrev, known)
# Mainline tips are hub ref tips, so the check above covers them too.
scope_paths = git_objects.changed_paths(
local_dir,
oldrev,
newrev,
git_objects.mainline_tips(hub_dir, repo["protected_refs"]),
paths, scope_paths = update_paths(
local_dir, hub_dir, repo, oldrev, newrev, known_tips=known
)

refusals: list[str] = []
Expand Down Expand Up @@ -890,6 +917,31 @@ def prepare_push(
}


def _derive_pushed_paths(
conn: Connection,
repository: str,
git_dir: str,
updates: list[tuple[str, str, str]],
) -> list[tuple[list[str], list[str]]]:
"""(paths, scope_paths) for every update, read from Git alone.

This is the expensive part of a push check (it reads every new commit),
so it runs before the store's write reservation is taken: other agents'
commands and pushes are not held up while a large push is inspected.
Commits are immutable, and the paths are recorded with the validation,
so the reference transaction re-checks the update against these same
paths under the ref locks.
"""
repo = get_repository(conn, repository)
derived = []
for oldrev, newrev, _refname in updates:
try:
derived.append(update_paths(git_dir, git_dir, repo, oldrev, newrev))
except git_objects.GitObjectError as exc:
raise PushRejected(f"Unable to inspect pushed objects: {exc}") from exc
return derived


def run_pre_receive(
conn: Connection, repository: str, stdin_lines: Iterable[str]
) -> int:
Expand All @@ -900,7 +952,6 @@ def run_pre_receive(
return 1
pusher = os.environ.get("QUORUMGIT_AGENT") or None
try:
begin_immediate(conn)
if pusher is None:
raise PushRejected(
"Pusher identity required; set QUORUMGIT_AGENT to a registered agent."
Expand All @@ -909,17 +960,25 @@ def run_pre_receive(
get_agent(conn, pusher)
except RegistryError as exc:
raise PushRejected(f"Pusher identity is not registered: {pusher}") from exc
saw_update = False
for line in stdin_lines:
parts = line.split()
if not parts:
continue
if len(parts) != 3:
raise PushRejected(f"Malformed pre-receive input: {line!r}")
saw_update = True
check_ref_update(conn, repository, git_dir, pusher, *parts)
if not saw_update:
updates = _parse_updates(stdin_lines, hook="pre-receive")
if not updates:
raise PushRejected("No ref updates supplied on stdin.")
derived = _derive_pushed_paths(conn, repository, git_dir, updates)
begin_immediate(conn)
for (oldrev, newrev, refname), (paths, scope_paths) in zip(
updates, derived, strict=True
):
check_ref_update(
conn,
repository,
git_dir,
pusher,
oldrev,
newrev,
refname,
paths=paths,
scope_paths=scope_paths,
)
# Without the transaction hook nothing would re-validate at update
# time or consume approvals, so a missing one must reject the push.
_require_reference_transaction_hook(conn, repository)
Expand All @@ -945,14 +1004,16 @@ def run_pre_receive(
PREPARED_STUCK_AFTER_SECONDS = VALIDATION_TTL_SECONDS


def _parse_updates(stdin_lines: Iterable[str]) -> list[tuple[str, str, str]]:
def _parse_updates(
stdin_lines: Iterable[str], hook: str = "reference-transaction"
) -> list[tuple[str, str, str]]:
updates = []
for line in stdin_lines:
parts = line.split()
if not parts:
continue
if len(parts) != 3:
raise PushRejected(f"Malformed reference-transaction input: {line!r}")
raise PushRejected(f"Malformed {hook} input: {line!r}")
updates.append((parts[0], parts[1], parts[2]))
return updates

Expand Down
67 changes: 55 additions & 12 deletions src/quorumgit/git_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,18 +195,45 @@ def new_commits(


def commit_paths(git_dir: str | Path, commit: str) -> list[str]:
"""Paths one commit introduces.
"""Paths one commit introduces (see paths_by_commit)."""
return paths_by_commit(git_dir, [commit])[commit]


def paths_by_commit(git_dir: str | Path, commits: list[str]) -> dict[str, list[str]]:
"""Paths each commit introduces, read with a single Git process.

Root commits are compared with the empty tree. Merges use Git's combined
diff, so a clean merge introduces nothing of its own and only paths that
differ from every parent (conflict resolutions, evil merges) are counted.

The raw format keeps the parse exact: Git heads each commit's entries with
its object ID, and every path follows its own `:`-prefixed status record,
so no path can be mistaken for the next commit's ID.
"""
result: dict[str, list[str]] = {commit: [] for commit in commits}
if not commits:
return result
out = _git(
git_dir,
"diff-tree", "-r", "-c", "--root", "--no-renames", "--name-only",
"--no-commit-id", "-z", commit,
"diff-tree", "--stdin", "--always", "-r", "-c", "--root", "--no-renames",
"--raw", "-z",
stdin="".join(f"{commit}\n" for commit in result),
)
return [_text_path(path) for path in out.split("\0") if path]
tokens = iter(out.split("\0"))
current: str | None = None
for token in tokens:
if not token:
continue
if token.startswith(":"):
path = next(tokens, "")
if current is None or not path:
raise GitObjectError("git diff-tree produced an unexpected record.")
result[current].append(_text_path(path))
elif token in result:
current = token
else:
raise GitObjectError(f"git diff-tree reported an unrequested commit {token}.")
return result


def changed_paths(
Expand All @@ -221,12 +248,28 @@ def changed_paths(
checked only on claimed branches, so they are measured against the
mainline alone (mainline_tips). A deletion carries no content.
"""
return changed_paths_against(git_dir, oldrev, newrev, [known_tips])[0]


def changed_paths_against(
git_dir: str | Path,
oldrev: str,
newrev: str,
baselines: list[list[str]],
) -> list[list[str]]:
"""changed_paths for each baseline, reading every commit's paths once.

The number of Git processes is fixed (one commit walk per baseline and a
single diff-tree), however many commits the update brings in.
"""
if is_zero(newrev):
return []
known = list(known_tips)
if not is_zero(oldrev):
known.append(oldrev)
paths: set[str] = set()
for commit in new_commits(git_dir, newrev, known):
paths.update(commit_paths(git_dir, commit))
return sorted(paths)
return [[] for _ in baselines]
old = [] if is_zero(oldrev) else [oldrev]
walks = [new_commits(git_dir, newrev, [*tips, *old]) for tips in baselines]
by_commit = paths_by_commit(
git_dir, list(dict.fromkeys(commit for walk in walks for commit in walk))
)
return [
sorted({path for commit in walk for path in by_commit[commit]})
for walk in walks
]
Loading
Loading