From 7013335b09e34ddbbe63c7e9086cef6f05a25756 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:47:01 +0000 Subject: [PATCH] bump-leg.py: a tracked workflow the disk spells another way is rewritten and committed under git's spelling, so a `legs.yml` renamed `Legs.yml` by case alone outside git on macOS or Windows is no longer refused as `bump-leg-workflow-untracked` while `git status` is clean, with an exit that could not clear it (#332) `plan_workflows` walks `.github/workflows/` on disk and made a row for a file only when the walk's spelling was in git's tracked set, compared exactly. On a disk that ignores case (APFS and NTFS by default) a tracked `legs.yml` renamed outside git to `Legs.yml` is one file under both names. Git calls the root clean, so `_ensure_root_clean` passed, and the walk's `Legs.yml` was then refused as untracked, with "commit it first" as the exit when there was nothing to commit. - **A name git does not hold is the tracked file when the filesystem says it is.** `_tracked_spelling`: a name in git's tracked set is its own spelling, as before. Any other name is the tracked file only when exactly one tracked name in the same directory is missing from the listing and `os.path.samefile` resolves it to this very file. `_same_file` is the one place the tool asks, and the seam the tests replace. A tracked name the walk lists is a file of its own, a hard link included. A name that two tracked spellings both resolve to (an index holding `legs.yml` and `LEGS.yml`) is still refused as untracked, because one rewrite cannot keep two index entries in step. - **The row carries git's spelling.** The dry run lists `.github/workflows/legs.yml`, the commit's `git add` and pathspec name it, and the rewrite reaches the one file through it. Git matches a commit's pathspec against the index by exact spelling whatever the disk does, so the disk's spelling could never have been committed. Measured with `core.ignorecase=true`: `git commit -- Legs.yml` refuses with "pathspec 'Legs.yml' did not match any file(s) known to git". The disk keeps its own spelling, and nothing is renamed. - **The filesystem is asked, not `core.ignorecase`.** The issue suggested asking git which files are untracked (`git ls-files -o`). On a disk that keeps case, with `core.ignorecase=true`, git lists a separate `Legs.yml` beside the tracked `legs.yml` as neither untracked nor stageable (`git add Legs.yml` is a no-op). Tried as a mutation, that direction took the second file for tracked, and the bump ended `bump-leg-validators-red` after the rewrite instead of a refusal before it. With `samefile` it is refused by name before anything is written, whatever the setting says. - **Tests.** Four functions in `tests/test_bump_leg.py`, seven cases. `test_a_tracked_workflow_respelled_by_case_moves_under_gits_spelling` is the issue on its own disk. A tracked `legs.yml` is renamed `Legs.yml` outside git and `git status` is clean. The dry run lists `.github/workflows/legs.yml`, and the bump exits 0 and commits exactly `spec`, the pin and `.github/workflows/legs.yml`, with the rewrite in HEAD's blob. The project's validator is green and the status is still clean. It runs only where a probe of the test's temporary directory finds a disk that ignores case (the macOS and Windows jobs), and is skipped on one that keeps it. `test_a_respelled_tracked_workflow_is_planned_under_gits_spelling` is the rule on every disk, with `_same_file` answering as a disk that ignores case would. It fails on main with the issue's refusal. `test_a_name_two_tracked_spellings_answer_to_is_refused_as_untracked` runs everywhere. `test_a_second_spelling_that_is_a_second_name_is_refused_whatever_git_says` is parametrized over a separate file and a hard link, each with `core.ignorecase` true and false, and runs only where the disk keeps case. Four mutations of the fix are each caught by at least one of these tests: the row carrying the disk's spelling, any number of matches accepted, the listing check dropped, and asking `git ls-files -o`. - **Unchanged:** main's and this branch's `bump-leg.py` were each run over a copy of one project, on an ordinary bump (a tracked `legs.yml` naming the leg). Both print the same dry-run and real output and write the same commit message and the same tree. The refusal's id, text and exits are unchanged. `--help` is unchanged, so `docs/cli.md` is too, and README's "an untracked or ignored workflow file it would rewrite" stays true. - **Left out:** an index that already holds two spellings of one workflow, on a disk that ignores case and lists one of them exactly, still has that one rewritten and committed while the other keeps its old `@` in the tree. That defect predates #222. A name APFS stores in another Unicode normalization form than the index falls under the same `samefile` rule, but is not tested here. Closes #332 Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 --- scripts/bump-leg.py | 77 ++++++++++++++++-- tests/test_bump_leg.py | 173 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 5 deletions(-) diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py index 706b0506..47fc59cc 100755 --- a/scripts/bump-leg.py +++ b/scripts/bump-leg.py @@ -41,7 +41,10 @@ would turn the bump red, because `validate-pins.py` reads every file on disk and would still see its old `@`. Refused by name, before anything is written, under `--dry-run` as well. One that names no leg, - or this leg already at `--to`, is left alone. + or this leg already at `--to`, is left alone. A TRACKED file the disk + spells another way — renamed by case alone outside git, on a disk that + ignores case — is that tracked file, rewritten and committed under + git's spelling, never refused as untracked. * the root standing on its own tracking branch. The next step this prints would then be a push to the default branch, and these organisations are pull-request only. @@ -252,6 +255,56 @@ def _tracked_workflows(root: Path, workflows: Path) -> set[str]: return {os.fsdecode(name) for name in raw.split(b"\0") if name} +def _same_file(first: Path, second: Path) -> bool: + """Whether the filesystem resolves both names to ONE file — False when + either cannot be resolved at all. + + THE SEAM a test on a disk that keeps case replaces to stand in for one + that ignores it (#332): no other line of this tool asks the question. + """ + try: + return os.path.samefile(first, second) + except OSError: + return False + + +def _tracked_spelling(root: Path, path: Path, tracked: set[str], + listed: set[str]) -> str | None: + """Git's spelling of the workflow file at `path`, or None when git does + not track it. + + `path` is a name the directory walk yielded, and `listed` is every name + that walk yields. A name git holds is its own spelling. Any other is a + tracked file only when EXACTLY ONE tracked name in the same directory is + missing from `listed` and the filesystem resolves it to this very file: + a disk that ignores case, which macOS and Windows use by default, holds + a tracked `legs.yml` renamed outside git to `Legs.yml` as one file, + lists it as `Legs.yml` and opens it under either name. A tracked name + the walk DOES list is a file of its own, never a respelling of another, + and one no file answers to is not this one. Two that both answer are an + index carrying two spellings of one file, which no single rewrite can + keep in step, so that name is left unresolved and refused as untracked. + + THE FILESYSTEM IS ASKED, NOT `core.ignorecase`. The setting is git's + record of the disk a clone was made on, and a clone copied to another + disk keeps it. Set true on a disk that keeps case, git lists neither a + separate `Legs.yml` as untracked nor stages it (`git add Legs.yml` is a + no-op and `git commit -- Legs.yml` refuses the pathspec): taken from + git, such a file would be rewritten on disk and be missing from the + commit. Asked of the filesystem, a second spelling there is a second + file and is refused by name, whatever the setting says. + """ + relative = path.relative_to(root).as_posix() + if relative in tracked: + return relative + parent = relative.rsplit("/", 1)[0] + answering = [other for other in sorted(tracked) + if other.rsplit("/", 1)[0] == parent + and other.rsplit("/", 1)[1] not in listed + and _same_file(root / other, path)] + return answering[0] if len(answering) == 1 else None + + def plan_workflows(root: Path, repository: str, commit: str) -> list[tuple[Path, str, int]]: """Every TRACKED workflow file whose `@` refs to THIS leg have to move. @@ -280,12 +333,26 @@ def plan_workflows(root: Path, repository: str, repository. One with nothing to move — no reference to this leg, or this leg already at `commit` — is not this tool's business and is left alone, unnamed. + + A TRACKED FILE THE DISK SPELLS ANOTHER WAY IS STILL TRACKED (#332). The + walk yields the disk's spelling and `git ls-files` the index's, and on a + disk that ignores case a tracked `legs.yml` renamed to `Legs.yml` outside + git is one file under both: `git status` is clean under the + `core.ignorecase` a clone there is given, and an exact comparison + refused it as untracked with an exit, "commit it first", that could + never clear it — there is nothing to commit. Its row carries GIT'S + spelling (`_tracked_spelling`), because git matches a commit's pathspec + against the index by exact spelling whatever the disk does: the commit + names the path git tracks and no other, and the rewrite reaches the one + file through it, read through the disk's name and written through + git's, which the filesystem has already said are the same file. """ plans: list[tuple[Path, str, int]] = [] workflows = root / ".github" / "workflows" if not workflows.is_dir(): return plans tracked = _tracked_workflows(root, workflows) + listed = {child.name for child in workflows.iterdir()} untracked: list[tuple[str, int]] = [] for path in sorted(workflows.iterdir()): if path.suffix not in (".yml", ".yaml") or not path.is_file(): @@ -306,11 +373,11 @@ def swap(match: re.Match) -> str: rewritten = WORKFLOW_REF_RE.sub(swap, text) if not moved: continue - relative = path.relative_to(root).as_posix() - if relative in tracked: - plans.append((path, rewritten, moved)) + spelled = _tracked_spelling(root, path, tracked, listed) + if spelled is not None: + plans.append((root / spelled, rewritten, moved)) else: - untracked.append((relative, moved)) + untracked.append((path.relative_to(root).as_posix(), moved)) if untracked: raise Refusal( "bump-leg-workflow-untracked", diff --git a/tests/test_bump_leg.py b/tests/test_bump_leg.py index 05d0a34d..ed49a226 100644 --- a/tests/test_bump_leg.py +++ b/tests/test_bump_leg.py @@ -16,6 +16,7 @@ from __future__ import annotations import importlib.util +import os import shutil import subprocess import sys @@ -511,6 +512,178 @@ def test_a_tracked_workflow_with_a_non_ascii_name_is_not_taken_for_untracked( assert validate(project).returncode == 0 +# --- a tracked workflow the disk spells another way (#332) ------------------ + +def disk_ignores_case(directory: Path) -> bool: + """Probe the disk `directory` is on: does a file written as + `case-probe` open as `CASE-PROBE`? macOS and Windows answer yes by + default, Linux no — asked of the disk, never assumed from the OS.""" + probe = directory / "case-probe" + probe.write_bytes(b"") + try: + return (directory / "CASE-PROBE").exists() + finally: + probe.unlink() + + +def respelled(workflows: Path, name: str, spelling: str) -> None: + """Rename `name` to `spelling` outside git, through a third name, so the + disk holds `spelling` whether or not it ignores case.""" + os.rename(workflows / name, workflows / (name + ".respelling")) + os.rename(workflows / (name + ".respelling"), workflows / spelling) + assert spelling in os.listdir(workflows) + assert name not in os.listdir(workflows) + + +def ignoring_case(first: Path, second: Path) -> bool: + """`_same_file` as a disk that ignores case answers it, for a test on + one that keeps case: two names in one directory spelled alike once case + is ignored are one file. On a disk that ignores case the real answer is + the same, so the tests that pass this run everywhere.""" + return (first.parent == second.parent + and first.name.casefold() == second.name.casefold()) + + +def test_a_tracked_workflow_respelled_by_case_moves_under_gits_spelling( + project, scaffolded, advanced, tmp_path): + """THE BUG (#332), on the disk it happens on. A tracked `legs.yml` + renamed outside git to `Legs.yml` on a disk that ignores case is one + file: the walk lists `Legs.yml`, the index holds `legs.yml`, and `git + status` is clean. The exact comparison refused it as untracked, and + "commit it first" could never clear it, because there is nothing to + commit. It is the tracked file: rewritten, listed and committed under + git's spelling, and only that, so the commit adds no path git does not + track and the rewrite is in it. + + RUNS ONLY WHERE THE DISK IGNORES CASE — the macOS and Windows jobs — and + is skipped on one that keeps it (Linux), where `Legs.yml` and + `legs.yml` are two files and `git status` reports the tracked one + deleted. The rule's own logic runs everywhere, in the test after this + one. + """ + if not disk_ignores_case(tmp_path): + pytest.skip("needs a disk that ignores case (macOS, Windows): on " + "this one a second spelling is a second file") + was, code = reference_both_legs(project) + branched(project) + workflows = project / ".github" / "workflows" + respelled(workflows, "legs.yml", "Legs.yml") + assert git("status", "--porcelain", cwd=project).stdout == "", ( + "git calls the root clean, so there is nothing to commit first") + + dry = bump(project, scaffolded, advanced, "--dry-run") + assert dry.returncode == 0, dry.stderr + dry.stdout + assert "bump-leg-workflow-untracked" not in dry.stderr + assert ".github/workflows/legs.yml 1 reference(s)" in dry.stdout + + result = bump(project, scaffolded, advanced) + assert result.returncode == 0, result.stderr + result.stdout + assert committed(project) == {"spec", "contracts/spec-pin.yaml", + ".github/workflows/legs.yml"} + names = git("ls-tree", "-r", "--name-only", "HEAD", "--", + ".github/workflows", cwd=project).stdout.splitlines() + assert ".github/workflows/legs.yml" in names + assert ".github/workflows/Legs.yml" not in names + blob = git("show", "HEAD:.github/workflows/legs.yml", cwd=project).stdout + assert spec_ref(advanced) in blob and was not in blob, ( + "the rewrite is in the commit, under git's spelling") + assert f"{ORG}/{PROJECT}-code/.github/workflows/y.yml@{code}" in blob + assert spec_ref(advanced) in (workflows / "Legs.yml").read_text( + encoding="utf-8") + assert validate(project).returncode == 0 + assert git("status", "--porcelain", cwd=project).stdout == "" + + +def test_a_respelled_tracked_workflow_is_planned_under_gits_spelling( + bump_leg_module, project, advanced, monkeypatch): + """The rule itself, on every disk: `_same_file`, the one place the tool + asks the filesystem, answers as a disk that ignores case would. The row + names git's `legs.yml`, which is the path the commit's pathspec carries, + with the text read from the disk's `Legs.yml` and its reference moved; + on main this is the `bump-leg-workflow-untracked` refusal.""" + was, _ = reference_both_legs(project) + workflows = project / ".github" / "workflows" + respelled(workflows, "legs.yml", "Legs.yml") + monkeypatch.setattr(bump_leg_module, "_same_file", ignoring_case, + raising=False) + + plans = bump_leg_module.plan_workflows(project, f"{ORG}/{PROJECT}-spec", + advanced) + + assert [(path.relative_to(project).as_posix(), moved) + for path, _, moved in plans] == [(".github/workflows/legs.yml", 1)] + (_, text, _), = plans + assert spec_ref(advanced) in text and was not in text + + +def test_a_name_two_tracked_spellings_answer_to_is_refused_as_untracked( + bump_leg_module, project, advanced, monkeypatch): + """An index holding `legs.yml` AND `LEGS.yml`, on a disk that holds one + `Legs.yml` for both: one rewrite cannot move two index entries, and + committing one would leave the other's old `@` in the tree. Not + resolved to either, so it is refused by the disk's name, as before.""" + was, _ = reference_both_legs(project) + blob = git("rev-parse", "HEAD:.github/workflows/legs.yml", + cwd=project).stdout.strip() + git("update-index", "--add", "--cacheinfo", + f"100644,{blob},.github/workflows/LEGS.yml", cwd=project) + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", + "Track a second spelling", cwd=project) + respelled(project / ".github" / "workflows", "legs.yml", "Legs.yml") + monkeypatch.setattr(bump_leg_module, "_same_file", ignoring_case, + raising=False) + + with pytest.raises(bump_leg_module.Refusal) as refused: + bump_leg_module.plan_workflows(project, f"{ORG}/{PROJECT}-spec", + advanced) + assert refused.value.code == "bump-leg-workflow-untracked" + assert ".github/workflows/Legs.yml 1 reference(s)" in refused.value.detail + assert was in (project / ".github" / "workflows" / "Legs.yml").read_text( + encoding="utf-8"), "nothing was written" + + +@pytest.mark.parametrize("twin", ["another-file", "a-hard-link"]) +@pytest.mark.parametrize("ignorecase", ["true", "false"]) +def test_a_second_spelling_that_is_a_second_name_is_refused_whatever_git_says( + project, scaffolded, advanced, tmp_path, ignorecase, twin): + """The filesystem is asked, not `core.ignorecase`. On a disk that keeps + case a `Legs.yml` beside the tracked `legs.yml` is another file, and + with the setting true git neither lists it as untracked nor will stage + it — so a tool that took git's word would rewrite it and leave it out of + the commit. It is refused by name, and the tracked `legs.yml` is not. + A HARD LINK is the same file under a second name the directory lists, + and the listing is what says so: a tracked name the walk lists is a file + of its own, so the link is not taken for a respelling of it either. + + RUNS ONLY WHERE THE DISK KEEPS CASE (Linux): one that ignores it cannot + hold the two names at once. + """ + if disk_ignores_case(tmp_path): + pytest.skip("needs a disk that keeps case (Linux): this one cannot " + "hold two spellings of one name") + was, _ = reference_both_legs(project) + branched(project) + git("config", "core.ignorecase", ignorecase, cwd=project) + workflows = project / ".github" / "workflows" + if twin == "a-hard-link": + os.link(workflows / "legs.yml", workflows / "Legs.yml") + else: + leave_untracked(project, "Legs.yml", spec_ref(was)) + if ignorecase == "true": + assert git("ls-files", "-o", "--", ".github/workflows", + cwd=project).stdout == "", ( + "this test only means something while git calls the twin " + "tracked") + before = found(project) + + result = bump(project, scaffolded, advanced) + assert result.returncode == 2, result.stderr + result.stdout + assert "bump-leg-workflow-untracked" in result.stderr + assert ".github/workflows/Legs.yml 1 reference(s)" in result.stderr + assert ".github/workflows/legs.yml" not in result.stderr + assert found(project) == before + + # --- --dry-run -------------------------------------------------------------- def test_dry_run_prints_the_move_and_changes_nothing(project, scaffolded,