Skip to content
19 changes: 17 additions & 2 deletions scripts/family.py
Original file line number Diff line number Diff line change
Expand Up @@ -1550,9 +1550,24 @@ def _checked_inside_sparse_rules(root: Path, verb: str,
def _print_next(family: "Family", branch: str) -> None:
"""The NEXT an act that committed ends with (#487): ONE pasteable line
that pushes the branch the commit is on, every word quoted for the
reader's shell (#486), then the pull request it is for."""
reader's shell (#486), then the pull request it is for.

THE BRANCH BY ITS FULL REFNAME, ON BOTH SIDES (#689). A bare `<branch>`
is a REFSPEC git reads for itself, and a leading `+` in one means FORCE:
a holder on `+x` printed `push -u origin +x`, which pushed the LOCAL
branch `x` over origin's `x` (`+ 77de599...261789e x -> x (forced
update)`) and never the commit this act made, or, with no local `x`,
died `src refspec x does not match any`. A branch that shares its name
with a tag (`v1`) died `src refspec v1 matches more than one`.
`refs/heads/<branch>:refs/heads/<branch>` is the branch it is whatever
is checked out, and `-u` still records `origin/<branch>` as its upstream
(`branch.<branch>.merge`). `init`'s own push names its side of the
remote in full too (`HEAD:refs/heads/<branch>`, #421), and the whole
refspec is ONE word for `shell_line` to quote.
"""
refspec = f"{HEADS_PREFIX}{branch}:{HEADS_PREFIX}{branch}"
print("\nNEXT " + shell_line(["git", "-C", family.root, "push", "-u",
"origin", branch]))
"origin", refspec]))
print(f" then open a pull request against {family.tracking()}; "
"never push to it directly")

Expand Down
74 changes: 69 additions & 5 deletions scripts/shape_materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,24 @@ def run(args: list[str], cwd: Path | None = None, capture: bool = True,
#: the person's name: a git that refuses this one too could not answer.
BRANCH_CONTROL = "main"

#: What git adds to a branch's last part to lock its ref while it makes it:
#: `refs/heads/<name>.lock`, a file in the same directory.
BRANCH_LOCK_SUFFIX = ".lock"

#: The remedy for a part too long for the disk (#689), whose own words are
#: the same for a flag and for a plan's key, so `adopt-project.py`'s `check`
#: prints it as it stands. `BRANCH_REMEDIATION` would send the person to
#: `git check-ref-format --branch`, which accepts every such name.
BRANCH_LENGTH_REMEDIATION = (
"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 "
f"the branch by first creating `<last part>{BRANCH_LOCK_SUFFIX}` 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.")


def _ask_git_branch(name: str) -> str:
"""`git check-ref-format --branch <name>`, asked of NO repository and
Expand Down Expand Up @@ -1227,8 +1245,9 @@ def _ask_git_branch(name: str) -> str:

def _branch_verdict(what: str, value) -> tuple[str | None, str]:
"""`(problem, remediation)`: `branch_problem`'s sentence, and the remedy
that fits it, which is about the NAME when git judged the name and about
GIT when git could not answer."""
that fits it, which is about the NAME when git judged the name, about
GIT when git could not answer, and about the DISK when a part of the
name is too long for it (#689, `_branch_length_verdict`)."""
text = str(value)
refused = f"{what} is {text!r}, which is not a branch name git will create"
unchecked = f"{what} is {text!r}, which could not be checked"
Expand Down Expand Up @@ -1264,12 +1283,55 @@ def _branch_verdict(what: str, value) -> tuple[str | None, str]:
if answer != text:
return (f"{refused}: `git check-ref-format --branch` reads it as "
f"{answer!r}, not as written", BRANCH_REMEDIATION)
return _branch_length_verdict(what, text)


def _branch_length_verdict(what: str, text: str) -> tuple[str | None, str]:
"""`_branch_verdict`'s last question, asked of a name git accepted
(#689): can the disk hold the files git makes for it?

`git check-ref-format` has NO LENGTH LIMIT, so a tracking branch of 251
to 255 characters, inside `checked_value`'s 255, passed it and passed
`--dry-run`, and the real run died at the first commit with every remote
already made: `fatal: cannot lock ref 'HEAD': Unable to create
'.../refs/heads/aaa....lock': File name too long`. The limit is the
disk's, not git's, so it is the one question here that is written down
and not asked of git. Git keeps a branch as a file under `refs/heads/`:
each part before the last is a directory and the last part is the file,
which git locks by first creating `<last part>.lock` beside it. So the
last part may be at most `NAME_MAX - len(".lock")` = 250 bytes and any
other part `NAME_MAX` = 255 (measured: `<255 a's>/x` and `x/<250 a's>`
are created, `x/<251 a's>` and `<251 a's>` are not). BYTES, as
`refuse_unstorable_names` counts them, in the form the operating system
is given (`os.fsencode`). One refusal, for the first part over its limit.
"""
parts = text.split("/")
for number, part in enumerate(parts, 1):
last = number == len(parts)
limit = NAME_MAX - (len(BRANCH_LOCK_SUFFIX) if last else 0)
size = len(os.fsencode(part))
if size <= limit:
continue
# BOTH ENDS, as `refuse_unstorable_names` shows a long name: the
# tail is what tells two long names apart.
shown = text if len(text) <= 27 else f"{text[:12]}...{text[-12:]}"
if len(parts) == 1:
which = "it"
elif last:
which = "its last part"
else:
which = f"its part {number}"
return (f"{what} is {shown!r}, which is not a branch name git will "
f"create: {which} is {size} bytes long, and may be at most "
f"{limit}", BRANCH_LENGTH_REMEDIATION)
return None, BRANCH_REMEDIATION


def branch_problem(what: str, value) -> str | None:
"""None when `git check-ref-format --branch` accepts `value` exactly as
written; otherwise the sentence that says what git made of it.
written and the disk can hold the files git makes for it (#689);
otherwise the sentence that says what git made of it, or which part is
too long.

`what` names the value as the person met it (`--tracking-branch`,
`tracking_branch`), the way `leg_path_problem` is given its flag. Git is
Expand All @@ -1291,8 +1353,10 @@ def refuse_bad_branch(refusal: str, what: str, value) -> None:

`refusal` is the caller's own code (`scaffold-bad-tracking-branch`), so
each tool refuses in its own voice, as `refuse_bad_leg_paths` lets it.
The remedy is `BRANCH_REMEDIATION` when git judged the name, and
`BRANCH_UNCHECKED_REMEDIATION` when git could not answer at all.
The remedy is `BRANCH_REMEDIATION` when git judged the name,
`BRANCH_UNCHECKED_REMEDIATION` when git could not answer at all, and
`BRANCH_LENGTH_REMEDIATION` when a part of the name is too long for the
disk (#689).
"""
problem, remediation = _branch_verdict(what, value)
if problem:
Expand Down
6 changes: 4 additions & 2 deletions tests/test_family_add_refusals.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@
"their own assembly root{named}.")

#: What a good `add` ends with (#487): the push of the branch it committed on,
#: which is the fixture's own, and the pull request it is for.
NEXT = ("NEXT git -C {root} push -u origin {branch}",
#: which is the fixture's own, by its full refname on both sides (#689), and
#: the pull request it is for.
NEXT = ("NEXT git -C {root} push -u origin "
"refs/heads/{branch}:refs/heads/{branch}",
" then open a pull request against main; never push to it "
"directly")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_family_printed_quoting.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ def test_nothing_is_flattened_and_a_typographic_apostrophe_is_doubled():

def test_a_line_is_its_words_each_quoted():
words = ["git", "-C", "/srv/my work/Sonata", "push", "-u", "origin",
"family/add-irrs"]
"refs/heads/family/add-irrs:refs/heads/family/add-irrs"]
assert shlex.split(shell_line(words, "posix")) == words
assert powershell_words(shell_line(words, "nt")) == words

Expand Down
7 changes: 4 additions & 3 deletions tests/test_family_switch_branch_exists.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
)
from test_family_printed_quoting import host_words
from test_family_tracking_branch import ( # noqa: F401
TRACKING, act, next_push, on_main, remedy_switch, snapshot, suggested,
TRACKING, act, next_push, on_main, refspec, remedy_switch, snapshot,
suggested,
)
from test_setup_project_py import entry_point_module
# `BRANCH_LEFT` is the sentence the tools end the remedy with, pinned as
Expand Down Expand Up @@ -194,7 +195,7 @@ def test_the_refusal_typed_as_printed_puts_the_act_on_its_branch(
assert git("rev-parse", "HEAD~1", cwd=holder).stdout.strip() == \
earlier
push, _ = next_push(done.stdout)
assert push[-1] == branch, push
assert push[-1] == refspec(branch), push
assert validate(holder).returncode == 0


Expand Down Expand Up @@ -515,5 +516,5 @@ def test_the_branch_filled_in_as_written_is_one_add_commits_on(family,
done = act(family, holder, "add")
assert done.returncode == 0, done.stdout + done.stderr
push, _ = next_push(done.stdout)
assert push[-1] == branch, push
assert push[-1] == refspec(branch), push
assert SPARE in [row["project"] for row in manifest(holder)["members"]]
12 changes: 10 additions & 2 deletions tests/test_family_tracking_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ def next_push(stdout: str) -> tuple[list[str], str]:
return host_words(lines[at][len("NEXT "):]), lines[at + 1]


def refspec(branch: str) -> str:
"""The word the NEXT push ends with (#689): the branch by its full
refname on both sides, which no `+`, tag or checked-out branch can
change the reading of."""
return f"refs/heads/{branch}:refs/heads/{branch}"


@pytest.mark.parametrize("verb", ["add", "bump", "remove"])
def test_each_act_refuses_the_tracking_branch_then_lands_on_a_branch(
family, on_main, verb):
Expand Down Expand Up @@ -156,7 +163,7 @@ def test_each_act_refuses_the_tracking_branch_then_lands_on_a_branch(
"the tracking branch did not move")
push, then = next_push(done.stdout)
assert push == ["git", "-C", str(holder), "push", "-u", "origin",
branch]
refspec(branch)]
assert then == (f" then open a pull request against {TRACKING}; "
"never push to it directly")
assert done.stdout.rstrip().endswith(then), (
Expand Down Expand Up @@ -305,7 +312,8 @@ def test_the_tracking_branch_is_the_one_family_yaml_declares(family,
done = run_script(FAMILY, *add)
assert done.returncode == 0, done.stdout + done.stderr
push, then = next_push(done.stdout)
assert push == ["git", "-C", str(holder), "push", "-u", "origin", "main"]
assert push == ["git", "-C", str(holder), "push", "-u", "origin",
refspec("main")]
assert then == (" then open a pull request against trunk; never "
"push to it directly")

Expand Down
Loading
Loading