diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py index 6a4e3316..1f7f5b85 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. @@ -265,6 +268,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. @@ -293,12 +346,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(): @@ -319,11 +386,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,