From 5c9f4dd16ed6ab2ed532a6410df38d478ae36fe6 Mon Sep 17 00:00:00 2001 From: omind conveyor factory Date: Sat, 3 Oct 2026 09:09:41 -0500 Subject: [PATCH] fix(rules): judge unique-prefix push options and glob refspecs (#443) git takes any unique prefix of a long option, so `git push --al origin` is `--all` and `--mirr` is `--mirror`; the push parser matched only the exact tokens and read them as a bare push of the checked-out branch. Push options now resolve by unique prefix over git push's long options via policy._long_prefixes. A refspec holding `*` (on the command line or in remote..push) is judged against every local ref its source matches, mapped to the destination, instead of a literal branch named `*`. Fails open to the literal destination when git cannot list refs. Closes #443 Co-Authored-By: Claude Opus 5.5 --- BACKLOG.md | 10 ++++- CHANGELOG.md | 15 +++++++ src/omind/rules.py | 96 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_rules.py | 73 ++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 6 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 62baeb7..c0d94c3 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -11,8 +11,6 @@ _Mirrors the [GitHub Issues tab](https://github.com/CryptoJones/omind/issues). All open items are sequenced by priority (Priority 1 to 7) so future agents know the exact order of operations._ -- [ ] **rules: unique-prefix push options (`--al`, `--mirr`) and glob refspecs reach main from a feature branch** ([#443](https://github.com/CryptoJones/omind/issues/443)) — _bug_ — - follow-up from the #433 review: git takes any unique prefix of `--all` / `--mirror`, and `'refs/heads/*:refs/heads/*'` sends `main`. - [ ] **README: link Flatline Roundtable and conveyor in the first lines** ([#436](https://github.com/CryptoJones/omind/issues/436)) — _docs_ — prerequisites: #429 (merged) and #428. Also adds the Nebraska banner and drops the stale Codeberg badge. - [ ] **Run conveyor as omind's backlog factory on dl380** ([#428](https://github.com/CryptoJones/omind/issues/428)) — _enhancement (infra)_ — @@ -79,6 +77,14 @@ _Closed as not planned or rejected. Kept for the record; nothing here is pending ## Done +### Shipped — unreleased + +- [x] **rules: unique-prefix push options (`--al`, `--mirr`) and glob refspecs reach main from a feature branch** ([#443](https://github.com/CryptoJones/omind/issues/443)) — _bug_ — + a unique prefix of a git push long option is read as that option (`--al`/`--bra` are `--all`, + `--mirr` is `--mirror`); a glob refspec, on the command line or in `remote..push`, is judged + against the local refs its source matches, mapped to the destination. Fails open to the literal + destination. + ### Shipped — 2026-10-02 (v10.2.28) - [x] **guard: `decide()` raises OSError (ENAMETOOLONG) on long cd chains; add an overall fail-closed hook deadline** ([#460](https://github.com/CryptoJones/omind/issues/460)) — **v10.2.28** — _bug_ — diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d0b8a..c05cbbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **The repo-scoped push rules see unique-prefix push options and glob refspecs + ([#443](https://github.com/CryptoJones/omind/issues/443)).** git takes any + unique prefix of a long option, so `git push --al origin` is `--all` and + `git push --mirr origin` is `--mirror`; the rules read them as a bare push of + the checked-out branch, and a push from `feature/x` reached a public `main`. + Push options now resolve by unique prefix over git push's own long options + (`policy._long_prefixes`); an ambiguous prefix (`--a`) stays a plain flag. + - A refspec holding `*` (`'refs/heads/*:refs/heads/*'`, `'*:*'`) is judged + against every local ref its source matches, by full refname as git matches + it, mapped to the destination. A configured `remote..push` glob is + judged the same way for a bare `git push`. Negative (`^`) refspecs are + skipped. When git cannot list refs, the destination is read literally, as + before. + ## [10.2.28] - 2026-10-02 ### Fixed diff --git a/src/omind/rules.py b/src/omind/rules.py index e6dcd4d..1e49b8c 100644 --- a/src/omind/rules.py +++ b/src/omind/rules.py @@ -472,7 +472,9 @@ def _default_push_branches(repo: Path, remote: str, config: tuple[str, ...] = () ).split() for spec in specs: src, _, dst = spec.lstrip("+").partition(":") - if src.upper() in _CURRENT_BRANCH_REFS: + if "*" in spec and not spec.startswith("^"): + dests += _glob_branches(repo, src, dst or src) + elif src.upper() in _CURRENT_BRANCH_REFS: dests.append(dst.removeprefix("refs/heads/") or current) # `push.default=matching`, or a configured `:` refspec (which wins # over push.default), pushes every local branch the remote also has, @@ -678,8 +680,81 @@ def unquote(m: re.Match[str]) -> str: #: Marker prefix for the ``:`` refspec (``git push origin :``): every local #: branch the remote also has (#433). The remote follows. _MATCHING = "(matching):" +#: Marker prefix for a glob refspec (``refs/heads/*:refs/heads/*``, #443): +#: the source pattern and the destination, split by :data:`_CONFIG_SEP`. +_GLOB = "(glob):" #: Push options that send every local branch whatever the refspecs (#433). _ALL_BRANCH_OPTS = frozenset({"--all", "--branches"}) +#: git push's long options (builtin/push.c, `git push -h`), so a unique prefix +#: (`--al`, `--mirr`) is read as git reads it (#443). +_PUSH_LONG_OPTS = [ + f"--{name}" + for name in ( + "all", + "atomic", + "branches", + "delete", + "dry-run", + "exec", + "follow-tags", + "force", + "force-if-includes", + "force-with-lease", + "ipv4", + "ipv6", + "mirror", + "no-verify", + "porcelain", + "progress", + "prune", + "push-option", + "quiet", + "receive-pack", + "recurse-submodules", + "repo", + "set-upstream", + "signed", + "tags", + "thin", + "verbose", + "verify", + ) +] +#: Each spelling git accepts for a push long option, mapped to the option. +_PUSH_LONG_SPELLINGS = { + spelling: spelling + if spelling in _PUSH_LONG_OPTS + else next(o for o in _PUSH_LONG_OPTS if o.startswith(spelling)) + for spelling in policy._long_prefixes(_PUSH_LONG_OPTS) +} + + +def _glob_dest(src: str, dst: str, ref: str) -> str | None: + """``ref`` mapped through the one-``*`` refspec ``src:dst`` as git maps + it, or None when ``src`` does not match it (#443).""" + head, _, tail = src.partition("*") + if len(ref) < len(head) + len(tail) or not ref.startswith(head) or not ref.endswith(tail): + return None + return dst.replace("*", ref[len(head) : len(ref) - len(tail)], 1) + + +def _glob_branches(repo: Path, src: str, dst: str) -> list[str]: + """Branches a glob refspec ``src:dst`` lands on in ``repo`` (#443): every + local ref the source pattern matches (git matches the full refname, + unqualified), mapped to the destination. Fails open to the destination + read literally, as before #443.""" + literal = [dst.removeprefix("refs/heads/")] + try: + out = _git_out(repo, "for-each-ref", "--format=%(refname)") + if out is None: + return literal + mapped = (_glob_dest(src, dst, ref) for ref in out.split()) + return [d.removeprefix("refs/heads/") for d in mapped if d is not None] + except deadline.DeadlineExceededError: + raise + except Exception as exc: # noqa: BLE001 - enforcement fails open + _breadcrumb(f"rules_glob({repo})", exc) + return literal def _pushed_branches(command: str, *, in_code: bool = True) -> list[str] | None: @@ -722,13 +797,15 @@ def _pushed_branches(command: str, *, in_code: bool = True) -> list[str] | None: positional: list[str] = [] mirror = False for token in tokens: - if token == "--tags": + # A unique prefix is the option (`--al` is `--all`, #443). + option = _PUSH_LONG_SPELLINGS.get(token.partition("=")[0], token) + if option == "--tags": refs.append("(tags)") continue - if token in _ALL_BRANCH_OPTS: + if option in _ALL_BRANCH_OPTS: refs.append(_ALL_BRANCHES) continue - if token == "--mirror": + if option == "--mirror": mirror = True continue if token.startswith("-"): @@ -739,6 +816,12 @@ def _pushed_branches(command: str, *, in_code: bool = True) -> list[str] | None: if token.lstrip("+") == ":": refs.append(_MATCHING + positional[0]) continue + if token.startswith("^"): + continue # a negative refspec only excludes + if "*" in token: + src, _, dst = token.lstrip("+").partition(":") + refs.append(_GLOB + src + _CONFIG_SEP + (dst or src)) + continue if ":" not in token and token.lstrip("+").upper() in _CURRENT_BRANCH_REFS: refs.append(_CURRENT_REF) continue @@ -845,6 +928,11 @@ def fact(name: str, fn: Any) -> Any: f"push:{branch}", functools.partial(_default_push_branches, remote=remote, config=tuple(config)), ) + elif branch.startswith(_GLOB): + src, _, dst = branch.removeprefix(_GLOB).partition(_CONFIG_SEP) + branches += fact( + f"glob:{branch}", functools.partial(_glob_branches, src=src, dst=dst) + ) elif branch == _ALL_BRANCHES: branches += fact("local_branches", _local_branches) elif branch.startswith(_MIRROR): diff --git a/tests/test_rules.py b/tests/test_rules.py index 091df37..b03f9ee 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -1472,6 +1472,79 @@ def test_matching_push_to_an_unfetched_remote_or_url_judges_every_branch( assert _denied(omi, command, repo, monkeypatch), command +@pytest.mark.parametrize( + "command", + [ + "git push --al origin", + "git push --bra origin", + "git push --b origin", + "git push --mirr origin", + "git push --m", + "git push origin 'refs/heads/*:refs/heads/*'", + "git push origin '+refs/heads/*:refs/heads/*'", + "git push origin '*:*'", + "git push origin 'refs/heads/ma*:refs/heads/ma*'", + ], +) +def test_prefix_and_glob_pushes_from_feature_branch_are_denied( + remote_has_main: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, command: str +) -> None: + """#443: a unique prefix of `--all`/`--branches`/`--mirror` is that option, + and a glob refspec sends every local ref its source matches, `main` too.""" + omi, repo = remote_has_main + assert _denied(omi, command, repo, monkeypatch), command + + +def test_ambiguous_prefix_and_feature_glob_are_allowed( + remote_has_main: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """#443: `--a` (all/atomic) is ambiguous to git, so it stays a plain flag; + a glob whose source matches only `feature/*` never reaches `main`.""" + omi, repo = remote_has_main + for command in ( + "git push --a origin", + "git push --at origin feature/x", + "git push --repo=origin", + "git push origin 'refs/heads/feature/*:refs/heads/feature/*'", + "git push origin 'feature/*:feature/*'", # git matches full refnames only + "git push origin '^refs/heads/main' feature/x", + ): + assert not _denied(omi, command, repo, monkeypatch), command + + +def test_configured_glob_push_refspec_is_denied( + remote_has_main: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """#443: a stored `remote.origin.push` glob makes a bare push send `main`.""" + omi, repo = remote_has_main + _git(repo, "config", "remote.origin.push", "refs/heads/feature/*:refs/heads/feature/*") + for command in ("git push", "git push origin"): + assert not _denied(omi, command, repo, monkeypatch), command + _git(repo, "config", "remote.origin.push", "+refs/heads/*:refs/heads/*") + for command in ("git push", "git push origin"): + assert _denied(omi, command, repo, monkeypatch), command + + +def test_glob_lookup_failure_judges_the_destination_literally( + remote_has_main: tuple[Path, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """AGENTS.md invariant 2: when git cannot list refs the glob fails open to + its destination read literally, as before #443.""" + omi, repo = remote_has_main + not_a_repo = tmp_path / "plain-443" + not_a_repo.mkdir() + assert rules._glob_branches(not_a_repo, "refs/heads/*", "refs/heads/*") == ["*"] + command = "git push origin 'refs/heads/*:refs/heads/*'" + assert rules.evaluate(_action(command), omi, not_a_repo) is None + + def no_git(*args: object, **kwargs: object) -> object: + raise FileNotFoundError("git") + + monkeypatch.setattr(rules.subprocess, "run", no_git) + assert rules._glob_branches(repo, "refs/heads/*", "refs/heads/*") == ["*"] + assert rules.evaluate(_action(command), omi, repo) is None + + @pytest.mark.parametrize("count", [10_000, 100_000]) @pytest.mark.parametrize("shape", ["x=1;", "bash -c 'echo hi; '"], ids=["assignments", "bash-c"]) def test_long_repeats_are_judged_within_a_second(