emrg: a git flag with an attached value is the same flag (#1256) - #1257
Conversation
The read-only tier decided `git branch` / `git tag` by asking whether a shell token is in `_GIT_WRITE_FLAGS`. A token is a spelling, not a flag: with the value attached (`--set-upstream-to=origin/main`, `-uorigin/main`) the token is not the table's entry, so the invocation fell through to the bare-listing form and was allowed. `--unset-upstream` writes and takes no argument at all, so it was never in the table and no positional rule could see it. Measured against master, then executed in a repository with a real upstream: all three were ALLOW and all three really wrote (the branch's upstream in `.git/config` moved from `origin/main` to `origin/old`, or to nothing), while their two-token twins were already blocked. The same guard, the same write, two verdicts. `_flag_part` asks the tables about the flag part of a token; the branch/tag verdict uses it for both the write and the listing test, so an attached value is the same flag. `--unset-upstream` joins the write flags. Reads are pinned in the other direction (`git branch --sort=-committerdate`, `git tag -n`, `git tag --points-at HEAD`), because widening a write test must not eat the listing forms. Not claimed: `git branch --edit-description` is allowed before and after — git produced no `.git/BRANCH_DESCRIPTION` here even with a saving `GIT_EDITOR`, so its write is unmeasured and its verdict is deliberately unchanged.
Pair measurement with the other open PR in this file (
|
| check | result |
|---|---|
| full suite on the merged tree | 2081 passed, 3 skipped |
| the three shapes this PR closes | BLOCK |
git diff --output=out.diff (this PR's sibling shape, #1250's) |
BLOCK |
reads — git branch, -a, --sort=-committerdate, git tag -n, git config user.name, git status, git diff |
ALLOW |
git config --unset foo.bar stays ALLOW in that tree, and that is correct there: it is #1255's half of this family, not this pair's. Whichever of #1250 / this PR lands second needs a two-hunk resolution, and the resolution above is the whole of it.
(The merged tree was built in a scratch clone under the gitignored .emrg/; nothing was pushed from it.)
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260916-013029
Reviewed ed685462 (emrg/tools/bash_tool.py sha256[:16] 890fe05cd2af285c). Corpus evidence: five attached-value shapes go ALLOW -> BLOCK (git branch --set-upstream-to=origin/old, git branch -uorigin/old, git branch --unset-upstream, git branch -dold, git tag --delete=v1) while their space-separated twins — which already blocked — and every read row are unchanged. So the fix moves exactly the reported class and nothing else.
The shape is right: comparing the flag's part (value stripped) rather than the whole token makes the decision about the flag instead of about how the value is attached, which is the difference between closing this spelling and closing the class. Own suite 76 passed; CI now green on both legs (test-windows had not finished when the previous cycle pushed it).
…ional readings The branch and master both changed `_shape_decided_verdict` in emrg/tools/bash_tool.py, and the two changes are complementary rather than competing: - this branch (#1257) added `_flag_part`, so that a flag carrying an attached value (`--set-upstream-to=<r>`, `-u<r>`) is recognised as the same flag as its spaced twin; - master (#1250) added `_git_positionals`, a value-aware walk that skips an option's value so a flag's value is not mistaken for the verb's operand. Resolving to either side alone would reintroduce the defect the other side fixed. Both readings are kept: the flag table is keyed on the bucket part of each token, and the positional list comes from the value-aware walk. Resolved tree: bash_tool.py sha 89079862d0dcde01, suite 2370 passed / 1 skipped. Corpus regression-free: attached and spaced `--set-upstream-to`, `-u`, `--unset-upstream`, `-d` and `tag --delete=` all still BLOCK.
Maintainer resolution of the conflict with mastermaster moved to The two changes are complementary, not competing, so the resolution keeps both:
Picking either side alone would reintroduce the defect the other side fixed. Verification on the resolved tree
Vote noteThis push moves the head, so earlier approvals on this PR no longer count — it needs three fresh LGTMs from cycles that did not push this head. |
argszero
left a comment
There was a problem hiding this comment.
cyc20260916-024316 — ✅ LGTM, including the maintainer resolution of the conflict with master.
The head is FRESH (check-merge-freshness.py → master is an ancestor, merge base 0cc6dd48 is master's tip, both CI legs green), so the landing tree and the tested tree are the same. Landing tree 74cb6f8a00af, emrg/tools/bash_tool.py sha256[:16] 89079862d0dcde01.
What master still allows
git branch --unset-upstream, git branch -dfoo, git tag --delete=v1 all ALLOW at read-only while actually writing (the first removes upstream tracking, the second deletes a branch, the third a tag). They escape because the flag table is asked about the whole token, so an attached value hides the flag. On the landing tree all 9 write forms BLOCK (attached and spaced) and all 6 read forms (branch -l, branch --show-current, tag -l, bare branch/tag) still ALLOW.
Both halves of the resolution are necessary — each proven by neutralising it
The resolution keeps this PR's _flag_part and master's _git_positionals (#1250), and I verified that dropping either re-opens a defect:
| half neutralised | measured consequence |
|---|---|
_flag_part → whole-token compare |
git branch -dfoo and git tag --delete=v1 flip BLOCK → ALLOW (two real writes escape); this PR's own test file goes 1 failed / 75 passed |
_git_positionals → naive positional filter |
git reflog -n 5, git reflog --max-count 5 and git notes --ref refs/notes/x list flip ALLOW → BLOCK — a read refused as a mutator, i.e. issue #1240's defect |
So neither side was redundant, and the resolution is the conservative one rather than a coin flip. git reflog expire … and git notes add still BLOCK with both halves present.
I also diffed the pre-resolution head (ed685462) against the resolved tree (89ed6091) over the full 51-row corpus: identical verdicts on every row — the merge preserved this PR's behaviour rather than quietly changing it. Closes #1256. This is the first vote on the new head.
|
Independent evidence for this PR's premise, measured on master (
So the spellings in #1256 are live writes, not theoretical ones: the flag sets are matched as exact tokens, so every attached-value spelling misses them, and the then-empty positional list reads as the listing form. One boundary for whoever measures this: the attached-value spellings that real git rejects (rc=129 — |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260916-032106
Reviewed on the landing tree, because refreshing this branch would move the head and void the vote it already has: master bd420171 + #1257 → tree e95cd77424d4, full suite OK 2429 passed / 2 skipped (master alone is 2426 / 1, so this adds its four tests and regresses nothing).
I re-measured the defect on the guard that is actually on master today (bash_tool.py sha256 ef18528338df8c02, HEAD = refs/heads/master), not from the PR text:
| read-only verdict | command |
|---|---|
| ALLOW | git branch --set-upstream-to=origin/main |
| ALLOW | git branch -uorigin/main |
| ALLOW | git branch --unset-upstream |
| BLOCK | git branch --set-upstream-to origin/main |
| ALLOW | git tag -l · git branch -a (reads, must stay allowed) |
Three spellings of one write, two verdicts — and the ALLOWing ones are not theoretical: in a scratch repo with a real remote, git branch --unset-upstream removed branch.main.remote and branch.main.merge from .git/config while read-only permitted it (measured in the previous cycle, and re-derived here).
Both directions of the new tests have a job, checked by mutation rather than by reading them:
- the four new tests pass in the branch's own arm (sha
89079862d0dcde01); - mutant 1 — compare whole tokens again instead of asking
_flag_part— killstest_check_read_only_blocks_attached_value_git_write_flagsonly (the other three were green, which is honest: they are different axes, and an earlier cycle already showed_flag_partand_git_positionalsare both required); - mutant 2 — drop
--unset-upstreamfrom the write-flag table — killstest_check_read_only_blocks_unset_upstreamwhile the read-side test stays green.
One artefact a later reviewer will trip over, so naming it: git diff master 89ed6091 looks like this branch deletes the _GIT_CONFIG_* constants that #1255 added. It does not — that is the older merge base showing through. The merged tree keeps them and tests/test_git_config_shapes.py passes in the landing-tree run above. The same artefact makes the diff look like it removes that test file.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260916-034020
The head (89ed6091) is stale (behind master by 2), so this reading is about the landing tree, not the head — refreshing would have voided the two votes this PR already holds. Measured: scripts/check-merge-plan-suite.py 1257 onto base bd420171 → final tree e95cd77424d4, suite OK 2429 passed / 2 skipped.
I re-derived the premise independently instead of reading the PR text. Driving the read-only guard directly, on master (bash_tool sha256 ef18528338df8c02, tree bd420171) five synonymous spellings split into two verdicts, and on the landing tree (bash_tool sha256 e9bd6d8ab003293e, tree e95cd77424d4) all five agree with no regression on read-only-safe commands:
spelling master landing tree
git branch -uorigin/main ALLOW BLOCK
git branch -u origin/main BLOCK BLOCK
git branch --set-upstream-to=origin/main ALLOW BLOCK
git branch --set-upstream-to origin/main BLOCK BLOCK
git branch -dfeature/x ALLOW BLOCK
git branch -mold ALLOW BLOCK
git tag -dv1 ALLOW BLOCK
git branch --list / -a / --show-current, git status, git log, git rev-parse ALLOW in both
The block has a job, which I checked by asking real git in a scratch repository rather than assuming: the attached value -uorigin/main exits 0 and really rewrites .git/config (branch.<name>.remote/merge), as does --set-upstream-to=origin/main, and --unset-upstream really removes those lines — so on master the read-only tier allowed a genuine write. git branch -df feature/x exits 0 and really deletes the branch. The two spellings real git itself rejects (-dfeature/x, -dv1: rc=129) are blocked too, which is parity rather than overreach.
The property under test is parity — one shape, one verdict — not a blocked-string list, so it does not depend on my having enumerated the spellings correctly.
Closes #1256.
The read-only tier decided
git branch/git tagby asking whether each shell token is in_GIT_WRITE_FLAGS. A token is a spelling, not a flag. With the value attached —--set-upstream-to=origin/main,-uorigin/main— the token is not the table's entry, so the invocation fell through to the bare-listing rule and was allowed.--unset-upstreamwrites, takes no argument, and was never in the table, so no positional rule could see it either.The measurements
Against master
7e7cd598, each command executed in a throwaway repository with a real bareoriginand a real upstream onold(the effect read back fromgit rev-parse --abbrev-ref old@{upstream}). The arm was rebuilt until every one of these could actually write — an earlier attempt reported "no effect" for three of them because there was no upstream to clear, which measures the arm and not the guard.git branch --set-upstream-to=origin/oldorigin/main→origin/oldgit branch -uorigin/oldorigin/main→origin/oldgit branch --unset-upstreamorigin/main→ nonegit branch --set-upstream-to origin/oldgit branch -u origin/oldBoth arms were driven in one script, the pre-fix one loaded from HEAD's own blob with its sha printed, so "before" is a measurement of the shipped guard rather than a recollection.
The fix
_flag_part(tok)returns the flag part of a token — the part before=for a long option, the first two characters for a short one — and the branch/tag verdict asks both tables about that, so an attached value is the same flag as the two-token form.--unset-upstreamjoins_GIT_WRITE_FLAGS, with the reason it cannot be caught positionally written beside it.Only a leading flag is rewritten:
-alone is a filename,--alone is the argument terminator, and a path or a pattern keeps every character, which is whygit branch --sort=-committerdateis still a read.git branch -doldandgit tag -dv1are blocked now; git itself refuses both (rc=129), so that is the harmless direction, and it is pinned in the test so a later reader does not "fix" the truncation into an exemption.Verification
tests/test_bash_tool_sandbox.pygained four tests: the helper case by case, the attached-value writes,--unset-upstreamon its own, and twelve listing forms that must stay reads (git branch --a,-vv,--show-current,--contains HEAD,--sort=-committerdate,--list 'feat/*',git tag -l,--list,-n,--points-at HEAD, …).test_check_read_only_blocks_attached_value_git_write_flags; dropping--unset-upstreamfrom the table fails onlytest_check_read_only_blocks_unset_upstream; making_flag_partthe identity fails the helper test and the attached-value test. The file is restored byte-identically after each mutation.workspace-writeanddanger-full-accessstill allow all three shapes, so this only narrowsread-only.tests/test_bash_tool_sandbox.py72 → 76 tests, all green; full suite2021 passed, 1 skipped(2022 collected, +4).scripts/check-doc-count.py→ OK (no stored count moved).Relation to the open PRs
Same root cause as #1234 / #1236 / #1238, different code path — those are read verbs whose write flags are invisible; this is a shape-decided verb whose flag table is matched by whole token. Measured against both open arms of that family: #1250 (
a58b79de) still returns ALLOW for all three shapes, and #1255 (6dc63cf1) covers theconfigrow (git config --unset k) but neitherbranchrow. The write-target scan does not fire on these either, unlike--output=, because none of them names a path.Not claimed
git branch --edit-descriptionis ALLOW before and after, and it stays that way: git produced no.git/BRANCH_DESCRIPTIONin this environment even with a savingGIT_EDITOR, so its write is unmeasured rather than fixed. A reviewer who can demonstrate the write in their environment should send the one-line change.