Repository navigation
family.py: bump's fetch leaves out every refspec of the mount's whose source can name a tag, a pattern such as refs/* and a short name too, so a mirror in a folder, +refs/*:refs/remotes/origin/all/*, no longer fails the fetch on a branch and a tag of one name or pins a commit only a tag reaches (#1009) - #1027
Conversation
…se source can name a tag, a pattern such as `refs/*` and a short name too, so a mirror in a folder, `+refs/*:refs/remotes/origin/all/*`, no longer fails the fetch on a branch and a tag of one name or pins a commit only a tag reaches (#1009) Closes #1009. `_origin_fetch_refspecs` (#686, #905) left out of `bump`'s fetch a refspec of the mount's own whose source is under `refs/tags/`, and one whose destination is the branches' glob `refs/remotes/origin/*` with a source not under `refs/heads/`. A mirror in a folder, `+refs/*:refs/remotes/origin/all/*`, met neither and was carried: its `refs/*` takes every tag and writes it under `origin/all/tags/`, where `bump` reads branches. Lane 4's RV1007 measured it on #1007 (issuecomment-6277873296); it is RV946's S1 in another form. The clause for a tag now asks whether the source CAN NAME a tag, in three ways (measured, git 2.43): - under `refs/tags/`, as #946 landed it (RV946's S1); - a pattern whose part before the `*` is a start of `refs/tags/` (`refs/*`, `refs/t*`, `*`): git matches a pattern against each ref's whole name; - no pattern and not starting with `refs/`: a short name, which git completes, trying `refs/tags/<name>` before `refs/heads/<name>` (with a branch and a tag `x`, `+x:refs/remotes/origin/x` fetched the tag). The issue's wording, every source that can match a ref outside `refs/heads/`, would also leave out the pull-request and `refs/review/` refspecs #946 carries on purpose (both U184 rows fail on that mutant); keyed on tags, it leaves out neither. tests/test_family_bump_fetch_refspecs.py: the `mirror-in-a-folder` row in `TAGS_UNDER_ORIGIN` (both U183 tests); a `WHICH_CLAUSE` table of 16 refspecs that asks `_origin_fetch_refspecs` in process which clause leaves each out, which holds the branches' glob (the new pattern way otherwise shadows it on every end-to-end row) and the short name; and docstrings that name the clause for each refspec, with #1007's NIT N1 wording (RV1007). Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideUpdates family bump’s origin fetch construction so every refspec capable of naming a tag is omitted when it writes under origin tracking refs, preventing tag/branch fetch clashes and accidental tag-based pinning while preserving intended non-tag refspecs; comprehensive regression tests cover nested mirrors, source forms, clause ordering, and compatibility cases. Sequence diagram for safe family bump origin fetchsequenceDiagram
participant Bump
participant Config as Mount remote.origin.fetch
participant Git
participant Origin as Remote origin
Bump->>Config: _origin_fetch_refspecs(member)
loop Positive refspecs
Config->>Config: Check destination under origin/
Config->>Config: Check source can name a tag
Config->>Config: Check branches glob collision
alt Ref is excluded
Config-->>Bump: Omit refspec
else Ref is safe to carry
Config-->>Bump: Carry refspec
end
end
Bump->>Git: Fetch branches plus carried refspecs with prune
Git->>Origin: Fetch refs
Origin-->>Git: Branch refs and safe auxiliary refs
Git-->>Bump: No tag/branch collision or tag-based pinning
Flow diagram for origin fetch refspec filteringflowchart TD
A[Mount remote.origin.fetch refspec] --> B{Negative refspec?}
B -->|Yes| X[Skip]
B -->|No| C{Destination outside origin/?}
C -->|Yes| X
C -->|No| D{Source can name a tag?}
D -->|Yes: tags path, matching pattern, or short name| X
D -->|No| E{Branches glob destination and source not heads?}
E -->|Yes| X
E -->|No| F[Carry refspec]
G[Always include refs/heads/* to origin/*] --> H[Run bump fetch with prune]
F --> H
X --> H
Flow diagram for tag-capable source detectionflowchart TD
A[Source refspec] --> B{Starts with refs/tags/?}
B -->|Yes| X[Omit]
B -->|No| C{Pattern source?}
C -->|Yes| D{refs/tags/ starts with fixed pattern prefix?}
D -->|Yes| X
D -->|No| Y[Continue checks]
C -->|No| E{Starts with refs/?}
E -->|No| X
E -->|Yes| Y
Y --> Z[May be carried if other clauses allow]
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(lane openRepoShape-4 RV1027, 2026-10-20T01:04:11Z) RV1027's independent review of #1027 (closes #1009) at RV1027 is lane 4's independent reviewer for #1027. W1009 wrote it; I did not. No MUST and no SHOULD. There are four NITs, and two follow-up calls on the body's limits. The rule does what the body says for every refspec the body names. Clause 2(c) is right, safe and wanted. The NITs are an undocumented side effect, one overstated "every", a limit no row holds, and Sonar's two smells. The verdict on what W1009 flagged
NITs
What I ranAll runs used
The probes added
The diff and its hygiene:
My mutants, each one exact replacement in
The clauses, in process. I put 29 refspecs to main's and this head's
The body's claims:
CI at No commit, push, review state or body edit was made on this PR. My clone and scratch files are mine only. Lane: openreposhape-4 (openRepoShape-4) |
…_can_name_a_tag`, which names the exact object id it also leaves out and the full name it does not, and two `WHICH_CLAUSE` rows hold a 40-hex source and `heads/main` (#1027's review, N1 N3 N4) (#1009) RV1027's NITs on #1027 (issuecomment-6280876954), at 669d611: - N1: clause 2(c), a source that is no pattern and not under `refs/`, also leaves out an exact object id (`+<40 hex>:refs/remotes/origin/pinned`). The docstring names it, and the row `tag-object-id` kills RM2 (2(c) exempting a 40-hex id). - N2: the docstring says git tries `<name>`, then `refs/<name>`, before `refs/tags/<name>` and `refs/heads/<name>`, and that a full name that is no pattern is not left out though git falls back to a tag for it (`+refs/heads/zz:` took `refs/tags/refs/heads/zz` where the remote had no branch `zz`; measured, git 2.43). - N3: the row `tag-short-name-heads-main` kills RM1 (2(c) narrowed to one-component names and `tags/...`). - N4: the three ways move into `_can_name_a_tag(source)`, with `_TAGS_PREFIX` for `refs/tags/`, for Sonar's python:S3776 (cognitive complexity 20 of 15 on `_origin_fetch_refspecs`) and python:S1192. No behaviour changes: every row that passed still passes. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-4, 2026-10-20T05:20:57Z) To RV1027: the NIT round on #1027 is pushed at
Lane: openreposhape-4 (openRepoShape-4) |
|
(lane openRepoShape-4 RV1027, 2026-10-20T05:33:17Z) RV1027's re-check of #1027 at There is no MUST and no SHOULD. W1009's NIT round answers all four of my NITs (issuecomment-6280876954), and I measured each answer. I found one new wording NIT, N5, which is optional. The merge,
|
| run | result |
|---|---|
test_family_bump_fetch_refspecs.py (family.py sha256 609632f5..., the head's blob) |
33 passed |
-k bump over all 44 tests/test_family*.py |
277 passed, 1766 deselected (two more than round 1, the two new rows) |
Mutants, each one exact replacement in _can_name_a_tag or in clause 3, run against the refspec file and restored by git checkout. Each restore was checked against sha256 609632f5..., and all six matched.
| mutant | change | result |
|---|---|---|
| RM1 | 2(c) narrowed to a one-component name or one under tags/ |
killed: 1 failed, tag-short-name-heads-main |
| RM2 | 2(c) exempts a 40-hex object id | killed: 1 failed, tag-object-id |
| RM3 | refs/tags/ asked of patterns only |
killed: 1 failed, tag-source-one-tag |
| RM4 | a pattern with an empty prefix (*) carried |
killed: 1 failed, tag-pattern-every-ref |
| RM5 | 2(c) only for a source with no / |
killed: 2 failed, tag-short-name-in-tags and tag-short-name-heads-main |
| RM6 | clause 3 asks for a source outside refs/, not outside refs/heads/ |
killed: 1 failed, branches-glob-pull-requests |
Hygiene of the delta:
- no added line over 79 columns and no non-ASCII character;
git diff --checkis clean;- pyflakes finds 3 in
family.py, as on main.
CI: at 2026-10-20T05:33:17Z, gh pr checks 1027 shows tests still pending (run 39374211731, started 05:18:47Z). tests-git-bash, package and SonarCloud have passed. The previous run at 669d611e (39347284357) ended at 01:54:38Z with 2 failed, 17675 passed and 318 skipped. Both failures were main's inherited test_a_second_members_line_above_the_block_is_refused rows ([members: null] and [members: []]), and nothing else failed. This head contains #1026's fix (051176ce6), so tests should now pass in full, and any failure in it is a finding against this READY.
No commit, push, review state or body edit was made on this PR.
Lane: openreposhape-4 (openRepoShape-4)
|
Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days. You can request another review in 1 day and 12 hours by commenting |
|
… holding U+202E or another character the launcher refuses, by code point and in the launcher's words, before it prints the value or runs `git clone` (#953) (#1043) `setup-project.py`'s self-bootstrap refuses an `$OPENREPOSHAPE_REPO` holding a character the launcher refuses (U+202E and the rest of `MISDRAWN_CHARACTERS`). It refuses by code point, in the launcher's words, before it prints the value or hands it to git. Head `c42afa30`, one commit on origin/main `5b41d44f`. Only `setup-project.py` and a new `tests/test_setup_project_repo_misdrawn.py` change. No flag, no `--help` line and no doc changes. **`setup-project.py`** (section 0, just above `self_bootstrap`): - `MISDRAWN_CHARACTERS` is the launcher's list (`openRepoShape:502` at `5b41d44f`), entry for entry, in the package's tuple form. It is a copy, for `checked_value`'s reason: self-bootstrap mode runs before there is a checkout to read the launcher from. - `misdrawn_characters(value)` names the characters a value holds as `U+XXXX`, in code point order. It is the package's `standard.misdrawn_characters`, and it looks for characters, as `CONTROL_RE` does. - `checked_repo(value)` asks that first. It refuses with the launcher's `checked_repo` sentence, which names the code points and nothing else of the value. Then it calls `checked_value(..., PATH_RE)`, unchanged, for a leading `-` and a control character. - `self_bootstrap` reads a non-empty `$OPENREPOSHAPE_REPO` through `checked_repo`. Unset or empty still means the default. The check stays where `--org` is asked, so the order is the same: `--org` first, then the repository, then `openRepoShape setup` is printed, then `mkdtemp` and `git clone`. - The value's raw bytes are never printed. The refusal goes through `die`, so it is `REFUSED: ...` on stderr with exit 2. **`tests/test_setup_project_repo_misdrawn.py`**, 22 tests: - **Parity:** `test_the_misdrawn_table_is_the_launchers_list` reads `MISDRAWN_CHARACTERS=(...)` out of `openRepoShape` and compares the tuple. It also checks that `misdrawn_characters` names all of the launcher tests' `MISDRAWN`, each once and in order. `test_misdrawn_characters_names_the_launchers_and_nothing_else` runs the launcher's `SHARING_ITS_BYTES`. These follow #947's two tests for the package. - **Direct path, 12 tests:** two runs (`Atlas --org acme`, which is the issue's run, and `--org acme` alone) times six values: U+202E, U+00AD, U+200B, U+E0001 (two, three and four UTF-8 bytes), two characters at once, and a tab before U+202E (the launcher asks for the code point before the control character). - Each test runs a lone copy of the file under `call_loggers`' logging git, gh and curl. - Each checks the launcher tests' `assert_refused_saying`: exit 2, the launcher's sentence, an empty stdout, and no listed character, escape or `forged` in either stream. So no `E2 80 AE` gets out. - The git log must hold exactly one call: the checkout probe. - **Values that still go on, 7 tests:** the launcher's `NOT_CONTROLS` after `acme/`: e-acute, a no-break space, U+200C and U+200D where a script or an emoji needs them, U+2026, U+FF21 and a subdivision flag. Each is printed in the clone line and handed to `git clone` as before. - **Package path, 1 test:** a tree with no `.git` (the package's modules under `src/openreposhape/`, plus `setup-project.py`, `scaffold-project.py`, `shape-doctor.py` and `contracts/repository-naming.yaml`). `openRepoShape Atlas --org acme` is run from that tree's `src` with the issue's value. The package prints its one line (`the standard is the checkout this package sits in, <tree>.`) and hands over. The tree's `setup-project.py` refuses: exit 2, the launcher's sentence, no U+202E and no `forged` in either stream, and only the checkout probe in the git log. - Tests that use the logging stub are skipped on Windows. There `subprocess` starts `git.exe` and never the stub's `.cmd`, so the stub would log nothing. CI is Linux only (#597). - `setup.sh` has no twin of these tests. Its guard covers control characters and the two separators and no other listed character, by #940's design: the launcher refuses the rest first where it is the way in. **"No git call", stated exactly.** The refused run makes one git call, `git -C <dir> rev-parse --show-toplevel`. That is `is_shape_checkout`, which runs before `self_bootstrap` on every run past `--help` and `--preflight`. It asks whether the file stands in a checkout, which is how the run knows it is self-bootstrapping at all, and it names nothing of the value. No git call names the value, and there is no clone. Moving the check above the probe would also refuse the value on the developer path, which never reads it. It would also leave `setup-project.py` ordered differently from `setup.sh`, which probes first and guards only on its self-bootstrap path. These run `$Q/v118/w953/probe.py`. Each case uses a fresh scratch folder, a logging git stub that then runs the real git, a gh that fails, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null`. "Before" is `git archive 5b41d44`; "after" is the worktree. Results are the same under git 2.43.0 and 2.47.1. | Case | Before (`5b41d44f`) | After | |---|---|---| | package, ZIP tree, `Atlas --org acme`, `acme/r[U+202E]forged` | exit 128; `E2 80 AE` on stdout and stderr; git: probe, then `clone --quiet -- acme/r[U+202E]forged /tmp/openreposhape-shape-...` | exit 2; no `E2 80 AE`; git: probe only; stdout is the package's one line | | direct, `Atlas --org acme`, same value | exit 128; raw bytes in the clone line; probe + clone | exit 2; refusal names `U+202E`; probe only | | direct, `--org acme`, same value | exit 128; probe + clone | exit 2; probe only | | direct, `acme/r[U+200B]forged` | exit 128; probe + clone | exit 2; names `U+200B` | | direct, `acme/r[U+E0001]forged` | exit 128; probe + clone | exit 2; names `U+E0001` | | direct, `acme/r[U+2026]b` (not listed) | exit 128; probe + clone | the same: probe + clone, exit 128 | The "after" refusal, from the probe: ``` REFUSED: OPENREPOSHAPE_REPO holds a character a terminal draws as something other than itself (U+202E): one that is invisible, is drawn as another character, ends a line or reorders the text around it. This tool will not put it on a `git clone` command line. Set it to a URL or a path. ``` The new file was run on main's `setup-project.py` (`git show 5b41d44:setup-project.py`, put back byte for byte afterwards): **15 failed, 7 passed**. - Failed: both parity tests (`AttributeError`: no `MISDRAWN_CHARACTERS`, no `misdrawn_characters`), all 12 direct tests, and the package test. - Passed: the 7 `NOT_CONTROLS` tests. They pass at main by design, because they guard against a check that is too broad. Each mutant was applied alone (`$Q/v118/w953/mutate.py`), the new file was run (22 tests), and both files were put back. **All 18 are killed.** | Mutant | Failed | |---|---| | m01 check removed (main's read) | 13 | | m02a check moved after the `(0) self-bootstrap` line | 13 | | m02b check moved after the clone line | 13 | | m03a only the value's first character checked | 15 | | m03b only the table's first entry checked | 13 | | m04 only U+202E checked | 8 (U+00AD, U+200B, U+E0001, two-at-once) | | m05 refusal exits 1 | 13 | | m06a table drops `E0001` | 4 | | m06b parity compares the first five entries + table drops `E0001` | 4 (parity's second assert, `SHARING_ITS_BYTES`, both U+E0001 tests) | | m06c parity compares the first five + table adds `200C` | 2 (`SHARING_ITS_BYTES`, the Persian/U+200C test) | | m06d parity compares the first five + `2060-206F` narrowed to `206E` | 2 | | m06e parity uses set inclusion + table drops `E0001` | 4 | | m07 a range read without its last code point | 15 | | m08 control check asked before the code points | 2 (both tab-then-U+202E tests) | | m09 characters named in the value's order | 3 | | m10 every character past ASCII refused | 7 (every `NOT_CONTROLS` test) | | m11 refusal quotes the value back | 13 | | m12 check skipped on a hand-over (`OPENREPOSHAPE_SELF_BOOTSTRAP=1`) | 1 (the package test alone) | All at `c42afa30`, one pytest process at a time: `/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp, `EMAIL=service@opensoft.dev`, a stub gh first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`. | Run | git | Files | Result | |---|---|---|---| | h1 | 2.43.0 | `test_setup_project_repo_misdrawn.py` (22), `test_setup_project_ref_parts.py` (27), `test_openreposhape_package.py` (213), `test_readme_windows_quoting_sentence.py` (44), `test_release_install_check_children.py` (30), `test_repo_hygiene.py` (100), `test_agents_md_codes.py` (47), `test_cli_reference.py` (23) | 506 passed in 94.06s | | h2 | 2.43.0 | `test_setup_project_py.py` | 340 passed in 122.68s | | h3 | 2.43.0 | `test_setup_sh.py` | 191 passed in 96.62s | | h4 | 2.47.1 | `test_setup_project_repo_misdrawn.py`, `test_setup_project_ref_parts.py`, `test_setup_project_py.py` | 389 passed in 128.13s | | h5 | 2.43.0 | `test_no_auto_maintenance.py`, `test_setup_project_dry_run_offers.py`, `test_validator_child_encoding.py`, `test_setup_project_name_edges.py`, `test_setup_project_typed_name.py` | 353 passed, 2 skipped in 73.92s (the two skips: `GIT_CONFIG_GLOBAL` was set) | No `shell_quoted` call was added (count 0), so the README's and the handbook's Windows-quoting sentences and `NAMED` are unchanged. `setup-project.py` is not a pinned copy. The wheel takes it at build time (`pyproject.toml` `force-include`), and the tests that read its source (`test_setup_project_py.py`, `test_release_install_check_children.py`, `test_repo_hygiene.py`) pass. - `git merge-tree --write-tree` of `c42afa30` with origin/main `5b41d44f` (fetched with `-c gc.auto=0 -c maintenance.auto=false`) is clean, tree `59de4c261c228274a5cff18aff953211de7002e3`. - No open PR touches `setup-project.py` or the new test file. Every open PR's head was still merged in, and all ten are clean: #1041 `4a23c6e2`, #1039 `5f924500`, #1038 `fd1de5ab`, #1031 `eea70be1`, #1030 `9b4845bb`, #1028 `0c9eac6c`, #1027 `052f1e24`, #1025 `f83ca618`, #1023 `4d0aa1cf`, #954 `dc7fd7ea`. - **RV947's companion is left out.** That companion is the package's scaffold run asking the repository even beside a checkout. - Once this lands, no package run prints the value or hands it to git. Beside a git checkout, the hand-over takes `setup-project.py`'s developer path, which never reads `$OPENREPOSHAPE_REPO`. `self_bootstrap` and `standard.py` are the only shipped Python that read it. Beside a tree with no `.git`, this check refuses it. - The companion would change `standard.prepare`, which `--doctor` and the four tools share. They would then refuse a value they never use. That is the launcher's "checked once, before every run but `--help`" policy, and it is a decision about the package of its own. - `src/openreposhape/standard.py` is also open in #1038. - **Found while measuring, not changed here:** `CONTROL_RE` and `PATH_RE` hold `\x00-\x1f\x7f` only, so a C1 control passes `checked_value`. The launcher and the package refuse C1 (U+0080 to U+009F) in a repository. With `acme/r[U+009B]31mforged`, the clone line prints the raw `C2 9B`, before and after this change. `--into` and `--local-remote-dir` go through the same `PATH_RE` (read in the code, not measured). That would be a follow-up for `checked_value`, which every flag shares. Review: rv953, lane 5's independent reviewer, not the writer (w953): READY at `c42afa30`, the head that lands (issuecomment-6284626889), with no MUST. It rules #953's Verify met in substance: the refused run's only git call is the checkout probe every run makes, which names nothing of the value, and there is no clone. Its SHOULD is #459's documented C1 known limit (U031, L4-24), left with its test NIT for a follow-up on a ruling. CI (Brett Heap's ruling 27): H0 is the head c42afa3 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 5b41d44; and the local gate passed on its predicted squash tree e146bb5 over main e656199: the setup_project_repo_misdrawn, repo_hygiene, agents_md_codes, cli_reference sets, 192 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green". Closes #953. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hain link before an `=` that leaves a path after it, so the offer the gate prints for a link holding `=` verifies that link when typed as printed (#980) (#1044) For a declared-unverified chain link whose name holds `=`, the naming gate offers only sources that verify that link's tree when typed as printed. The gate offered `open=y` the source `--link-source open=y=<path>`, and read every `--link-source` key at the value's first `=`: the key was `open`, which no chain names, so the value was a bare PATH for the first link, `open=y` stayed declared-unverified under the same offer, exit 0. **The issue's direction (a)**, in `scripts/validate-repository-naming.py`. `LinkSources._read_source` reads a `--link-source` key the way `_read_remedy` reads a `--link-remedy` link since #949: the longest text before an `=` that names a link of a chain this run reads, by `pin_product_key`, and leaves a path after it. `__init__` and `no_path` both take the key and the path from it, so the path refused as empty is the one after the key's own `=`. For a chain whose links hold no `=`, every value is read exactly as before: a text holding `=` can only name a link that holds one. **What (a) keeps from the documented rules, and how each case was checked** (probes below, and one test row or more each): - A link holding `=`: `open=y=<tree>` verifies `open=y`, as printed and in capitals (`OPEN=Y=<tree>`, by `pin_product_key` as every key is). - Two links where one is a prefix of the other (`open` and `open=y` in one chain): `open=y=<tree>` is `open=y`'s and `open=<tree>` is `open`'s, in either order. On main both were `open`'s and the later replaced the earlier, so `open=y` read none. - A path that itself holds `=`: `open=y=<dir>/a=b/tree` is `open=y`'s with that whole path. A text holding `/` names no link, since a link's name is `repo_basename`'d, so an `=` after a `/` never ends the key. A path that begins like a longer link's name is given after `./`: `open=./y=dir` is `open`'s in a chain naming `open=y` too. - A key that names a link but leaves an empty path: `open=y=` is `PRODUCT=` for `open=y`, refused as `link-source-no-path`, exit 2, as `openXdox=` is (#442). On main it was a bare PATH asked for `openXdox`, a link the person did not name. Where a shorter link leaves a path (`open=y=` with `open` in the chain too), that link is the key, `open` with the path `y=`, as `--link-remedy openXdox=y=` gives `openXdox` the words `y=` where a chain names `openXdox=y`: the longest link that leaves a path wins, and `open=y` leaves none. - A value that is a real directory spelled like the whole value: `open=y=x` beside a directory `open=y=x` is still `open=y`'s, decided by the chain and not the disk, with `_also_a_directory`'s line; `./open=y=x` asks for the directory, as a bare PATH for the first link. - The key is read whole, where a remedy's link is stripped and cut at its last `/`: ` open=y=<tree>` and `opensoft/open=y=<tree>` name no link and stay bare PATHs, as on main. **One difference from `--link-remedy`, deliberately.** `--link-remedy open=y=` is a NOTE (its link falls back to the first `=`, `open`). `--link-source open=y=` is refused, because the #442 rule refuses `PRODUCT=` for every link, and `--link-remedy`'s own `openXdox=` refusal is made by argparse before the chains are known, which is why it cannot catch `open=y=`. `_read_source`'s docstring says so. **Not (b).** (a) makes the offer the gate already prints true, so the person who types it gets the verification; (b) would only stop offering it. Setup's step (4) is unchanged: it passes the gate its `--pin-source` as an absolute path, with a separator before every `=`, so no text before one names a link. **Docs.** The module docstring (a new paragraph, "A KEY IS THE LONGEST LINK A VALUE NAMES"), the `LinkSources`, `_link_remedy` and `_read_remedy` docstrings, the `--link-source` help, and `docs/cli.md` regenerated from it. `setup-project.py`'s `as_the_plan_reads_it` docstring stated the first-`=` rule, and now states this one. README, the handbook and AGENTS.md state no rule for how a `--link-source` key is read (README names `--link-source PRODUCT=<path>` only), so they are unchanged. The class docstring records that `validate-pins.py` still ends a `--pin-source` key at the first `=`; no scaffold writes a pin holding one (`--pin` names are `[A-Za-z0-9_.-]`), and it is left as its own change. Measured from a root scaffolded at main `5b41d44f` (local remotes, no network): - `update-shape.py check --upstream <this checkout> --at c64e85f` lists `upstream-changed scripts/validate-repository-naming.py`, with `upstream-changed 1 unchanged 10`. - `update-shape.py apply --yes --branch ...` exits 0, commits `contracts/shape-pin.yaml`, `project.yaml` and the copy, and the project's validators print `pins ok` and `manifest ok`. A fresh `check` prints `unchanged 11`, and the root's own naming gate exits 0. - The new sha256 of `scripts/validate-repository-naming.py`: `ec167fb0e259f90705db895a1a37f4adebd4cbf03ca2d5ae18f6d9a8df896548` (78664 bytes). No other pinned copy and no template changes. Run from empty folders, `<D>` a fresh folder, trees as named (`trees/y` declares `openInk`, `trees/x` declares `open=y`; for the chain with `open`, `trees/x2` declares `open` and `trees/o` declares `open=y`). The gate prints an arrow between links; it is written `->` here. | Probe | At main `5b41d44f` | At head `c64e85fe` | |---|---|---| | P1, the issue's row: `--referent-chain openXdox,open=y,openInk --link-source open=y=<D>/trees/y` | `link-source-unreadable ... could not answer for openXdox`, "It was read as a PATH: `open` is not a link", and `open=y` declared-unverified with the same offer; exit 0 | only `openXdox` declared-unverified (no source named it); no `link-source-unreadable`; exit 0 | | P2, P1 plus `openXdox=<D>/trees/x`, `--explain` | NOTE: the value not used, read as a PATH; `CHAIN openXdox -> open=y -> openInk [declared-unverified]` | `[verified]`, nothing warned or noted | | P3, chain `openXdox,open,open=y,openInk`, `open=<o>` then `open=y=<y>` | `link-source-unreadable: y=<D>/trees/y (--link-source open=...)`, NOTE `open=<o>` replaced; 2 links declared-unverified | `[verified]`, nothing warned or noted | | P4, `open=y=` (chain without `open`) | bare PATH `open=y=` asked for `openXdox`; exit 0 | `REFUSED link-source-no-path: --link-source open=y= names no path for open=y`; exit 2 | | P5, `open=y=` (chain with `open`) | `y= (--link-source open=...) could not answer for open` | the same | | P6, `open=y=<D>/trees/a=b/y` | read as a PATH, `[declared-unverified]` | `[verified]` | | P7, `open=y=x` beside a directory `open=y=x` | bare PATH, read as the directory for `openXdox` | `link-source-unreadable: x (--link-source open=y=...) could not answer for open=y`, then "`open=y=x` is also a directory; `--link-source ./open=y=x` reads it as one." | | P7b, `./open=y=x` | the directory, for `openXdox` | the same | | P8, chain with `open`: `open=./y=dir`, `open=y=<y>` | `open=y=<y>` read for `open`, replacing `open=./y=dir` | `[verified]` | Eleven new rows in `tests/test_chain_remedy_callers.py`, section "#980", reusing its helpers (`naming`, `tree`, `gate_warnings`, `UNREACHED`, `GATE_OFFER`, `EQUALS`), with no assert that joins conditions with `and`: - `test_the_offer_for_a_link_holding_an_equals_verifies_it[as-printed, in-capitals, a-path-holding-an-equals]`: reads the offer the gate prints for `open=y`, types it with a tree, and asserts the one WARNING left is `openXdox`'s, with no `link-source-unreadable` and no NOTE. - `test_each_link_reads_its_own_source_where_one_name_begins_another[open-first, open=y-first, a-path-that-begins-like-the-longer-link]` - `test_a_value_that_ends_at_a_link_holding_an_equals_names_no_path` - `test_a_shorter_link_that_leaves_a_path_is_the_key` - `test_a_link_holding_an_equals_wins_over_a_directory_spelled_like_it` - `test_a_key_is_read_whole[a-blank, an-owner]` **Failing at main.** The test file copied onto a `git archive` of `5b41d44f`: 8 of the 11 fail, each on the defect (the offer row's `open=y` WARNING and `link-source-unreadable`, the prefix rows' `[declared-unverified]`, `open=y=` exiting 0, the keyed reading not winning over the directory). The other 3 pass at main by design: they pin behaviour main already had (`test_a_shorter_link_that_leaves_a_path_is_the_key`, `test_a_key_is_read_whole[a-blank]` and `[an-owner]`), and each kills a mutant below. **Mutants**, each applied alone to the fixed gate in a scratch repository, then `test_chain_remedy_callers.py`, `test_link_source_chain_defaults.py`, `test_naming_policy.py`, `test_chain_link_fold.py` and `test_chain_pin_sources.py` (400 passed, 4 skipped with no mutant). All 11 killed: | Mutant | Failed | Killed by (new rows unless named) | |---|---|---| | M1 back to `typed.partition("=")` | 8 | the offer rows, the prefix rows, `open=y=`, the directory row | | M2 the shortest match instead of the longest | 3 | the prefix rows | | M3 the "leaves a path after it" check dropped | 1 | `test_a_shorter_link_that_leaves_a_path_is_the_key` | | M4a the `./PATH` rule dropped (`./` stripped from the key) | 3 | the directory row; `test_a_labelled_source_holding_an_equals_is_not_said_to_be_a_path`; `test_naming_policy.py::test_a_link_named_before_the_equals_is_the_key_even_over_a_directory` | | M4b the key cut at its last `/`, as a remedy's link is | 4 | as M4a, and `test_a_key_is_read_whole[an-owner]` | | M5 the empty-path fallback dropped | 6 | `open=y=`; `test_a_value_that_names_no_path_is_spelled`; 4 rows of `test_link_source_chain_defaults.py::test_a_link_source_that_names_no_path_is_refused` | | M6 the fallback at the first `=`, as a remedy's | 1 | `open=y=` | | M7 `no_path` taken after the first `=` | 1 | `open=y=` | | M8 the keyed path taken after the first `=` | 7 | the offer rows, the prefix rows, the directory row | | M10 the key stripped, as a remedy's link is | 1 | `test_a_key_is_read_whole[a-blank]` | | M12 the key not folded by `pin_product_key` | 56 | `in-capitals` and the prefix rows, and 52 existing rows | **Runs**, all on head `c64e85fe`, `/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp per run, with `EMAIL=service@opensoft.dev`, a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null`: - git 2.43.0 (`/usr/bin/git`), 31 files: the 25 that run the naming gate (`grep -l validate-repository-naming tests/`, the pinned-copy files `test_pinned_copy_text.py`, `test_update_shape_kept_helper.py`, `test_update_shape_unreadable.py` and `test_adopt_reencoded_files.py` among them, and `test_repo_hygiene.py`), the files that scan every script's source (`test_git_output_decoding.py`, `test_shape_advisory.py`, `test_no_auto_maintenance.py`, `test_non_utf8_git_failures.py`), and the trio's other two, `test_agents_md_codes.py` and `test_cli_reference.py`: **2964 passed, 5 skipped** in 1749 s, exit 0. - git 2.47.1 first on PATH, the 25 naming-gate files: **2680 passed, 3 skipped** in 1181 s, exit 0. No template changes. `grep` of `tests/` found no fixed digest or `^id:`-style pattern over the gate: the tests that read a root's copy of it (`test_shape_doctor.py`, `test_doctor_validator_verdicts.py`) compare it at run time, and both are in the first run, as are the source scanners above and `test_cli_reference.py`, which holds `docs/cli.md` to the help. - `git merge-tree --write-tree origin/main HEAD`, origin/main `5b41d44f` (fetched with `-c gc.auto=0 -c maintenance.auto=false`, and `git ls-remote` agrees): clean, tree `82780dc63d7f`, the head's own tree. - Open pull requests: none of the 9 open (#954, #1023, #1025, #1027, #1028, #1030, #1031, #1038, #1039) touches `scripts/validate-repository-naming.py`, `tests/test_chain_remedy_callers.py`, `setup-project.py` or `docs/cli.md`, so there is no merge-tree to run against them. Review: rv980, lane 5's independent reviewer, not the writer (w980): READY at `c64e85fe` (issuecomment-6288391970), with no MUST or SHOULD; the landing head only merges main over it. Its NIT-1 (three test rows for the corners of the old first-`=` split) and NIT-2 (a sentence in `validate-pins.py`) go to a follow-up. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 c64e85f passed tests, tests-git-bash and SonarCloud; the head 0f5845b is a clean update-branch merge of main b0babe1, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree ac9c882 over main b0babe1: the chain_remedy_callers, repo_hygiene, agents_md_codes, cli_reference sets, 233 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green". Closes #980. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n that can bring a tag under `origin/`, and one onto the branches' names from another namespace, by the rule `family.py bump` asks, now one function both tools import, so a branch and a tag of one name no longer fail it and a tag no longer answers for a branch (#1037) (#1068) `bump-leg.py` fetches the leg's remote with `+refs/heads/*:refs/remotes/origin/*` and the leg's own refspecs that write under `refs/remotes/origin/` (#898), so that `--prune` keeps what they keep, and then asks `on_a_remote_branch`, which reads every ref there. `_leg_fetch_refspecs` left out only a destination outside `origin/`. #946 and #1027 gave `family.py`'s `_origin_fetch_refspecs` two more clauses, a source that can name a tag and the branches' glob from a source not under `refs/heads/`, and the leg's fetch had neither. Lane 4's RV1027 asked for this issue. THE RULE IS NOW ONE FUNCTION. `shape_materialize.py` gains `origin_fetch_refspecs(configured)`, with `ORIGIN_BRANCHES`, `ORIGIN_BRANCH_NAMES`, `TAGS_PREFIX` and `can_name_a_tag`, moved from `family.py` with their docstrings: skip a negative refspec, force a positive one, leave out by the three clauses in #1027's order, and keep the rest once each. `family.py`'s `_origin_fetch_refspecs` and `bump-leg.py`'s `_leg_fetch_refspecs` each read `remote.origin.fetch` their own way, as before, and return what the shared function returns. - It lives in `shape_materialize.py`, as `readable_branch` and the trailer helpers do: both tools already import it, and it is in no copy list. `repo_shape.py` is a shape copy every root digest-pins, so a function added there would be an `upstream-changed` row for every project. `bump-leg.py` does not import `family.py`. - `family.py` loses `_ORIGIN_BRANCHES`, `_ORIGIN_BRANCH_NAMES`, `_TAGS_PREFIX`, `_can_name_a_tag` and the loop; its docstring points at the shared one for the clauses. Its behaviour does not change: `test_family_bump_fetch_refspecs.py` passes as it did. - `bump-leg.py` loses `LEG_BRANCHES` and the loop. The leg's reading is kept as it was: `git config --list` through `run`, a line at a time. #946's U184 for legs (RV946's N7) is not in scope. - The stale docstring is fixed: "`family.py` shares both; #905 holds them" is gone, with the tags-into-`origin/` case it named, and the docstring says what is left out now and why. `fetch_leg`'s prune and `--refmap=` paragraphs say a refspec left out has its refs pruned until the leg's own next `git fetch origin`. MEASURED ON MAIN, git 2.43, by the new test file, at `1a47ed07e` and again at `eee089cae`, the base this branch is rebased on: - With a branch and a tag of the name a tag-capable refspec writes, the leg's fetch exited 128 ("Cannot fetch both"), where the leg's own `git fetch origin` exits 0, and both runs gave the failed-fetch note. - With no name shared, the tag under `origin/` answered for a branch, and a commit only a tag of the remote reaches was pinned. - A short name gave two answers by turns. A fetch that finds the ref the short name wrote prunes it, as the remote has no ref of that very name, and the fetch after it writes it back. So the dry run pinned the commit and the real run refused it. A probe of three fetches in a row showed the ref written, then deleted, then written. tests/test_bump_leg_fetch_refspecs.py is new, 38 rows: - `TAGS_UNDER_ORIGIN` holds the issue's five refspecs: the tags onto the branches' names and in a folder, a mirror into `origin/` and in a folder, and a short name. Each runs end to end, both runs, through the leg's `origin` and through `--local-remote-dir`, with no ref of the leg moved: - the clash row fetches with no note and pins; - the reachability row refuses `bump-leg-commit-local-only` in the words for a commit only a tag of the remote reaches. - `WHICH_CLAUSE` is `test_family_bump_fetch_refspecs.py`'s own 18-row table, imported and asked of `_leg_fetch_refspecs` in process, so one table holds both tools to one rule. Its comment there follows the rename. On main's three scripts the new file gives 32 failed and 6 passed: all 20 end-to-end rows, and the 12 table rows a tag or the branches' glob leaves out. With the family refspec file beside it, the run is 32 failed and 39 passed on main and 71 passed here. The targeted runs at this head all passed: - `-k bump` over the 11 `tests/test_bump_leg*.py`: 339 passed, 1 skipped; - `-k bump` over the 45 `tests/test_family*.py`: 277 passed; - the new file, `test_family_bump_fetch_refspecs.py`, `test_no_auto_maintenance.py`, `test_repo_hygiene.py`, `test_cli_reference.py`, `test_agents_md_codes.py` and `test_family_dry_run.py`: 362 passed. Fourteen mutants, each run over the new file, the family refspec file and `test_bump_leg_reachability_words.py` (105 tests), then restored and checked by sha256. Eleven are caught: - forcing removed: 3 fail; - clause 1, a destination outside `origin/`, removed: 7; - clause 2, a source that can name a tag, removed: 34; - clause 3, the branches' glob, removed: 2; - `can_name_a_tag`'s `refs/tags/` check removed: 2; - its pattern check removed: 10; - its pattern check made True for every pattern: 10; - its no-pattern check made False: 12; - its no-pattern check made True: 4; - `bump-leg.py` passing none of the leg's own refspecs: 8; - `family.py` passing none of the mount's: 6. Three survive: - The negative-refspec skip removed is equivalent. Git refuses a negative refspec that has a destination, "invalid refspec", exit 128, in the repository's own fetch too, so clause 1 leaves out every one git takes. - Dedupe removed is equivalent at the fetch. Git takes two identical refspecs, exit 0, and writes the same refs. - Clause 3's guard removed survives, and is #1066 (below). Limits: - #1066: clause 3's guard keeps a refspec onto the branches' glob from a folder of `refs/heads/`: `+refs/heads/release/*:refs/remotes/origin/*`. Where the remote has `x` and `release/x`, the fetch exits 128 ("Cannot fetch both refs/heads/x and refs/heads/release/x to refs/remotes/origin/x"), where the repository's own fetch exits 0 (measured, git 2.43). This is #946's rule, unchanged here, and now both tools have it. The mutant that drops the guard survives. - The leg's config is still read a line at a time (#946's U184 for legs). - What #1027 lists of its own rule holds for both tools now. A full name that is no pattern can still bring a tag. Every short name is left out. A non-tag refspec in a folder can still clash with a branch. Closes #1037 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y refspec of the repository's own onto the branches' names, whatever its source, and every one that puts a branch under a name not its own, so `family.py bump` and `bump-leg.py` no longer fail where the remote has `x` and `release/x` and the repository's own `git fetch origin` works (#1066) (#1087) Both tools fetch `+refs/heads/*:refs/remotes/origin/*` and the repository's own refspecs that write under `refs/remotes/origin/`, as `origin_fetch_refspecs` picks them (#1068). Its third clause, from #946, left out a refspec onto `refs/remotes/origin/*` only where its source is not under `refs/heads/`. A plain `refs/heads/*` refspec is the first refspec itself, so that guard only ever carried a folder of branches onto the branches' names, such as `+refs/heads/release/*:refs/remotes/origin/*`. Where the remote has `x` and `release/x`, the fetch exited 128 ("Cannot fetch both refs/heads/x and refs/heads/release/x to refs/remotes/origin/x") where `git fetch origin` exits 0. Both runs then said the fetch had failed and answered from stale refs. THE RULE. A configured refspec rides beside the first one only where git cannot put two refs on one name through it: - THE BRANCHES' GLOB (clause 3) leaves out every refspec onto `refs/remotes/origin/*`, whatever its source. The guard is gone. - A BRANCH UNDER ANOTHER NAME (clause 4, new, `gives_a_branch_another_name`) leaves out a source under `refs/heads/` whose destination is not `refs/remotes/origin/` and the source's own part after `refs/heads/`. That covers one branch onto another's name, every branch into a folder, and a folder into another folder. Each one fails the same way wherever the remote has the two branches. - One that gives each branch its own name, a branch (`+refs/heads/main:refs/remotes/origin/main`), a folder (`+refs/heads/release/*:refs/remotes/origin/release/*`) or a pattern, maps every ref where the first refspec does, so it still rides along. - Clauses 1 and 2 (outside `origin/`; a source that can name a tag) are unchanged, so #1009's and #1037's tag rules hold exactly. - Nothing is lost to the answer. The first refspec brings every branch under its own name, and a left-out refspec only ever named branches. Measured on git 2.43 by a probe of 31 cases, each comparing the repository's own `git fetch origin` with the tools' fetch on main's rule and on this one, through `origin` and through a path: - The issue's case, its unforced form, the same beside a negative refspec, one branch onto another's, `x` onto `release/x`, every branch into a folder and a folder into another: the repository's own fetch exits 0. Main's fetch exits 128 ("Cannot fetch both"). This fetch exits 0, with every branch's remote-tracking ref at that branch. - A folder of branches under its own name, a pattern, one branch under its own name, the glob itself, forced or not, negative refspecs and every tag case #1009 and #1037 settled: the same refspecs carried and the same exits on main and here. - The cost is the one #905 accepted for the tags onto the branches' names. A ref the repository kept under another name holds the branch of that name, or is pruned, until its own next fetch writes it back. A refspec written without `+` is refused there as non-fast-forward where the two commits are not in line, exit 1, as the tags onto the branches' names are on main (measured, both). - A differential over 21,487 single and paired refspecs: this rule never carries a refspec main's does not. Each one it newly leaves out has a source under `refs/heads/` and a destination that is not the branch's own name. Docstrings: `origin_fetch_refspecs` names four clauses and what can still clash. `bump-leg.py`'s `_leg_fetch_refspecs` says how the leg's fetch can still fail, and that #1066's way no longer does (#1068's review, NIT 2). Its module docstring and `fetch_leg`'s prune and `--refmap=` paragraphs follow, as does `family.py`'s `_origin_fetch_refspecs`. Tests: - `BRANCH_UNDER_ANOTHER_NAME` (in tests/test_family_bump_fetch_refspecs.py) holds three refspecs: the issue's, one branch onto another's, and every branch into a folder. Each runs end to end through `family.py bump` (both runs) and through `bump-leg.py` (both runs, through `origin` and through `--local-remote-dir`). Each proves the clash first, then that both runs print no note, pin the commit and move no ref. - `WHICH_CLAUSE`, the table both tools' files ask, goes from 18 to 29 rows. `outside-origin-prefix` (NIT 1) has a pull request for its source, so only clause 1's `/` can leave it out. Four rows are THE BRANCHES' GLOB's, four are the new clause's, one is a negative refspec, and two more are carried. `carried-branches-in-a-folder` (`refs/heads/*:refs/remotes/origin/all/*`) was a #1066 case. It is now `another-name-branches-in-a-folder`, left out. - NIT 3: `only_a_tag_reaches` in tests/test_bump_leg_reachability_words.py holds the premise block both files repeated. Each file's clash premise and the leg's two-run tail are now one helper each (`clashes`, `both_runs_pin`). Before and after, over the three changed test files (136 tests): - main's scripts with these tests: 21 failed, 115 passed. The 21 are the 9 end-to-end rows (3 through `family.py bump`, 6 through `bump-leg.py`) and 6 table rows for each tool; - this head: 136 passed. Gates at this head: - A, `-k bump` over the 11 `tests/test_bump_leg*.py`: 356 passed, 1 skipped; - B, `-k bump` over the 46 `tests/test_family*.py`: 291 passed; - C, the two refspec files, `test_no_auto_maintenance.py`, `test_repo_hygiene.py`, `test_cli_reference.py`, `test_agents_md_codes.py` and `test_family_dry_run.py`: 393 passed. Mutants, run one at a time over the three changed files, each restored and checked by sha256: - clause 1's `/` removed: 2 fail (`outside-origin-prefix`, both tools); - clause 2 removed: 34; - clause 3 removed: 2; - #946's guard put back on clause 3: 0. This one is equivalent: clause 4 then leaves the same refspecs out, and the only branch source onto the glob it lets through is the first refspec itself, which is deduped; - clause 4 removed: 14; - clause 4's `refs/heads/` test removed: 9; - that test inverted: 23; - its own-name test negated: 21; - every branch source left out: 7; - the own-name test off by one: 7. Four were not run, stopped at a usage checkpoint: clause 4 reduced to the glob alone, the negative skip removed, forcing removed, and dedupe removed. Limit: a refspec carried into a folder of its own from another namespace, such as pull requests in `origin/pr/`, still fails the fetch where the remote has a branch whose name falls in that folder (`pr/7` beside pull request 7). The repository's own fetch fails there too wherever it has the branches' glob, as a clone's default refspec is. Measured: with `main` alone plus pull requests, its own fetch exits 0 and this one exits 128, on main and here. Leaving such a refspec out would prune what it keeps on every run, which #686 carries it to keep. #1027 already listed this limit. Closes #1066 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… leaves out a pattern like `refs/heads*` that puts a branch under another name, its clauses move into `_left_out`, and the docstrings name the clashes left and the way back (#1100) (#1157) #1100 holds five follow-ups from RV1087's review of #1087 (issuecomment-6314349196). This PR does all five: 1. Take the clause-4 fix for a pattern such as `refs/heads*`, and name the clash that is left, `+refs/pull/7/head:refs/remotes/origin/main`, in the three places that list clashes. 2. Add the `WHICH_CLAUSE` row that kills R16. 3. In `bump-leg.py`, name the unforced exception and the way back, `git fetch --force origin`. 4. Write down the accepted cost where the cost is described. 5. Move `origin_fetch_refspecs`' four clauses into a helper, `_left_out`. Fix round 1 (`baa19972a`) answers the independent review rv1157/rv1157b (issuecomment-6347113460: NOT READY, 2 SHOULD, 4 NIT, no BLOCKER). It does all six. See "Fix round 1" below. The NIT round (`368b87e9b`) answers rc1157's re-check of fix round 1 (issuecomment-6352215076: READY, 3 NITs, all in this body). It adds one test row, which kills F2, the survivor of fix round 1, and corrects three statements in this body: the reason F2 was left, the longest line's length and the #1131 merge-tree line. The scripts are unchanged. See "NIT round, after the re-check of fix round 1" below. Measured by a probe on git 2.43 and git 2.47.1, with local bare remotes only. The two gits gave identical tables. For each case the probe ran: - the repository's own `git fetch origin`; - the tools' fetch, `git fetch -q --prune --no-prune-tags --refmap= <origin|path> <refspecs>`, with the refspecs from main's `origin_fetch_refspecs`, once through `origin` and once through a path; - the repository's own fetch again ("own-after"). Where own-after failed, it then ran `git fetch --force origin`, a plain fetch, and the tools' fetch followed by the repository's own fetch ("again"). Branches are `main`, `x` and `release/x` at three unrelated commits unless the case says otherwise. ``` case own main o/p own-after force, plain, again C25 +refs/heads*:refs/remotes/origin/h*, branch h/x 0 128/128 0/0 - C18b +refs/pull/7/head:refs/remotes/origin/main alone 0 128/128 0/0 - C02 release/* onto origin/*, unforced 0 0/0 1/1 0 (ref back), 0, 1 C02c same, x descends from release/x 0 0/0 1/1 0 (ref back), 0, 1 C05u release/x onto origin/x, unforced 0 0/0 1/1 0 (ref back), 0, 1 C20u tags onto origin/*, unforced, branch and tag x 0 0/0 1/1 0 (ref back), 0, 1 C32 heads/* onto origin/*-a, branches q and q-a 0 0/0 0/0 - C33 feat-* onto origin/feat-*-old, feat-1, feat-1-old 0 0/0 0/0 - ``` These match the issue's numbers. C32 and C33 exit 0 because #1087 leaves them out. The own-after failure is `! [rejected] release/x -> origin/x (non-fast-forward)`. C02b, where `release/x` descends from `x`, gives 0. So the condition is that the kept commit does not descend from the branch's, not that the two commits are unrelated. The old paragraph's words "not in line" were wrong for C02c, and the new text corrects them. Main has since moved to `961640c6` (#1155, which touched only `docs/release-notes/v1.2.0.md`). The four files this PR changes are byte-identical there, so nothing above changes. **`scripts/shape_materialize.py`** - **Clause 4** (`gives_a_branch_another_name`) now reads a source that is not under `refs/heads/` as naming branches when it is a pattern whose part before the `*` is a start of `refs/heads/`. This is how `can_name_a_tag` reads `refs/*` for tags: ``` fixed, star, _ = source.partition("*") if not source.startswith(HEADS_PREFIX): return bool(star) and HEADS_PREFIX.startswith(fixed) ``` Such a pattern's `*` also takes what the pattern leaves of `refs/heads/`. So no destination under `refs/remotes/origin/` gives a branch its own name, and every such refspec is left out. The only pattern that keeps the names, `refs/heads*:refs/remotes/origin*`, writes outside `origin/`, and clause 1 leaves it out first. The shorter starts (`refs/`, `*`) can name a tag, and clause 2 leaves them out first. - **The fix was taken only after measuring it.** A pure-function differential of main against the branch ran over 39,385 configs: 43 sources x 31 destinations, forced and unforced, negatives, pairs, doubles, and each one beside the first refspec. - The branch carries nothing that main does not. - It newly leaves out 207 distinct refspecs. Every one has a pattern source whose part before the `*` starts `refs/heads/` but neither `refs/heads/` nor `refs/tags/` (`refs/h*` to `refs/heads*`). - The helper alone (main plus `_left_out`) gives main's output on all 39,385 configs. - The fix alone and the fix plus the helper give the same output as each other on all 39,385. - **The probe of all 53 cases**, on both gits, changed no outcome outside that class: exits, own-after, force, plain and again are identical for the 46 cases outside the class, C03u, C17u and C32o included. The seven cases in the class changed: ``` case own main o/p branch o/p own-after (branch) C25 refs/heads* onto origin/h*, branch h/x 0 128/128 0/0 0/0 C25u same, unforced 0 128/128 0/0 1/1; --force 0, plain 0 C25n refs/heads* onto origin/h*, no clash 0 0/0 0/0 0/0 (pruned, written back) C25g default glob + refs/heads* onto origin/h* 0 0/0 0/0 0/0 (pruned, written back) C25b default glob + refs/he* onto origin/he*, heads/x 128 128/128 0/0 128/128 C25c refs/he* onto origin/he*, branch heads/x 0 128/128 0/0 0/0 C25h glob + refs/h* onto origin/hh/*, ref refs/hidden/1 0 0/0 0/0 0/0 (pruned, written back) ``` Wherever a tools' fetch exited 0, every remote branch's `refs/remotes/origin/<name>` held that branch's commit (all cases, both rules, both ways, both gits). A fetch that exited 128 changed no ref. - **`_left_out(source, destination)`** holds the four clauses, in their order and with their comments. `origin_fetch_refspecs` now asks it once. The SonarQube analyzer (`python:S3776`, `analyze_code_snippet`) raises nothing on an excerpt of the four functions as committed, with their docstrings dropped. With four more `if ...: continue` in the loop it reports "from 17 to the 15 allowed", so `origin_fetch_refspecs` measures 9 (it was 15). By hand, `_left_out` is 3 and `gives_a_branch_another_name` is 2. - **The docstrings:** - `gives_a_branch_another_name` no longer ends "A source not under `refs/heads/` names no branch". It names the pattern and points to WHAT CAN STILL CLASH. - The A BRANCH UNDER ANOTHER NAME paragraph names the pattern. - A new THE COST, ACCEPTED paragraph says that, after either tool runs, an unforced refspec's own fetch exits 1 on every run until `git fetch --force origin`. Where the refspec was carried, the tools' fetch exited 128 in those same states and left the ref alone. No commit is lost, and main already behaves this way for unforced tags onto the glob. - WHAT CAN STILL CLASH now names any carried refspec whose source names no branch, onto a name a branch has: the pull requests in `origin/pr/` and C18b's single name. - Fix round 1 changes three of these paragraphs and adds one (below). **`scripts/bump-leg.py`**, docstrings only. An AST comparison with the docstrings blanked shows the code is identical to main's, at both heads. - The #1066 paragraph of `_leg_fetch_refspecs` names the `refs/heads*` pattern. It adds NOT WHERE THE LEG'S REFSPEC WAS WRITTEN WITHOUT ITS `+`, with the way back, `git fetch --force origin`. - "Two ways remain" now names the second way as a source that names no branch, onto a name a branch has, in a folder or onto a single name (C18b). **`tests/test_family_bump_fetch_refspecs.py`** - New `WHICH_CLAUSE` rows (both tools' tests ask them): - `another-name-suffix`, `+refs/heads/*:refs/remotes/origin/*-old`, left out (item 2); - `another-name-heads-pattern`, `+refs/heads*:refs/remotes/origin/h*`, left out; - `another-name-heads-pattern-shorter`, `refs/he*:refs/remotes/origin/he*`, left out; - `carried-not-refs-heads`, `+refs/headsx/*:refs/remotes/origin/headsx/*`, carried; - fix round 1: `another-name-heads-pattern-refs-h` and `another-name-heads-pattern-suffix` (below). - New `BRANCH_UNDER_ANOTHER_NAME` row `heads-pattern-into-a-folder`, `+refs/heads*:refs/remotes/origin/h*`, with `x-w1066` and `h/x-w1066`. It runs end to end through `family.py bump` and through `bump-leg.py` via `origin` and via `--local-remote-dir`, with the clash premise first. - Fix round 1: `NO_BRANCH_A_PATTERN_TAKES` and its end-to-end rows (below). - NIT round: `carried-refs-h-not-a-pattern`, `+refs/h:refs/remotes/origin/hy`, carried (below). **`tests/test_bump_leg_fetch_refspecs.py`**: one docstring sentence, and in fix round 1 the leg's `NO_BRANCH_A_PATTERN_TAKES` rows. `scripts/family.py` is unchanged. Its `_origin_fetch_refspecs` paragraph "A mount whose refspec git cannot satisfy" gives examples and claims no complete list, and it stays true. One new commit, `baa19972a`, on `a35a97ea5`. No merge of main was needed: main's one new commit touches none of these files. The scripts' code is unchanged: `astsame.py` (AST with docstrings blanked) gives SAME CODE for `shape_materialize.py` against `a35a97ea5` and for `bump-leg.py` against main. **SHOULD 1 (finding 1): two mutants of the fix survived the rows.** Two `WHICH_CLAUSE` rows are added. Both tools' tests ask them (`test_family_bump_fetch_refspecs.py` holds the table, and `test_bump_leg_fetch_refspecs.py` imports it): - `another-name-heads-pattern-refs-h`, `+refs/h*:refs/remotes/origin/hh/*`, left out. This is the class's lower edge, the shortest start of `refs/heads/` that no earlier clause meets. - `another-name-heads-pattern-suffix`, `+refs/heads*x:refs/remotes/origin/h*x`, left out. Git accepts it and maps branch `x` onto `origin/h/x` and `ax` onto `origin/h/ax` (probe section B, both gits). On the pure function (`tools/rows.py`, `logs/rows.log`): ``` row main head B1 B9 F2 +refs/h*:refs/remotes/origin/hh/* carried left out carried left out left out +refs/heads*x:refs/remotes/origin/h*x carried left out left out carried left out ``` So each new row fails on main. B1 is killed by `-refs-h` and B9 by `-suffix`. F2 was the one survivor of fix round 1. The NIT round's row kills it (see "NIT round" and Mutants). **SHOULD 2 (finding 2): leaving the class out changes the answer from pin to refuse** for a commit that only a non-branch ref reaches, where the pattern takes that ref too. The three places that said nothing is lost now say so: - `shape_materialize.py`, `origin_fetch_refspecs`: LEFT OUT BY EITHER OF THE LAST TWO, NO BRANCH IS LOST TO THE ANSWER, followed by a new paragraph, BUT A COMMIT ONLY A REF THAT IS NO BRANCH REACHES IS REFUSED. It covers the pull requests onto the branches' names (left out since #946, refused on main too) and, since #1100, a ref of another namespace that a pattern from a start of `refs/heads/` takes. It says that every run of either tool refuses such a commit alike. It also says that a refspec whose source is that namespace itself is carried, and the commit is pinned through it. This is finding 2(b): one remote ref, two answers by refspec. - `shape_materialize.py`, `_left_out`'s comment: a ref that is no branch, which a pattern takes too, no longer answers for one, so a commit only it reaches is refused, as one only a tag reaches is. - `bump-leg.py`, the #1066 paragraph: "so no branch is lost to the answer", and a new paragraph, A COMMIT ONLY A REF OF ANOTHER NAMESPACE THE PATTERN TAKES REACHES IS REFUSED NOW (#1100), with the review's `heads-x-in` case and its `heads-x-out` control. New end-to-end rows pin the refusal, `NO_BRANCH_A_PATTERN_TAKES` (`+refs/heads*:refs/remotes/origin/h*` with `refs/heads-archive/old-w1100`, and `+refs/h*:refs/remotes/origin/hh/*` with `refs/hidden/old-w1100`): - `test_a_ref_a_heads_pattern_takes_answers_for_no_branch` runs `family.py bump --dry-run` and `bump`. Both refuse `member-to-local-only` in the plain words, with nothing in the holder moved and no ref of the mount moved. - `test_a_ref_a_heads_pattern_takes_answers_for_no_branch_of_the_leg` runs `bump-leg.py` via `origin` and via `--local-remote-dir`. Both runs refuse `bump-leg-commit-local-only` in the plain words, with nothing in the root moved and no ref of the leg moved. - Each first asserts its premises: the ref reaches the commit, no branch of the remote does, and no remote-tracking ref does. All 6 rows fail on main, where both runs pin (Rows failing on main). The docstrings' claims, measured by `tools/claims.py` (the tools' own code from an exported tree: `fetch_leg` end to end, then `family.py`'s reader and `_on_a_remote_branch`). It ran on main `961640c6` and on `baa19972a`, on git 2.43 and 2.47.1. The two gits gave identical output on each tree (`logs/claims-{main,head}-{243,247}.log`): ``` case refspec beside the glob ref holding C main head C25h-in +refs/h*:refs/remotes/origin/hh/* refs/hidden/1 PIN REFUSE C25h-unforced refs/h*:refs/remotes/origin/hh/* refs/hidden/1 PIN REFUSE heads-x-in +refs/heads*:refs/remotes/origin/h* refs/heads-archive/old PIN REFUSE heads-x-unforced refs/heads*:refs/remotes/origin/h* refs/heads-archive/old PIN REFUSE hidden-out +refs/hidden/*:refs/remotes/origin/hidden/* refs/hidden/1 PIN PIN heads-x-out +refs/heads-archive/*:refs/remotes/origin/h-archive/* refs/heads-archive/old PIN PIN pulls-out +refs/pull/*/head:refs/remotes/origin/pr/* refs/pull/7/head PIN PIN pulls-onto-glob +refs/pull/*/head:refs/remotes/origin/* refs/pull/7/head REFUSE REFUSE ``` - `family.py`'s answer equals `bump-leg.py`'s in every row, and so do the two readers' lists. - On the head, the tools' fetch exits 0 and prunes the class ref (`origin/hh/idden/1`, `origin/h-archive/old`). The repository's next own `git fetch origin` exits 0 and writes it back. **NIT 1 (finding 3), `bump-leg.py`'s tag paragraph.** It now says that such a ref is pruned or holds the branch of that name, and that the leg's own next fetch writes it back. It adds NOT WHERE THE LEG WROTE THAT REFSPEC WITHOUT ITS `+` and the tag's commit does not descend from the branch's: then the leg's own fetch exits 1 on that run and every one after it, until `git fetch --force origin`. Measured (probe section C, both gits, main and head alike): ``` case refspec own tool own-after --force (ref back) plain C20 +refs/tags/*:refs/remotes/origin/* 0 0 0,0 - - (origin/x back at the tag) C20u refs/tags/*:refs/remotes/origin/* 0 0 1,1 0 (back) 0 ! [rejected] x -> origin/x (non-fast-forward) C20u-desc same, the tag's commit a child of x 0 0 0,0 - - ``` **NIT 2 (finding 4).** The 109-character line is reflowed. The longest line this PR adds, over `961640c6..368b87e` in the four files, is 78 characters (`test_bump_leg_fetch_refspecs.py`'s `assert contains(bare_of(remotes), made, ref), f"premise: {ref} reaches it"`). In the scripts the longest added lines are 75 (`bump-leg.py`) and 76 (`shape_materialize.py`). `bump-leg.py`'s longest line, 81, is main's. This body said 79 until the re-check measured 78. **NIT 3 (finding 5), the overclaim in `gives_a_branch_another_name`.** It no longer says each such refspec puts a branch under another name. Where the part before the `*` is all of `refs/heads` and the destination's `*` comes right after a `/`, git refuses the name it gives each branch, and the fetch writes no branch. The docstring now says this, and says that the pattern still takes the refs of another namespace, pointing to the new paragraph. Probe section B, both gits: `+refs/heads*:refs/remotes/origin/h/*` with branches `main`, `x` and `ax` exits 0, says `error: * Ignoring funny ref 'refs/remotes/origin/h//x' locally` (and the same for `h//main` and `h//ax`), and writes only `origin/h/foo/x` for `refs/headsfoo/x`. **NIT 4 (finding 6), the stale merge-tree list.** Merge-trees were re-run at `baa19972a` against main and every open PR touching these four files (Merge-trees). One new commit, `368b87e9b`, on `baa19972a`, in `tests/test_family_bump_fetch_refspecs.py` only: one `WHICH_CLAUSE` row, a sentence in the table's comment and one in its test's docstring. The scripts are byte for byte `baa19972a`'s (`git diff --name-only baa1997 368b87e` names that one file). The row is `pytest.param("+refs/h:refs/remotes/origin/hy", True, id="carried-refs-h-not-a-pattern")`. `WHICH_CLAUSE` is one table, which `tests/test_bump_leg_fetch_refspecs.py` imports and asks of `_leg_fetch_refspecs`, so the one row is two test ids, one in each file, and both tools' tests ask it. **NIT 1 (finding 1): the reason given for leaving F2 was wrong, and the row it said could not be added can be.** The first body said F2 differs from this head only on a source that is no pattern and is a start of `refs/heads/` (`refs/`, `refs/h`, ... `refs/heads`), that no such full name can be a ref beside the remote's branches, and that a row for it would pin an arbitrary answer. Measured by `tools/claims.py` (the tools' own code from an exported tree: `fetch_leg` end to end, then `family.py`'s reader and `_on_a_remote_branch`), on main `961640c6`, on this head and on this head with F2 applied, on git 2.43 and 2.47.1 (`logs/claims-{main,head,f2}-{243,247}.log`; the two gits gave identical output on each tree): ``` case refspec beside the glob ref holding C main head F2 refs-h-no-pattern +refs/h:refs/remotes/origin/hy refs/h PIN PIN REFUSE refs-head-no-pattern +refs/head:refs/remotes/origin/hz refs/head PIN PIN REFUSE refs-heads-no-pattern +refs/heads:refs/remotes/origin/hx refs/heads the remote cannot hold refs/heads and refs/heads/main hidden-out +refs/hidden/*:refs/remotes/origin/hidden/* refs/hidden/1 PIN PIN PIN C25h-in +refs/h*:refs/remotes/origin/hh/* refs/hidden/1 PIN REFUSE REFUSE ``` - Only `refs/heads` itself cannot be such a ref: `git update-ref` refuses it with "cannot process 'refs/heads/main' and 'refs/heads' at the same time" (exit 128, no ref made). `refs/h` and `refs/head` are made beside `refs/heads/main` with exit 0. - With `+refs/h:refs/remotes/origin/hy` and a commit C that only `refs/h` reaches, main's code and this head's carry the refspec and PIN C. Under F2 both tools leave it out and REFUSE C (`bump-leg-commit-local-only`, `family_answer=False`): the tools' fetch prunes `origin/hy`, which the repository's own fetch had written, and the next own fetch writes it back. - So this head's answer is not arbitrary. It is the rule ANY OTHER SOURCE NAMES NO BRANCH, the same as `hidden-out`'s: a source that is no pattern takes the ref of its name. Where the remote has no ref of that name, git completes the name instead: with a branch named `refs/h` (the ref `refs/heads/refs/h`) beside `hy`, the tools' fetch line exits 128 with `Cannot fetch both refs/heads/hy and refs/heads/refs/h to refs/remotes/origin/hy`, on git 2.43.0 and 2.47.1, on main as here. That is the WHAT CAN STILL CLASH shape (lane 5's re-check, issuecomment-6371455922, NIT 1). - On the pure function (`tools/rows.py`, `logs/rows.log`, `origin_fetch_refspecs` alone): ``` row main head B1 B9 F2 +refs/h:refs/remotes/origin/hy carried carried carried carried left out +refs/head:refs/remotes/origin/hz carried carried carried carried left out +refs/h*:refs/remotes/origin/hh/* carried left out carried left out left out +refs/heads*x:refs/remotes/origin/h*x carried left out left out carried left out ``` - **The new row passes on main by design,** as `another-name-suffix` does, because main's clause 4 (`gives_a_branch_another_name`) answers `False` for every source not under `refs/heads/` (`961640c6`, `scripts/shape_materialize.py` line 1704), and `refs/h` meets no earlier clause. It passes on this head. It kills F2: with F2 applied to `scripts/shape_materialize.py`, `-k carried-refs-h-not-a-pattern` over both test files gives 2 failed (`test_which_clause_leaves_each_refspec_out[carried-refs-h-not-a-pattern]` and `test_which_clause_leaves_each_refspec_out_of_the_legs_fetch[carried-refs-h-not-a-pattern]`), and over the 90 rows of Mutants, 2 failed and 88 passed. The runs are in `logs/mut-summary.log`: the row alone, 2 passed on main's code (`logs/mut-ROW-main-code.log`), 2 passed on this head (`logs/mut-ROW-head.log`), 2 failed under F2 (`logs/mut-ROW-F2.log`). - The Mutants section below replaces the F2 sentence with this result. **NIT 2 (finding 2): the line length.** Fix round 1's NIT 2 sentence now says 78, and names the scripts' 75 and 76 (above). The new commit adds lines of at most 76 characters, all ASCII, and no assert that joins conditions with `and` or `or`. **NIT 3 (finding 3): the #1131 merge-tree line.** #1131's head is `55c30f20` now (its NIT round), not `a4beec9e`. `git merge-tree --write-tree 55c30f2 368b87e` exits 0 with tree `e036c02d6a12`; at `baa19972a` it gave `bd6ebc5cd3af`, as the re-check measured. See Merge-trees. Main's production code (`961640c6`: `scripts/shape_materialize.py` and `scripts/bump-leg.py`) was put in the worktree under this head's two changed test files. Then the committed files were put back, with sha256 and dirty=0 checked (`logs/mut-MAIN-code-head-tests.log`). The result is **17 failed, 106 passed** at `baa19972a`, and **17 failed, 108 passed** at `368b87e9b` (`logs/mut-MAIN-code-head-tests.log`, 125 rows): the NIT round's 2 rows pass on main, by design. The failures are exactly the rows the fix decides: - `WHICH_CLAUSE` `[another-name-heads-pattern]`, `[-shorter]`, `[-refs-h]` and `[-suffix]`, in both files: 8. Each is carried on main ("Left contains one more item"). - `test_a_branch_put_on_another_branchs_name_fails_no_fetch[heads-pattern-into-a-folder]` (member), and `..._of_the_leg[origin-heads-pattern-into-a-folder]` and `[local-remote-dir-heads-pattern-into-a-folder]` (leg): 3. The fetch exits 128 on main, so the runs give the failed-fetch note. - Fix round 1's `NO_BRANCH_A_PATTERN_TAKES` rows: `test_a_ref_a_heads_pattern_takes_answers_for_no_branch[heads-pattern]` and `[refs-h-pattern]` (member), and `..._of_the_leg[origin-heads-pattern]`, `[origin-refs-h-pattern]`, `[local-remote-dir-heads-pattern]` and `[local-remote-dir-refs-h-pattern]` (leg): 6. On main the pattern is carried, so the real run pins the commit instead of refusing it. The first round's 7 are among these. Fix round 1 adds 10: `-refs-h` and `-suffix` in both files, and the 6 end-to-end rows. `another-name-suffix` and `carried-not-refs-heads` pass on main by design. They kill mutants (below). NIT round, on `368b87e9b` with git 2.43, through `tools/mut.sh` (`logs/mut-summary.log`). The scripts are `baa19972a`'s, so these are fix round 1's mutants over the NIT round's tests. Each mutant was applied alone to the committed `scripts/shape_materialize.py`. The runs covered both changed test files with `-k "which_clause or another_branchs_name or heads_pattern_takes"` (90 rows, 35 deselected). After each run the committed files were put back, and their sha256 and dirty=0 were checked: 9 of 9 restores in the script matched (`shape_materialize.py` `16c6ec378c36...`, `bump-leg.py` `8f4af9c5e78c...`). ``` mutant failed result main's code (961640c) under these two files, all 125 rows 17 the rows above (108 passed) F2 the fix's `bool(star) and` dropped 2 killed (carried-refs-h-not-a-pattern x2) B1 the class drawn from `refs/he` up (`refs/h*` carried) 5 killed (-refs-h x2, refs-h-pattern x3) B9 the fix only where the `*` ends the source 2 killed (-suffix x2) B4 the fix undone (`return False` in its place) 17 killed (every row the fix decides) ``` F2 is killed now. At `baa19972a`, with 88 rows, it survived (88 passed); B1 (5 failed), B9 (2) and B4 (17) gave the same counts as here. The earlier sentence said F2 differs from this head only on a source that is no pattern and is a start of `refs/heads/`, and that no such full name can be a ref beside the branches. The first half holds: over the 39,385-config corpus F2 differs from this head only there (`refs/`, `refs/h`, ... `refs/heads`). The second half held for `refs/heads` alone. `refs/h` and `refs/head` are refs a remote can hold beside its branches, and the head carries `+refs/h:refs/remotes/origin/hy` and pins a commit only that ref reaches, where F2 refuses it (NIT round, NIT 1). The row `carried-refs-h-not-a-pattern` holds that answer. At `a35a97ea5`, whose code this head keeps, the first round measured these over `WHICH_CLAUSE` in both files (66 rows then): ``` mutant failed result R16 clause 4 compares only the part before the `*` 2 killed (another-name-suffix x2); on main's own rows it survives (102 passed) R17 clause 4 compares only the part after the `*` 8 killed (the four another-name rows x2) F3 only `refs/heads*` itself, not shorter starts 2 killed (another-name-heads-pattern-shorter x2) F4 any pattern starting `refs/h` (too broad) 2 killed (carried-not-refs-heads x2) F5 every pattern not under refs/heads/ left out 6 killed (carried pulls, not-refs-tags, not-refs-heads x2) H1 _left_out's OUTSIDE clause removed 2 killed H2 _left_out's tag clause removed 14 killed H3 _left_out's branches'-glob clause removed 2 killed (branches-glob-pull-requests x2) H4 _left_out's last clause removed 14 killed H5 `if _left_out(...)` negated 60 killed ``` Every run went through `run9.sh` on the commit `368b87e9b` (dirty=0 in each START line): gh stub first on PATH, a bogus token, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`, and `EMAIL` set. - git 2.43, the three files that name the functions (`test_family_bump_fetch_refspecs.py`, `test_bump_leg_fetch_refspecs.py`, `test_bump_leg_reachability_words.py`): **159 passed** (`logs/g243.log`; 157 at `baa19972a`, and the NIT round's 2 rows). - git 2.47.1 (a git 2.47.1 build first on PATH; `git --version` printed `git version 2.47.1` in the log): the same three files: **159 passed** (`logs/g247.log`). - git 2.43, `test_path_guard_hygiene.py`, `test_pinned_copy_text.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`): **306 passed** (`logs/hyg243.log`, the same count as at `baa19972a`). `git merge-tree --write-tree <target> 368b87e`, run at 2026-10-23T12:06:38Z against main and the head of every open PR that touches one of the four files (55 open PRs listed at 12:06:22Z; `logs/merge-trees.log`). Each exits 0 with no conflict: - `origin/main` `961640c6`: tree `19c8442d96b0`; - #1104 `de89aa81` (`shape_materialize.py`): tree `b76c36be588a`; - #1105 `b28f40de` (`shape_materialize.py`): tree `5f2a8b3706e4`; - #1118 `2307036a` (`shape_materialize.py`): tree `0c21c6b39607`; - #1131 `55c30f20` (`bump-leg.py`; its head moved from `a4beec9e` in its NIT round): tree `e036c02d6a12`; - #1160 `0068f5af` (`shape_materialize.py`): tree `bce90eb65c6c`. The head's own tree is `351a9b73cda8`. Main is still `961640c6`: its one commit past `038dafba`, #1155, touches only `docs/release-notes/v1.2.0.md`. - C18b, a source that names no branch onto one branch's name, still clashes: own 0, tools 128. Which names the remote's branches have is not known from the refspecs. Leaving out every such refspec would also leave out the pull requests, which #946 carries on purpose. The docstrings now say so. The coordinator adds it and C25 to #1027's list. - Leaving out the `refs/h*` class prunes what such a refspec kept until the repository's next own fetch, as for #1066's cases (C25n, C25g, C25h). An unforced one held under a branch's name costs the non-fast-forward of THE COST, ACCEPTED (C25u). - Leaving it out also changes the answer for a commit that only a non-branch ref the pattern takes reaches, from pin to refuse (fix round 1, SHOULD 2). This is the rule #613 and #1037 keep for a commit only a tag reaches: a bump pins only what a branch reaches. Every run gives the same answer, so a dry run and the real run after it never differ. Pinning such a commit instead would be a design question, not part of this fix. Review: lane 5's independent review (issuecomment-6347113460) and its re-check, READY at `baa19972` (issuecomment-6352215076); then rc1157b, lane 5's independent sonnet re-checker, which wrote none of #1157: READY at `368b87e9`, the head that lands (issuecomment-6371455922), with no MUST or SHOULD; its wording NIT is corrected in the body. CI (Brett Heap's ruling 27): H0 is the head 368b87e itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 038dafb; and the local gate passed on its predicted squash tree 44b3b5c over main b055bb7: the bump_leg_fetch_refspecs, family_bump_fetch_refspecs, repo_hygiene, agents_md_codes, cli_reference sets, 296 passed. Landed on Brett Heap's word of 2026-10-23T13:10:26Z, relayed to every lane by lane 3 (#473 issuecomment-6365697123): "merge v1.2.1 PRs once reviewed and green". Closes #1100 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Closes #1009.
What changed and why
family.py bumpfetches the member'soriginwith+refs/heads/*:refs/remotes/origin/*plus the mount's own refspecs that write underrefs/remotes/origin/(#686), so that the prune keeps what they write. #946 (#905) left two kinds of those out in_origin_fetch_refspecs: a source underrefs/tags/, and a non-branch source onto the branches' own glob,refs/remotes/origin/*. A mirror in a folder,+refs/*:refs/remotes/origin/all/*, matched neither, so it was carried. Its sourcerefs/*takes every tag and writes it toorigin/all/tags/*, wherebumpreads branches. Lane 4's RV1007 found it while reviewing #1007 (issuecomment-6277873296). It is RV946's S1 class in another form.The fix keys the tag clause on whether the source CAN NAME a tag, not only on a
refs/tags/prefix. Every pattern that can match a tag, every short name and every exact object id is now left out, wherever underorigin/it writes. A full name that is no pattern is not, though git can fall back to a tag for it too (see Limits).+refs/*:refs/remotes/origin/all/*now gets the same answers as the cases #946 fixed:bump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686's words. Before, it was pinned ("member HEAD changed").The same file's docstrings now say which clause leaves each refspec out, and take #1007's NIT N1 wording (RV1007).
Reproduced on main
Add
"+refs/*:refs/remotes/origin/all/*"with the idmirror-in-a-foldertoTAGS_UNDER_ORIGIN, withfamily.pyas on main. Both rows fail, as #1009 says:test_tags_mapped_into_origin_fetch_a_branch_and_a_tag_of_one_name[mirror-in-a-folder]:both_pin'sdry.stderr == ""fails onnote: could not fetch members/IRRS from its origin; ....test_a_tag_a_mount_maps_into_origin_does_not_answer_for_a_branch[mirror-in-a-folder]:AssertionError: member HEAD changed.The rule
_origin_fetch_refspecsreads each positive refspec of the mount's own (negative ones are skipped, as before) and asks three clauses in order. The first that matches leaves the refspec out:origin/: the destination is not underrefs/remotes/origin/. Unchanged.origin/it writes. There are three ways:refs/tags/. This is family.py:bump's fetch no longer moves the mount's own branches, works where the mount maps tags intoorigin/instead of telling the person to fix the network, reads its refspecs withgit config --list -z, and asksgit remote get-url originfirst, so a mount with nooriginis not fetched from a path of that name (#905) #946's S1 clause, unchanged.*is a start ofrefs/tags/:refs/*,refs/t*,*. Git matches a pattern against each ref's whole name and never completes it.refs/: a short name, or an exact object id (NIT round, N1). Git completes a short name, trying the name itself, thenrefs/<name>, thenrefs/tags/<name>beforerefs/heads/<name>. So+heads/main:takes the branchmain, and a short name names a tag wherever the remote has none of the names tried before it.refs/remotes/origin/*and the source is not underrefs/heads/. Unchanged.Everything else is carried, after
+refs/heads/*:refs/remotes/origin/*and without duplicates. Since the NIT round, the three ways of clause 2 are one helper,_can_name_a_tag(source).The git facts behind 2(b) and 2(c) were measured on git 2.43 in scratch repositories:
+refs/t*:...tookrefs/tags/tandrefs/tags/x.+*:...took HEAD, every branch and every tag.+heads/*:...took nothing.xat one commit and a tagxat another:+x:refs/remotes/origin/got-xfetched the TAG's commit.+tags/t:...fetched the tagt.git fetch originwith+x:refs/remotes/origin/cfg-xconfigured did the same.+refs/heads/*:refs/remotes/origin/*, with each refspec alone exiting 0 in the person's owngit fetch origin, each of these exits 128:+refs/*:refs/remotes/origin/all/*: "Cannot fetch both refs/heads/all/tags/v1 and refs/tags/v1 to refs/remotes/origin/all/tags/v1".+x:refs/remotes/origin/x: "Cannot fetch both refs/heads/x and refs/tags/x to refs/remotes/origin/x".+refs/pull/*/head:refs/remotes/origin/*: "Cannot fetch both refs/heads/7 and refs/pull/7/head to refs/remotes/origin/7".Why not the issue's literal wording. "Leave out every refspec whose source can match a ref outside
refs/heads/" would also leave out two refspecs #946 carries on purpose, which the U184 rows hold:+refs/pull/*/head:refs/remotes/origin/pr/*;+refs/review/caf<e9>:refs/remotes/origin/review/cafe.Mutant M7 below is that wording. It fails both U184 rows. Keyed on tags, the rule leaves out neither.
Every refspec the tests hold, and the clause that leaves it out (the first that matches):
+refs/heads/*:refs/heads/*heads-onto-heads+refs/*:refs/*mirror+refs/tags/*:refs/remotes/origin/*tags-onto-branches+refs/tags/*:refs/remotes/origin/tags/*tags-in-a-folder+refs/*:refs/remotes/origin/*mirror-into-origin+refs/*:refs/remotes/origin/all/*mirror-in-a-folder(NEW)+refs/pull/*/head:refs/remotes/origin/pr/*+refs/review/caf<e9>:refs/remotes/origin/review/cafe+refs/heads/main:refs/remotes/origin/mainmain-only+refs/notes/*:refs/notes/*^refs/heads/<branch>Tests
In
tests/test_family_bump_fetch_refspecs.py:The new row:
mirror-in-a-folderinTAGS_UNDER_ORIGIN, so both U183 tests run it end to end.WHICH_CLAUSE, NEW: an in-process table of 16 refspecs. Each row configures one refspec in a fresh repository and asks_origin_fetch_refspecswhether it is carried. The id names the clause, and family.py is loaded withconftest.load_without_bytecode. The table is needed for three reasons:branches-glob-pull-requestsrow, a mutant removing clause 3 would survive.refs/tags/*before 2b, so only a non-pattern tag source (tag-source-one-tag) holds 2a alone.tag-short-name,tag-short-name-in-tags).Its
carriedrows fail a clause that takes too much: the pull requests in a folder, one branch, branches in a folder, andrefs/tagsx/*.Docstrings name the clause for each refspec: the module docstring, the comment on
TAGS_UNDER_ORIGIN, and the U182, U183 and U184 rows. They take NIT N1's two wordings: "only where its destination was the branches' own glob,refs/remotes/origin/*" and "the mirror intoorigin/(S2) had no test". The module docstring's "EVERY ROW RUNSbump" paragraph names the one table that does not.How each run was made. One file per invocation, under
/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed, with one reused--basetemp:df -i /first;.invalidaddress;GIT_SSH_COMMAND,GIT_ALLOW_PROTOCOLand everyLANES_*unset;ghthat exits 97 first on PATH,GH_TOKEN=bogus, an emptyGH_CONFIG_DIR.e076e3e88)test_family_bump_fetch_refspecs.py(main's own)test_family_bump_fetch_refspecs.py(this head's), against main'sfamily.pye076e3e88, and the same on259743a)test_family_bump_reachability_words.pytest_family_bump_remove_rollback.pytest_family_bump_to_a_tag.pytest_family_bump_to_checks.pytest_family_bump_to_local_only.pytest_family_dry_run.pytest_no_auto_maintenance.pytest_shape_doctor.pytest_repo_hygiene.pytest_agents_md_codes.pytest_cli_reference.pytest_family.py -k bumptest_family_holder_states.py -k bumptest_family_refuse_up_front.py -k bumptest_family_tracking_branch.py -k bumptest_family_operation_reftable.py -k bumptest_family_member_path.py -k bumpThe main column's counts other than the refspec file's were measured on main
259743a, before this branch was rebased; of the four commits main gained since, none touches those test files, and only #1010 touchesscripts/family.py(the scaffold's folder checks and--help, notbump). "not re-run" marks a main run that was stopped at the usage checkpoint.Main's seven failures, the rows this change makes pass:
[mirror-in-a-folder]rows;WHICH_CLAUSE'stag-pattern-mirror-in-a-folder,tag-pattern-refs-t,tag-pattern-every-ref,tag-short-nameandtag-short-name-in-tags.Every other
WHICH_CLAUSErow passes on main too: it records #946's behaviour, which this change keeps.Not run: the full suite. Main's push run is red since #974 on two rows of
tests/test_family_add_gitlink_and_null_members.py(test_a_second_members_line_above_the_block_is_refused[members: null]and[members: []]). That file is not in this targeted set. #1026 (051176ce6) has since made those two rows pass, and this branch has merged it.Mutants
Each mutant is one exact replacement in
scripts/family.py, run againsttests/test_family_bump_fetch_refspecs.py. The bytes were restored after each one, and the sha256 was checked against918a202b...each time.[mirror-in-a-folder]rows;tag-pattern-mirror-in-a-folder,tag-pattern-refs-t,tag-pattern-every-reftag-short-name,tag-short-name-in-tagstag-source-one-tagfixed.startswith("refs/tags/"))carried-one-branchbranches-glob-pull-requestsrefs/heads/)carried-pull-requests-in-a-folder,carried-not-refs-tagsfixed == "refs/"tag-pattern-refs-t,tag-pattern-every-refnot starguard droppedstar andguard droppedWhy M9 and M10 survive:
refs/whose part before the*is no start ofrefs/tags/, such asheads/*orH*. Such a pattern takes no branch and no tag:heads/*took nothing, andH*can take only HEAD.refs/tags/and beginsrefs/, such asrefs/orrefs/t. That is no real ref name.Merges
Open PRs were listed fresh with
gh pr list, and each head was read withgh pr view. Of the 13 open PRs, only #1002 (fix/927-bump-family-root,43a4651e7) touchesscripts/family.py. None touchestests/test_family_bump_fetch_refspecs.py.git merge-tree --write-tree, each exit 0:e076e3e88;family.py bumpnames--family-root, so each runs as printed (#927) #100243a4651e7. Its family.py hunks sit near lines 6612-6753, and this change's near lines 8850-8970;family.py bumpnames--family-root, so each runs as printed (#927) #1002 + this.The branch was rebased once, before its first push, from
259743aontoe076e3e88.e076e3e8(#1010) changesscripts/family.pyelsewhere. The rebase had no conflict, andgit range-diffshows the patch unchanged (=).NIT round (RV1027, issuecomment-6280876954)
RV1027 found #1027 READY at
669d611ewith no MUST and no SHOULD. This round takes its four NITs.Heads:
fd4d06d01: a merge of maine2f9fc424into the branch, with no conflict. The branch's delta from main stayed exactly669d611e's patch. Thefamily.pychange main brought in (family.py and bump-leg.py: every remedy that namesfamily.py bumpnames--family-root, so each runs as printed (#927) #1002, the--family-rootremedies) sits far from_origin_fetch_refspecs.a79c957b0: the NIT commit, two files. This is the PR head.What changed:
_can_name_a_tag(source), with_TAGS_PREFIX = "refs/tags/". This is for Sonar's python:S3776 (cognitive complexity 20 of 15 on_origin_fetch_refspecs) and python:S1192 ("refs/tags/"three times). Behaviour is unchanged: all 31 rows of669d611epass on the new head.+<40 hex>:refs/remotes/origin/pinned): it names a commit and no branch. The new rowtag-object-idholds it.tag-short-name-heads-main(+heads/main:refs/remotes/origin/hm, left out) holds that 2(c) takes every short name. Git completesheads/mainto the branch first.refs/<name>, beforerefs/tags/<name>andrefs/heads/<name>. It also names the full-name fallback, which is now in Limits.W1009 measured these on git 2.43 too:
+heads/main:took the branchmainwith a tagheads/mainon the remote;+refs/heads/zz:took the tagrefs/tags/refs/heads/zzwhere the remote had no branchzz;+<40 hex>:wroteorigin/pinned.Counts at
a79c957b0, with the same environment as above, each run once:test_family_bump_fetch_refspecs.py-k bumpover everytests/test_family*.py, one invocationtest_repo_hygiene.pytest_cli_reference.pytest_agents_md_codes.pytest_no_auto_maintenance.pytest_family_dry_run.pyMutants at
a79c957b0, written in the helper's shape. Each was run against the refspec file. The bytes were restored after each one, and the sha256 was checked against609632f5...each time.[mirror-in-a-folder]rows;tag-pattern-mirror-in-a-folder,tag-pattern-refs-t,tag-pattern-every-reftag-short-name,tag-short-name-in-tags,tag-short-name-heads-main,tag-object-idtag-source-one-tagcarried-one-branchbranches-glob-pull-requestsrefs/heads/)carried-pull-requests-in-a-folder,carried-not-refs-tagsfixed == "refs/"tag-pattern-refs-t,tag-pattern-every-reftags/...tag-short-name-heads-maintag-object-idMerges:
bump's fetch leaves out every refspec of the mount's whose source can name a tag, a pattern such asrefs/*and a short name too, so a mirror in a folder,+refs/*:refs/remotes/origin/all/*, no longer fails the fetch on a branch and a tag of one name or pins a commit only a tag reaches (#1009) #1027 touchesscripts/family.pyor the test file.5b41d44fc(validate-family.py:git-not-runnablecounts only anOSErrorthat namesgit, or aFileNotFoundErrororPermissionErrorthat names no file, asvalidate-pins.pycounts it, and only around the git calls, so a member's checkout git could not enter, or a member check's own step, is never told to put git on PATH (#928) #983, which touches onlytemplates/family-root/scripts/validate-family.pyand its test).git merge-tree --write-treeof the head against it exits 0.Follow-up: #1037 tracks
bump-leg.py's leg fetch, as RV1027 asked.Limits
bump-leg.py's_leg_fetch_refspecshas no tag clause at all, so a leg's U183, tags intoorigin/, stands as family.py:bump's fetch no longer moves the mount's own branches, works where the mount maps tags intoorigin/instead of telling the person to fix the network, reads its refspecs withgit config --list -z, and asksgit remote get-url originfirst, so a mount with nooriginis not fetched from a path of that name (#905) #946 left it. This PR isfamily.py's only. bump-leg.py: the leg's fetch carries every refspec of the leg's own that can bring a tag underorigin/(tags onto the branches' names or in a folder, a mirror intoorigin/or in a folder, a short name), so a branch and a tag of one name fail it and a tag answers for a branch, asfamily.py bump's did before #946 and #1027 #1037 tracks the legs.origin/pr/*clash with a branchpr/7where the mount carries no branch refspec of its own. It is carried on purpose, so its refs survive the prune.heads/main, which git completes to the branch first, andHEAD. Where+refs/heads/*:refs/remotes/origin/*writes the same name, nothing is lost. Elsewhere, the prune drops what it wrote until the mount's nextgit fetch origin, as it already does for a tag's copy underorigin/. The rowtag-short-name-heads-mainholds this breadth.refs/<name>, thenrefs/tags/<name>. So where the remote has no branchzzbut a tag namedrefs/heads/zz,+refs/heads/zz:refs/remotes/origin/zztook that tag (refs/tags/refs/heads/zz; measured, git 2.43). Leaving out every full name would also leave out the pull-request andrefs/review/refspecs: M5 fails the U184 bytes row. So it stays a limit, named in_can_name_a_tag's docstring.refs/heads/clashed (shape_materialize.py and bump-leg.py: follow-ups of PR #1087 (#1066) from its review: two refspec shapes the docstrings say cannot clash, a clause-4 row, the way back after an unforced refspec, and Sonar headroom #1100; added 2026-10-22 by lane 5).+refs/heads*:refs/remotes/origin/h*putsxonh/x. With branchesxandh/x, the person's owngit fetch originexits 0 and both tools' fetch exited 128 (git 2.43 and 2.47.1). shape_materialize.py and bump-leg.py: the fetch for a branch's answer leaves out a pattern likerefs/heads*that puts a branch under another name, its clauses move into_left_out, and the docstrings name the clashes left and the way back (#1100) #1157 (for shape_materialize.py and bump-leg.py: follow-ups of PR #1087 (#1066) from its review: two refspec shapes the docstrings say cannot clash, a clause-4 row, the way back after an unforced refspec, and Sonar headroom #1100) has clause 4 read such a pattern as naming branches, ascan_name_a_tagreadsrefs/*for tags, and leaves it out.+refs/pull/7/head:refs/remotes/origin/mainalone, with a branchmain: the own fetch exits 0 and both tools' fetch 128 (git 2.43 and 2.47.1). It is carried on purpose, as the pull requests inorigin/pr/are: which names the remote's branches have is not known from the refspecs.Lane: openreposhape-4 (openRepoShape-4)
🤖 Generated with Claude Code
Summary by Sourcery
Update
family.py bumpto exclude all tag-capable mount refspecs from its origin fetch while preserving valid non-tag mappings.Bug Fixes:
family.py bumpfrom carrying mount refspecs whose sources can resolve tags, including mirrors and short-name or object-ID refspecs, avoiding fetch conflicts and tag commits being mistaken for branches.Enhancements:
Tests: