Skip to content

family.py and shape_materialize.py: the NEXT push line names the branch by its full refname on both sides, so a branch beginning with + or named like a tag pushes as itself, and a tracking branch whose last part is over 250 bytes, which git cannot lock, is refused by refuse_bad_branch before anything exists, --dry-run too (#689) - #813

Merged
brettheap merged 5 commits into
mainfrom
fix/689-next-push-refspec
Oct 17, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)

Claim: #689 (comment)

What changed and why

Three lines of #459's family list (R421 O1, O4 and O2), two changes: scripts/family.py (_print_next) and scripts/shape_materialize.py (_branch_verdict, one new helper behind it). No pinned copy, template, digest or recorded blob changes, no --help text, no AGENTS.md, README.md or handbook line: neither file is a pinned copy, and nothing the docs say is made false.

The push line (R421 O1 and O4). family.py add, bump and remove each ended with git -C <holder> push -u origin <branch>, the branch bare. A bare name is a refspec git reads for itself:

  • A holder on +x was told push -u origin +x, a FORCED refspec for the LOCAL branch x. With an older local x and somebody's commit on origin's x, pasting it said + ... x -> x (forced update): origin's x was overwritten and the commit the act made was never pushed. With no local x: src refspec x does not match any.
  • A branch named like a tag (v1, with a tag v1): error: src refspec v1 matches more than one, exit 1.

The line is now git -C <holder> push -u origin refs/heads/<branch>:refs/heads/<branch>, both sides in full (init's own push names its destination in full, HEAD:refs/heads/<branch>, #421, #643), so the line pushes that branch whatever is checked out when it is pasted. The refspec is ONE word, quoted whole by shell_line (#486), so a branch holding an apostrophe, a $ or a ; still reaches git as the name it is, and -u still records branch.<branch>.merge = refs/heads/<branch> and branch.<branch>.remote = origin. The update-shape.py and bump-leg.py halves of the same bare push are #458's (on this head scripts/bump-leg.py:1732 and update-shape.py:4658, :4689, :5294), and are not touched here.

The length (R421 O2). git check-ref-format has no length limit and checked_value allows 255 characters, so a tracking branch of 251 to 255 passed --dry-run and the real run died at the first commit with every remote made (fatal: cannot lock ref 'HEAD': Unable to create '.../refs/heads/aaa....lock': File name too long). _branch_verdict now ends with _branch_length_verdict, which refuses a name git accepted when a part is longer than the disk holds. refuse_bad_branch is the one helper scaffold-project.py, family.py init and adopt-project.py (plan and check) ask, so all three refuse before anything exists, and --dry-run refuses alike:

REFUSED family-bad-tracking-branch: --tracking-branch is 'aaaaaaaaaaaa...aaaaaaaaaaaa', which is not a branch name git will create: it is 251 bytes long, and may be at most 250
Remediation: shorten the part of the name the line above says is too long. Git keeps a branch as a file under `.git/refs/heads/`, each part before the last a directory and the last part the file, and it locks the branch by first creating `<last part>.lock` beside it; most disks keep a file or directory name to 255 bytes, so the last part may be at most 250 bytes and every other part at most 255. `git check-ref-format` has no length limit, so it accepts such a name, and git fails only when it first writes the branch.

The remedy is its own text (BRANCH_LENGTH_REMEDIATION): BRANCH_REMEDIATION would send the person to git check-ref-format --branch, which accepts every such name. adopt-project.py's check already prints a remedy that is not BRANCH_REMEDIATION as it stands, so a plan edited to a long name is told the same words, once, under plan-bad-tracking-branch. adopt-project.py's split branch (adopt_branch:) is asked the same helper, so check finds plan-bad-adopt-branch for a part over its limit and execute refuses it before any leg exists; on main check said plan ok and execute made both leg repositories and died.

Where I differ from the issue (it says 250 throughout; measured on current main, this disk: ext4/overlay, git 2.43.0). The .lock file is made for the LAST part only, so the limits are two:

name git branch helper
250 a's made accepts
251 a's, 255 a's cannot lock ref refuses
x/ + 250 a's made accepts
x/ + 251 a's cannot lock ref refuses
253 a's + /x (255 characters, what checked_value admits) made accepts
255 a's + /x made accepts
256 a's + /x cannot lock ref refuses

Refusing every part over 250, as the issue says, would refuse <253 a's>/x (255 characters, inside checked_value), a name git creates and the scaffold and family.py init complete with. The count is BYTES (os.fsencode), the way refuse_unstorable_names counts them, so 126 é (252 bytes) is refused and 125 (250) is held. No tool hands the helper such a name: each runs checked_value, whose alphabet is ASCII, first, so the byte count matters only to a caller that skips it. The issue's own rows (250 accepted, 251 refused) hold, for a name of one part and for the last part of a longer one; the one case where the two readings differ is a NON-last part of 251 to 255 bytes, and through the tools it is ASCII and real: checked_value admits only [A-Za-z0-9._/@+~-], up to 255 characters, so a first part of 251 to 253 (<253 a's>/x, 255 characters) reaches the helper, and scaffold-project.py and family.py init make it end to end; the issue's reading would refuse it. A reviewer who wants 250 for every part changes one line (limit = NAME_MAX - (len(BRANCH_LOCK_SUFFIX) if last else 0)) and the rows named first-part-255, middle-part-251, first-part-256 and 253-then-x in the new test file.

Commits. Four: the WIP commit fa513848 as it was pushed (a ruleset blocks a rewrite, so this pull request's squash subject carries the final words), the merge of main bf1f099b (clean, no conflict, none of its 29 commits touches _print_next or _branch_verdict), a docstring correction (b5a7a7f1), and fix round 1 after lane 1's review (BRANCH_LENGTH_REMEDIATION's text, the adopt and tool-level rows, and this body's reachability sentences).

Tests

New file tests/test_next_refspec_branch_length.py, 51 tests:

  • the push line, for add, bump and remove, in each of the issue's three states (+x over an older local x, +x with no local x, a branch named like a tag): the line is typed as printed against a bare repository of the test's own, lands the branch at the act's commit, leaves somebody's x alone, sets the upstream, and the holder's validate-family.py passes. THE CONTROL: the old bare spelling is run against a copy of the same remote and fails or forces exactly as the issue measured, so the row cannot pass on a git that reads a bare name another way;
  • the refspec is one word whatever the branch (seven spellings, among them +x, it's, a$b;c);
  • the length: five names held and five refused, with the words, the size and the limit; the rows are checked against git branch on this disk; scaffold-project.py and family.py init with 251 and 255, --dry-run and real, exit 2, the tool's own code, nothing made (no folder, no remote, no holder); 250, and <253 a's>/x (255 characters, a first part over 250 that checked_value admits: the row the ruling rests on), pass --dry-run in both and a real run with either makes every remote and pushes the branch; adopt-project.py plan and check of tracking_branch:, and check and execute of adopt_branch: set to 251 bytes and to x/ + 251 (plan-bad-adopt-branch once, in the helper's words, refused before any leg exists, the directory the legs go in never made). The remedy's rows pin what it says of how git stores a name (each part before the last a directory, the last the file, locked as <last part>.lock, git failing when it first writes the branch).

Moved with the line, in five existing files: tests/test_family_tracking_branch.py (a refspec helper, three assertions), tests/test_family_switch_branch_exists.py (the two push[-1] == branch), tests/test_family_add_refusals.py (the NEXT constant), tests/test_family_printed_quoting.py (its sample line) and tests/test_tracking_branch_ref.py (its corpus holds the names the disk holds: "a" * 255 is "a" * 250).

Gate, on the MERGED bytes (head 421e6cb2, main bf1f099b merged in), one background run, no GIT_ALLOW_PROTOCOL, frvenv first on PATH, GIT_CONFIG_NOSYSTEM=1, test identities: 56 files, 3767 passed, 22 skipped, 1 xfailed in 43:51, exit 0. The 56 files (the new file; the existing files that run family.py's verbs, scaffold-project.py, adopt-project.py's branch checks and validate-family.py; the three gates test_repo_hygiene.py, test_cli_reference.py and test_agents_md_codes.py; and four files main added or changed after this work began that run family.py or the scaffold: test_derived_id_refusals.py, test_branch_left_bump_leg_setup.py, test_naming_policy.py, test_free_plan_hint_reads.py):

the 56 file names
test_adopt_branch_names.py, test_adopt_github_exits.py, test_adopt_leg_keys.py, test_adopt_manifest_values.py, test_adopt_plan_yaml_quoting.py, test_adopt_split_branch.py, test_adopt_tracking_branch.py, test_agents_md_codes.py, test_branch_left_bump_leg_setup.py, test_cli_reference.py, test_clone_url_bytes.py, test_derived_id_refusals.py, test_dry_run_asks_remotes.py, test_family.py, test_family_add_excludes.py, test_family_add_member_id.py, test_family_add_refusals.py, test_family_bump_remove_rollback.py, test_family_bump_to_a_tag.py, test_family_bump_to_checks.py, test_family_bump_to_local_only.py, test_family_dry_run.py, test_family_head_lost_gitmodules.py, test_family_init_landing.py, test_family_init_values.py, test_family_makefile_siblings.py, test_family_member_name.py, test_family_member_path.py, test_family_members_case_variants.py, test_family_printed_quoting.py, test_family_refuse_up_front.py, test_family_remove_gitmodules.py, test_family_siblings_placement.py, test_family_switch_branch_exists.py, test_family_tracking_branch.py, test_free_plan_hint_reads.py, test_naming_policy.py, test_next_refspec_branch_length.py, test_push_branch_refspec.py, test_repo_hygiene.py, test_ruleset_hint_tracking_branch.py, test_scaffold_e2e.py, test_scaffold_leg_path_collisions.py, test_scaffold_manifest_values.py, test_scaffold_pin_and_reuse.py, test_scaffold_reuse_seeded_legs.py, test_shape_doctor.py, test_template_text_placeholders.py, test_tracking_branch_ref.py, test_validate_family_branch_step.py, test_validate_family_duplicates.py, test_validate_family_member_path_glob.py, test_validate_family_unnamed_mounts.py, test_validate_family_without_git.py, test_windows_paths.py, test_yaml_indicator_openers.py

Skips: 16 at tests/test_family_init_values.py:219 and 3 at :291 (a template that quotes the placeholder or id:, each held by another test), 3 at tests/test_shape_doctor.py:10429 (the PowerShell line is printed only on a Windows host). The xfail is tests/test_shape_doctor.py:4154, strict, until #735.

The last commit (b5a7a7f1) changes one docstring in _print_next (what init's own push spells) and no code. On those bytes, a second, narrow run: the new file, test_family_tracking_branch.py, test_family_printed_quoting.py, test_repo_hygiene.py, test_cli_reference.py and test_agents_md_codes.py: 293 passed, exit 0.

Fix round 1 (15c3b5ab, after lane 1's review): one background run on those bytes, no GIT_ALLOW_PROTOCOL: the new file (51 tests now), test_family_tracking_branch.py, test_tracking_branch_ref.py, test_adopt_branch_names.py, test_adopt_tracking_branch.py, test_scaffold_leg_path_collisions.py, test_shape_doctor.py, test_repo_hygiene.py, test_cli_reference.py and test_agents_md_codes.py: 946 passed, 3 skipped, 1 xfailed in 14:22, exit 0 (the skips are test_shape_doctor.py:10429, the xfail :4154). adopt-project.py itself is unchanged; the two adopt gates are there because the new file imports adopt's fixtures and holds its adopt_branch: path.

Mutants, each applied to the fix in a clone, the new file run, the file restored (sha256 checked against the committed bytes). M2 and M5 were run again on the final bytes (15c3b5ab, 51 tests); M1, M3 and M4 ran on the earlier 46-test file, and the hunks they revert are unchanged since (git diff bf1f099b HEAD -- scripts/ shows the helper, its constants and _print_next only):

mutant what is reverted failed passed
M1 _print_next prints the bare branch again 16 30
M2 no _branch_length_verdict (re-run on 15c3b5ab) 20 31
M3 no .lock allowance (last part limited to 255, not 250) 16 30
M4 the length counted in characters, not bytes 1 45
M5 the issue's reading: 250 for every part (re-run on 15c3b5ab) 8 43

All five are killed. M2's twenty now include the two adopt_branch: rows (on main check said plan ok for a split branch of 251). M4 is killed by one row, 126-two-byte-characters (252 bytes, 126 characters), so the byte count is held. M5 is the decision above: it fails the held rows first-part-255 and middle-part-251 against the helper and against git branch, the words of first-part-256 (may be at most 255), and the three tool-level rows with <253 a's>/x (--dry-run, and a real scaffold-project.py and family.py init run); a reviewer who prefers the issue's reading makes its one-line change and moves those rows.

What it cannot tell

  • CI is Linux only (ci: run the test suite on Linux only #597). Nothing here ran on macOS or Windows. The rows that make a 250-byte branch on disk skip where os.name == "nt" (a Windows path of that length is refused whatever the tool says); the others make no name of that length on disk.
  • The limit is NAME_MAX = 255 (shape_materialize.NAME_MAX, the constant refuse_unstorable_names already counts with), measured on ext4/overlay. A disk with a shorter file name limit (eCryptfs keeps 143) or a path limit that the holder's own location decides (Windows' 260 characters with long paths off) refuses names this helper holds. Neither is asked here, and neither is what the issue measured.
  • Left for the checklists, from lane 1's review: update-shape.py apply --branch has its own checked_branch (BRANCH_RE, up to 255, no length rule) and dies late in git's words (cannot lock ref ... File name too long), then rolls back clean (the same class as R421 O2; Tracking: update-shape.py and bump-leg.py, keeping a project's shape copies and leg pins current #458). On a git with init.defaultRefFormat=reftable (2.45 and later) git creates a name this helper refuses, so "not a branch name git will create" is false there; refusing is still right, since a clone using the files backend cannot hold it (not measured: this host has git 2.43). A branch part named CON, NUL or aux passes git and the helper and fails on Windows. A branch holding " breaks under Windows PowerShell 5.1's native argument passing, and csh and tcsh expand ! even inside single quotes; neither is new here.
  • The same bare push in scripts/bump-leg.py:1732 and update-shape.py:4658, :4689, :5294 (numbers on this head) is Tracking: update-shape.py and bump-leg.py, keeping a project's shape copies and leg pins current #458's (U674 has the tag case).

Shared files

git merge-tree --write-tree of this head 15c3b5ab against main and each open pull request that touches scripts/family.py, scripts/shape_materialize.py or one of the test files above, at 11:57Z on 2026-10-17, each head read with ls-remote first. Main is 20004887, seven commits past the bf1f099b this branch merged (one of them, 1eeef3b6, is in family.py: add's refusal of a member's own gitlink); it merges clean, so this PR needs no refresh merge yet.

against head files it shares with this PR result
main 20004887 clean
#817 fix/708-v110-regressions 0bc29c945a scripts/shape_materialize.py clean
#816 fix/684-family-holder-states 772105aa57 scripts/family.py clean
#814 fix/766-spelled-paths 0e516bba34 scripts/family.py, scripts/shape_materialize.py, tests/test_family_tracking_branch.py clean
#812 fix/693-local-remote-dir 372196a7dc scripts/family.py clean
#809 fix/688-family-init-values 6958dfbcd3 scripts/family.py clean
#795 fix/686-bump-reachability 7d7a69b143 scripts/family.py clean
#792 l3-785-non-ascii-paths a1e2c9a894 scripts/shape_materialize.py clean
#767 fix/685-spellings-remedies ac76467948 scripts/family.py clean
#747 fix/681-validate-family-remedies ff7c59b8f1 tests/test_family_switch_branch_exists.py clean
#731 fix/692-raw-folder-paths ab55c29f52 scripts/family.py, scripts/shape_materialize.py clean

#670 landed at 11:38Z and is in main. #814 spells the same refspec in bump-leg.py's NEXT line (#458) and asserts that line only; none of the other nine adds a line asserting the push -u origin line or quoting the remedy text, so none is made wrong by this PR's new spelling or text. The hunks here are _print_next (family.py) and _branch_verdict, its new helper and two constants (shape_materialize.py).

Closes #689

🤖 Generated with Claude Code

Summary by Sourcery

Make generated family push commands unambiguous and refuse branch names that Git cannot materialize on disk before execution begins.

Bug Fixes:

  • Prevent family action instructions from misinterpreting branch names beginning with + or colliding with tag names by printing fully qualified branch refspecs.
  • Reject tracking and adoption branch components that exceed filesystem name limits before repositories or other artifacts are created, including during dry runs.

Enhancements:

  • Apply byte-based branch-length validation with separate limits for final ref components, which must accommodate Git lock files, and intermediate components.
  • Use a dedicated remediation message explaining the filesystem limits and how to shorten invalid branch names.

Tests:

  • Add coverage for safe push behavior across family actions, shell quoting, Git edge cases, filesystem limits, dry-run behavior, scaffold and family initialization, and adoption workflows.

Brett Heap and others added 3 commits October 16, 2026 14:19
… shape_materialize.py: the NEXT push line names the branch by its full refname on both sides, so a branch beginning with `+` or named like a tag pushes as itself, and a tracking branch whose last part is over 250 bytes, which git cannot lock, is refused by `refuse_bad_branch` before anything exists, `--dry-run` too (#689)

THE PUSH LINE. `_print_next` ended `add`, `bump` and `remove` with
`git -C <holder> push -u origin <branch>`, the branch bare. A bare name is a
refspec git reads for itself, and a leading `+` in one means FORCE: a holder
on `+x`, with an older local `x` and somebody's commit on origin's `x`, was
told a line that pushed the LOCAL `x` over origin's (`+ 5f19342...647e6d4
x -> x (forced update)`), exit 0, and never pushed the commit the act made;
with no local `x` it failed `src refspec x does not match any`. A branch that
shares its name with a tag (`v1`) failed `src refspec v1 matches more than
one`, exit 1 (R421 O1, O4). It is now `refs/heads/<branch>:refs/heads/<branch>`,
the spelling `init`'s own push uses (#421), written out on both sides and
quoted whole by `shell_line`. Measured: it pushes the named branch whatever is
checked out, and `-u` still sets `branch.<branch>.merge` and the remote.

THE LENGTH. `git check-ref-format` has no length limit and `checked_value`
allows 255 characters, so a tracking branch of 251 to 255 passed `--dry-run`
and the real run died at the first commit with every remote made, `fatal:
cannot lock ref 'HEAD': ... File name too long` (R421 O2). Git locks a branch
by making `refs/heads/<last part>.lock`, and every other part is a directory,
so `_branch_verdict` now asks `_branch_length_verdict` of a name git accepted:
a last part over `NAME_MAX - len(".lock")` = 250 bytes, or any other part over
`NAME_MAX` = 255, is refused with its own `Remediation:`
(`BRANCH_LENGTH_REMEDIATION`), because the remedy for a name git judged sends
the person to `git check-ref-format`, which accepts it. One helper, so
`scaffold-project.py`, `family.py init` and `adopt-project.py` (`plan`,
`check`, `execute`) all refuse it before anything exists.

DECISION. The issue's direction says a part over 250; git measured here makes
`<255 a's>/x` and refuses `x/<251 a's>`, so the 250 limit holds for the last
part only, and the rows say so: 250 held, 251 refused, `<255>/x` held.

TESTS. A new file holds each case, runs the printed line against a bare
repository of the test's own, and holds the old bare spelling to the failure
measured. The tests that read the line's last word move with it
(`test_family_tracking_branch.py`, `test_family_switch_branch_exists.py`,
`test_family_add_refusals.py`, and the sample line in
`test_family_printed_quoting.py`); `test_tracking_branch_ref.py`'s corpus,
which holds the helper to git's own verdict, swaps its 255-character name for
250, since the length is the one rule git does not state.

Not touched: the same bare push in `bump-leg.py` and `update-shape.py`
(#458's lines).

Closes #689

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… destination in full (`HEAD:refs/heads/<branch>`), where the WIP commit said it spells the line the same way (#689)

The NEXT line is `refs/heads/<branch>:refs/heads/<branch>`, the source in full
as well, so it pushes the branch whatever is checked out when it is pasted.
`init`'s push (#421, #643) is `HEAD:refs/heads/<branch>`: its destination is in
full and its source is HEAD, which is the branch in a clone `init` has just
made. The docstring's "the same way" was not true of the source. Docstring
only; the test run on the merged bytes (3767 passed) ran the code as it
stands, and the new file, the three tracking-branch and quoting files and
the hygiene, CLI and AGENTS.md code tests were run again on these bytes.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens family-generated push commands by using explicit heads refspecs, and adds early, byte-accurate validation for tracking branch names that Git accepts but the filesystem cannot lock, with shared remediation and comprehensive regression coverage.

Sequence diagram for safe family branch publishing

sequenceDiagram
    participant Family as family.py
    participant Shell as User shell
    participant Git as Git remote

    Family->>Shell: print git push -u origin refs/heads/<branch>:refs/heads/<branch>
    Shell->>Git: push explicit heads refspec
    Git-->>Shell: update the same branch
    Shell-->>Family: upstream records refs/heads/<branch>
Loading

File-Level Changes

Change Details Files
Make generated NEXT push commands unambiguous and branch-specific.
  • Replace the bare branch argument with a full refs/heads/<branch>:refs/heads/<branch> refspec.
  • Preserve shell-safe quoting by emitting the complete refspec as one argument.
  • Update docstrings and assertions covering +-prefixed branches, tag-like names, special characters, upstream configuration, and all family verbs.
scripts/family.py
tests/test_family_add_refusals.py
tests/test_family_printed_quoting.py
tests/test_family_switch_branch_exists.py
tests/test_family_tracking_branch.py
tests/test_next_refspec_branch_length.py
Reject tracking branches whose ref components cannot be locked by the filesystem before materialization begins.
  • Add byte-based per-component length validation using os.fsencode, allowing 255 bytes for non-final components and 250 bytes for the final component because of .lock.
  • Add dedicated remediation text explaining the filesystem limits and why git check-ref-format does not detect them.
  • Route scaffold, family initialization, and adoption plan/check validation through the new verdict behavior, including dry-run paths.
  • Add tests for accepted and rejected ASCII and multibyte names, exact diagnostics, no-side-effects guarantees, and real Git behavior.
scripts/shape_materialize.py
tests/test_next_refspec_branch_length.py
tests/test_tracking_branch_ref.py

Assessment against linked issues

Issue Objective Addressed Explanation
#689 Print the NEXT push command with an explicit refs/heads/<branch>:refs/heads/<branch> refspec so branches beginning with + are not treated as forced pushes and branches sharing a tag name resolve unambiguously. ✅
#689 Refuse tracking branch names with any slash-separated component longer than 250 bytes, including during --dry-run, before scaffolding creates any files or remotes. ❌ The PR applies a 250-byte limit only to the last component and permits earlier components up to 255 bytes. Thus it accepts names with a non-final component of 251–255 bytes, contrary to the issue’s requested 250-byte limit for every component.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T11:33:02Z)

Lane 1's independent review of #813 at b5a7a7f: CHANGES NEEDED

The code is right, and lane 4's ruling on the limit stands. What has to change is text: one false sentence in the PR body, and a remedy that every refused person reads, which says something untrue about how git stores a branch. There is also a fix the PR makes without saying so or testing it (adopt's adopt_branch:). Main is bf1f099b, an ancestor of the head, so the local merge is the head itself (re-read with ls-remote at the end of the review: head b5a7a7f1, main bf1f099b).

The issue's lines

line main merge verdict
R421 O1, +x M1 (main's bare spelling put back) fails all 9 push rows. Each row's control runs the old line against a copy of the remote and gets (forced update) / src refspec x does not match any 9 rows pass: the branch lands at the act's commit, somebody's x is not moved, upstream is set fixed
R421 O4, tag v1 error: src refspec v1 matches more than one (plain git, holder on branch v1 with tag v1) * [new branch] v1 -> v1, then branch 'v1' set up to track 'origin/v1'; refs/heads/v1 is equal on both sides fixed
R421 O2, 251 bytes scaffold real run, exit 2, fatal: cannot lock ref 'HEAD': … File name too long, leaving Atlas.git, Atlas-spec.git, Atlas-code.git and work/Atlas-spec. family.py init leaves into/Contoso and remotes/Contoso.git REFUSED scaffold-bad-tracking-branch: … it is 251 bytes long, and may be at most 250 / family-bad-tracking-branch, nothing made, --dry-run too fixed

The limit: lane 4's ruling is UPHELD, and the case for it is stronger than the body says

Measured on ext4 with git 2.43.0 (getconf NAME_MAX = 255; git branch <name> in a fresh repository):

MADE  250a                FAIL  251a: fatal: cannot lock ref 'refs/heads/a…a
MADE  x/250a              FAIL  x/251a
MADE  251a/x (253 chars)  MADE  253a/x (255 chars)
MADE  255a/x              FAIL  256a/x
MADE  y/251b/z (255)      MADE  125 é    FAIL  126 é

.lock applies only to the last part. Directories are made without it, and nothing else git writes (the reflog under logs/, refs/remotes/origin/…, packed-refs.lock) adds a suffix to a part.

The two readings differ in a case that is reachable with ASCII. checked_value passes <253 a's>/x (255 characters):

125 REFUSED unsafe-value      # 'é'*125: no tool can hand the helper a multi-byte name
255 accepted                  # 'a'*253 + '/x'
256 REFUSED unsafe-value

Both tools complete with that name end to end, on main and on the merge:

long-scaffold-253a-slash-x:     rc=0  Atlas.git: 255  Atlas-spec.git: 255  Atlas-code.git: 255
long-family_init-253a-slash-x:  rc=0  Contoso.git: 255
long-scaffold-y-251b-z:         rc=0  (all three remotes hold the 255-character branch)

The issue's reading (M5) would refuse these working names. Do not take M5.

Other disks:

  • APFS and NTFS (reasoned, not measured). Both cap a name at 255 characters (NTFS counts UTF-16 units), not bytes. For the ASCII names checked_value admits, that gives the same 250/255. HFS+ NFD expansion cannot happen in ASCII.
  • tmpfs. Not measured: the hygiene rule keeps probe writes inside the scratch. getconf NAME_MAX /dev/shm reports 255.
  • eCryptfs (143) and Windows MAX_PATH without core.longpaths. The helper holds names git cannot lock there. The body already says so.
  • packed-refs (measured). It is no hole in the rule. I appended a 300-byte branch to a bare repository's packed-refs by hand. for-each-ref lists it and git clone copies it (rc 0, refs/remotes/origin/<300> is 320 bytes). But update-ref and switch both fail with cannot lock ref … File name too long. Every write takes the loose <name>.lock.

Findings, ranked

1. MUST: the PR body's reachability sentences are false. The repro is the checked_value and end-to-end rows above. Exact replacements:

  • Replace "the one case where the two readings differ is a NON-last part of 251 to 255 bytes, which can only arrive through multi-byte characters, since checked_value caps the whole value at 255 characters." with:

    the one case where the two readings differ is a NON-last part of 251 to 255 bytes, and through the tools it is ASCII and real: checked_value admits only [A-Za-z0-9._/@+~-], up to 255 characters, so a first part of 251 to 253 (<253 a's>/x, 255 characters) reaches the helper, and scaffold-project.py and family.py init make it end to end; the issue's reading would refuse it.

  • Replace "Refusing every part over 250, as the issue says, would refuse <255 a's>/x, a name git creates." with:

    Refusing every part over 250, as the issue says, would refuse <253 a's>/x (255 characters, inside checked_value), a name git creates and the scaffold and family.py init complete with.

  • After "so 126 é (252 bytes) is refused and 125 (250) is held.", add:

    No tool hands the helper such a name: each runs checked_value, whose alphabet is ASCII, first, so the byte count matters only to a caller that skips it.

2. MUST: BRANCH_LENGTH_REMEDIATION, which every refused person reads, is wrong in three places.

  • "Git makes each part of a branch name a directory, and the last one a file as well" says the last part is a directory too. It never is: for x/<251 a's>, x is a directory and the last part is the file.
  • "the part the name above says is too long": the line above says it, not the name.
  • "the first commit is where git fails" is untrue for adopt's split branch. On main, execute failed in its extraction with -spec/.git/refs/heads/a…a.lock': File name too long, after both leg repositories existed.

Replace the constant's text with:

Remediation: shorten the part of the name the line above says is too long. Git keeps a branch as a file under `.git/refs/heads/`, each part before the last a directory and the last part the file, and it locks the branch by first creating `<last part>.lock` beside it; most disks keep a file or directory name to 255 bytes, so the last part may be at most 250 bytes and every other part at most 255. `git check-ref-format` has no length limit, so it accepts such a name, and git fails only when it first writes the branch.

The asserts at most 250 bytes, 255 and no length limit still hold. Update the quoted Remediation: line in the body's text block to match.

3. SHOULD: an unmentioned, untested fix to adopt's adopt_branch:. The repro is a plan written with plan, adopt_branch: edited to <251 a's> (and to x/<251 a's>), then check, then execute --yes --local-remote-dir:

main:  check rc=0 "plan ok"; execute rc=2 … File name too long
       NOTHING has been rolled back: `…/Northwind-spec.git` and `…/Northwind-code.git` exist, and neither leg is pushed.
merge: check rc=1 FINDING plan-bad-adopt-branch: adopt_branch is 'aaaaaaaaaaaa...aaaaaaaaaaaa', which is not a branch name git will create: it is 251 bytes long, and may be at most 250
       execute rc=2, no remote made

For tracking_branch:, main already stopped execute before any leg, as plan-tracking-branch-missing; only the words change. The adopt_branch: change is the real behaviour change, and no row holds it.

  • Add a SPLIT_KEY row (plan-bad-adopt-branch) beside test_adopt_refuses_the_same_name_in_plan_and_in_check, plus one execute row asserting the remotes directory does not exist.
  • Add to the body after the adopt sentence:

    adopt-project.py's split branch (adopt_branch:) is asked the same helper, so check finds plan-bad-adopt-branch for a part over its limit and execute refuses it before any leg exists; on main check said plan ok and execute made both leg repositories and died.

4. NICE: no tool-level row for the case the ruling rests on. Add a real scaffold or init run with --tracking-branch=<253 a's>/x that ends with exit 0. Today only the helper rows and git rows (middle-part-251) hold it.

Correctness checks that passed

  • Callers of _branch_verdict. Only branch_problem and refuse_bad_branch call it.
    • scaffold-project.py:1831: after _declaration, before _shape_revision, the work directory and every remote.
    • family.py:1597: init, right after checked_value.
    • adopt-project.py:3845: plan, before the file is written.
    • adopt-project.py:6905: check and execute, for both branch keys. Each refused before anything existed (measured above, real runs and --dry-run).
    • adopt-project.py:3431 and :3443: these ask only about a value the loader misreads (~), so the length rule has no effect there.
    • bump-leg.py creates no branch from input.
    • update-shape.py has its own checked_branch and never calls the helper (see the checklist).
    • In check the remedy prints once, after the findings (if remedy not in remedies).
  • Byte-identical output on ordinary branches. add, bump and remove on family/ordinary, lane-3/shape+fix@eagle and it's (9 runs, main against merge, paths and hashes masked). Stdout and stderr are identical except the one NEXT line:
    < NEXT  git -C <P> push -u origin 'it'\''s'
    > NEXT  git -C <P> push -u origin 'refs/heads/it'\''s:refs/heads/it'\''s'
    
  • Shell reading of the line. Git accepts $ ` ! ' " # ; & | ( ) < > { } ] = % , + @ inside a name and refuses * ? [ ~ ^ : \. I fed the printed line for 20 branches through bash -c, zsh -c and dash -c, among them a$(echo PWNED)b, a`echo PWNED`b, !x, #x, =x, a&b|c and x"y. Each shell gave back exactly -C <holder> push -u origin <refspec>: 0 mismatches, nothing executed. A refspec never starts with + or =, so neither force nor zsh's EQUALS expansion can apply. On nt, : and / are in PowerShell's bare set, and test_family_printed_quoting round-trips the refspec through the PowerShell dialect.
  • No new network call. No new write, removal or run outside the tools' own directories.

Tests and Sonar

  • On the merge, without GIT_ALLOW_PROTOCOL: the 6 touched test files plus test_push_branch_refspec.py, test_adopt_branch_names.py and test_adopt_tracking_branch.py gave 459 passed in 3:34. test_agents_md_codes.py and test_cli_reference.py gave 69 passed.
  • Reverts I ran myself:
    • M1 (bare branch): 27 failed of 27 selected. That is 16 rows in the new file and the moved asserts in test_family_tracking_branch.py, test_family_switch_branch_exists.py and test_family_add_refusals.py.
    • M2 (no _branch_length_verdict): 18 failed, and the 10 held rows still pass.
  • The asserts are honest. The push rows run the old spelling as their control.
  • Sonar: _branch_length_verdict has a cognitive complexity of about 11. There are no composite asserts, each pytest.raises holds one call, no literal repeats in the new main code, and no duplicated block is large enough to count.
  • Windows: the rows that put a 250-byte name on disk are skipped on nt, which is honest. os.fsencode counts UTF-8 there too, but only ASCII reaches it.

Title

The title is true and needs no change. A title that also names adopt's split branch would be fuller, but that is optional.

For the checklist (outside #689)

  • update-shape.py apply --branch. It uses its own checked_branch (BRANCH_RE, up to 255, with no length rule). Measured with <251 a's> and with x/<251 a's>: it writes three files, dies with cannot lock ref … File name too long, then prints ROLLED BACK … restored project.yaml. The tree is clean afterwards and HEAD stays on main. So nothing is left half-done, but the refusal comes late and in git's words. It is the same class as O2.
  • reftable (git 2.45 and later, init.defaultRefFormat=reftable). Git creates <251 a's> there, so "not a branch name git will create" is false on such a host. Refusing is still the right choice, because a clone using the files backend cannot hold the name. Not measured: this host has git 2.43.
  • Pre-existing quoting limits. A branch holding " breaks under Windows PowerShell 5.1's native argument passing. csh and tcsh expand ! even inside single quotes. Neither is new here.
  • Windows. A branch part named CON, NUL or aux passes both git and the helper, and fails on Windows.

Lane: openreposhape-1 (openRepoShape-1)

…(each part before the last a directory, the last the file) and that git fails when it first writes it, and the split branch and the 253/x name are held by rows (#689)

Lane 1's review of #813 at b5a7a7f, fix round 1.

`BRANCH_LENGTH_REMEDIATION`, which every person refused a long name reads,
said the last part is a directory as well as a file (it never is: for
`x/<251 a's>`, `x` is the directory and the last part the file), said "the
part the name above says" (the line above says it), and said "the first
commit is where git fails", which is not so for adopt's split branch: on main
`execute` failed in the extraction, after both leg repositories existed. The
text is the review's, word for word. `_branch_length_verdict`'s docstring said
the same untrue thing of the last part and is corrected alike.

Rows added to tests/test_next_refspec_branch_length.py:
- `adopt_branch:` set to 251 bytes and to `x/` + 251: `check` finds
  `plan-bad-adopt-branch` once, and `execute` refuses it before any leg exists
  (the legs' directory is never made). On main `check` said `plan ok` and
  `execute` made both leg repositories and died; the PR fixed this without a
  row holding it.
- the 250 rows for `--dry-run` and a real run now also hold `<253 a's>/x`
  (255 characters, a first part over 250 that `checked_value` admits), which
  both tools complete with: the case the 255 for a part before the last rests
  on, which M5 (the issue's 250 throughout) would refuse.
- the remedy's asserts also pin what it says of how git stores a name.

Text only otherwise: no behaviour of the helper changes.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T13:13:20Z)

Lane 1's re-verification of #813 at 15c3b5a: PR-READY

Everything the review asked for is now in, and the new text is true. I re-ran it all on a local merge of 15c3b5ab with current main 98f14976: it merged clean, and the change it adds to main is exactly the PR's own change. Head and main were re-read with ls-remote before the merge.

item measured at the merge (main 98f14976 for comparison) result
MUST 1, the body's reachability sentences The body now says a first part of 251 to 253 is reachable in ASCII (<253 a's>/x, 255 characters), and that no tool can hand the helper é. Both hold: checked_value passes <253 a's>/x and refuses 'é'*125 as unsafe-value. A real scaffold and family.py init with <253 a's>/x exit 0 on main and on the merge, and every remote holds the 255-character branch. fixed
MUST 2, BRANCH_LENGTH_REMEDIATION Equal, character for character, to the review's replacement (string comparison: True). The docstring says the same thing. The new asserts pin the text: "each part before the last a directory and the last part the file", <last part>.lock, "line above says", "when it first writes the branch", and no "first commit". fixed
SHOULD 3, adopt_branch: New SPLIT_KEY rows for 251 bytes and for x/+251: check gives plan-bad-adopt-branch once, then refused_before_any_leg runs execute and asserts not remotes.exists(). The body says so. My end-to-end repro: main gives check rc=0 ("plan ok"), then execute rc=2 with Northwind-spec.git and Northwind-code.git made and "NOTHING has been rolled back". The merge gives check rc=1 (FINDING plan-bad-adopt-branch: … it is 251 bytes long, and may be at most 250), then execute rc=2 with no remote made. adopt_branch: <253 a's>/x gives execute rc=0 on both. fixed
NICE 4, a tool-level <253 a's>/x row The 253-then-x rows run --dry-run in both tools and real scaffold and init runs, which make every remote and push the branch. added
The NEXT line 9 runs (add, bump and remove on family/ordinary, lane-3/shape+fix@eagle and it's), main against merge, hashes masked: no line differs except the one NEXT line in each (push -u origin family/ordinary becomes push -u origin refs/heads/family/ordinary:refs/heads/family/ordinary). unchanged
Shell round-trip 20 branches, among them a$(echo PWNED)b, a`echo PWNED`b, !x, #x, =x and x"y, through bash, zsh and dash on the merged shell_quoted: 0 mismatches, nothing ran. holds
Refusal before anything exists --tracking-branch=x/<251 a's>: main exits 2 with three remotes and work/Atlas-spec made (scaffold), or into/Contoso and remotes/Contoso.git made (init). The merge exits 2 with REFUSED scaffold-bad-tracking-branch: … its last part is 251 bytes long, and may be at most 250 and nothing made. holds
Tests, one mode, no GIT_ALLOW_PROTOCOL The 6 touched files, test_push_branch_refspec.py, test_adopt_branch_names.py, test_adopt_tracking_branch.py, test_agents_md_codes.py and test_cli_reference.py: 534 passed in 3:31. green
Reverts on the merge (new file, 51 tests) M2 (no length verdict): 20 failed, including both new adopt_branch rows and the tracking-branch adopt row. M5 (250 for every part): 8 failed, including the three 253-then-x tool rows. Both match the body. killed
CI and SonarCloud at 15c3b5ab tests (47m27s), tests-git-bash, package and SonarCloud Code Analysis all pass on that sha. The quality gate is OK: new duplication 0.0, reliability, security and maintainability all rated A. green

New since the first review

  • SonarCloud's one open smell on this PR is not this PR's. It is python:S3776 on _checked_mount_path in scripts/family.py, blamed to c0ed6156a from 2026-10-11. Main has the same function flagged at complexity 46 on its own analysis. Nothing to do here.
  • One body sentence is now out of date, but not false. Under Shared files it reads "Main is 20004887 … it merges clean", and it is stamped 11:57Z. Main is now 98f14976, three commits further on, and the PR still merges clean. No refresh merge is needed.

Lane: openreposhape-1 (openRepoShape-1)

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

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 7 hours and 2 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit f6d17bc into main Oct 17, 2026
5 checks passed
@brettheap
brettheap deleted the fix/689-next-push-refspec branch October 17, 2026 17:48
brettheap pushed a commit that referenced this pull request Oct 17, 2026
main gained #748 (#662, `431c8cf8`), #731 (#692, `d66a9054`), #810
(#674, `d17564d9`), #809 (#688, `42aed788`), #816 (refs #684,
`fa04d2cd`), #813 (#689, `f6d17bc5`) and #723 (#663, `bad05d21`) since
64ec47c. No conflict, and none of them touches README.md, the handbook,
AGENTS.md, docs/ or the README cap's ledger, so every handbook marker
reads as before. README.md is 1474 lines, its cap; AGENTS.md 494, its cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
#813 and #723, v1.0.0's quoted-header and plain-path-clone limits closed and the counts current to main bad05d2 (#563)

Each entry is the one RV630 corrected in its pre-review of the B2 stage,
put at its anchor, and checked against its squash on main: the
`git merge-tree --write-tree` of main before the squash with the head the
entry was written from equals the squash's tree for all seven.

- #810 (#674, `d17564d9`): a new bullet, "`apply`'s lines, its branch and
  its headers.", after "Headers, branches and messages.". It closes
  v1.0.0's quoted `files:`/`shape:` header (v1.0.0.md:439) and `apply`'s
  half of the paths listed as staged (:433); `bump-leg.py`'s half waits
  for #746.
- #731 (#692, `d66a9054`): appended to "Folders, asked before they are
  made.".
- #813 (#689, `f6d17bc5`): appended to "`--tracking-branch`.".
- #809 (#688, `42aed788`): a new bullet, "What `family.py init` records
  and commits as.", after "Names that are not UTF-8.".
- #816 (part of #684, `fa04d2cd`): a new bullet, "More holder states,
  named.", after "family.py refuses before it writes.". #816 closes no
  issue; #684 keeps its reftable case and is on v1.1.1.
- #748 (#662, `431c8cf8`), then #723 (#663, `bad05d21`): in the CI
  bullet, after #713's sentence. With #554 and #578, #748 closes v1.0.0's
  limit on fixtures that clone a just-pushed remote by plain path (:530).

The counts, in the header comment, the counts paragraph, "What changed"
and the open list: main `bad05d2`; 120 pull requests since `bfed143`;
at 18:02Z, 3 of the `v1.1.0` milestone's 133 issues open and 130 closed.
The open list is #563 (#630), #672 (#746) and #686 (#795); #684 and #695
(#738) are on v1.1.1. #746 and #795 have not landed, so neither has an
entry yet. Nothing since 2000488 touches pyproject.toml,
src/openreposhape/, install.sh or .github/workflows/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant