diff --git a/actions/git-commit/action.yml b/actions/git-commit/action.yml index 55667cd3..73bcb15d 100644 --- a/actions/git-commit/action.yml +++ b/actions/git-commit/action.yml @@ -135,11 +135,27 @@ runs: git fetch origin "$branch" if ! git rebase FETCH_HEAD; then # Two generated version bumps conflict on the version line whenever a - # concurrent release's bump landed while this one ran (i2mint/wads#83). - # The commit being replayed carries the version that was just - # published, so for the version files it is always the right side. - # Anything else in conflict is a real change from the other merge - # that a formatter hunk must not overwrite: abort, as before. + # concurrent release's bump landed while this one ran (i2mint/wads#83), + # OR a real PR merged and touched the same file(s) while this run's + # bump/format commit was in flight (i2mint/wads#89 regression: this + # branch used to resolve by `checkout --theirs`, taking the WHOLE + # conflicted file from the commit being replayed -- this job's own + # stale snapshot, captured before the concurrent merge landed. That + # silently discarded any real content the concurrent merge added, + # e.g. an entire `[tool.hatch.build]` table, keeping only the + # version-line change this job actually cared about). + # + # The correct resolution needs BOTH properties #83 and #89 each only + # got half of: the real, concurrently-merged file content must + # survive (take `--ours` -- during a rebase "ours" is the branch + # being rebased ONTO, i.e. the just-fetched upstream), and the + # version number this run just published to PyPI must still land in + # git (the whole reason #83 exists -- git must not end up recording + # an older version than what's live). So: keep upstream's content, + # then reapply only the version-line edit on top of it, reading the + # target version out of the replayed commit's side of the conflict + # (git conflict stage 3, ":3:") rather than trusting any + # particular whole-file side. conflicted=$(git diff --name-only --diff-filter=U) only_version_files=1 for path in $conflicted; do @@ -149,9 +165,30 @@ runs: esac done if [ -n "$conflicted" ] && [ "$only_version_files" = 1 ]; then - echo "Version file conflict with a concurrent bump; keeping the replayed version in: $conflicted" - git checkout --theirs -- $conflicted - git add -- $conflicted + echo "Version file conflict with a concurrent change; keeping upstream content, replaying only the version bump in: $conflicted" + for path in $conflicted; do + theirs_content=$(git show ":3:$path" 2>/dev/null || true) + new_version=$(printf '%s\n' "$theirs_content" \ + | grep -m1 -E '^[[:space:]]*version[[:space:]]*=' \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) + git checkout --ours -- "$path" + if [ -n "$new_version" ]; then + case "$path" in + pyproject.toml) + sed -i -E "s/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/version = \"$new_version\"/" "$path" + ;; + setup.cfg) + sed -i -E "s/^version = [0-9]+\.[0-9]+\.[0-9]+/version = $new_version/" "$path" + ;; + setup.py) + # No fixed version-field syntax to target safely; skip -- + # the next publish run's own bump step corrects it, and + # skipping never loses data (unlike the old --theirs). + ;; + esac + fi + git add -- "$path" + done if ! GIT_EDITOR=true git rebase --continue; then git rebase --abort || true echo "::error::Cannot replay this commit onto the current 'origin/$branch' even after resolving the version files. Resolve by hand." diff --git a/pyproject.toml b/pyproject.toml index 59437995..be7106dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,20 @@ wads-install-skills = "wads.install_skills:main" wads-secrets = "wads.secrets_cli:main" wads-licence-check = "wads.licence_check:main" +[tool.hatch.build] +# Without this, hatchling's sdist walk follows the .claude/skills/ -> +# wads/data/skills/ symlinks (issue #89): .claude sorts before wads +# alphabetically, so the real directory is marked "already seen" by the time +# the walk reaches its canonical wads/data/skills/ path and is skipped +# there -- the sdist (and the wheel built from it) end up with the skill +# content living only under .claude/skills/, not under the documented, +# pip-installable wads/data/skills/ path. skip-excluded-dirs stops the walk +# from entering .claude at all, so the canonical path is never pre-empted. +skip-excluded-dirs = true + +[tool.hatch.build.targets.sdist] +exclude = [".claude", ".github", "scrap"] + [tool.hatch.build.targets.wheel] packages = ["wads"] include = [ diff --git a/wads/tests/test_git_commit_push_retry.py b/wads/tests/test_git_commit_push_retry.py index 8a674377..b8d9fd61 100644 --- a/wads/tests/test_git_commit_push_retry.py +++ b/wads/tests/test_git_commit_push_retry.py @@ -283,7 +283,8 @@ def test_conflicting_version_bumps_keep_the_replayed_version( The concurrent commit is another CI bump of the same version line, which is exactly what a second release run leaves behind (i2mint/wads#83). The - replayed commit carries the version that was just published, so it wins. + version this run just published to PyPI must still land in git even + though the whole-file content resolves onto upstream (i2mint/wads#89). """ origin, clone = remote_and_clone _commit(clone, "pyproject.toml", 'version = "0.0.4"\n', "**CI** bump to 0.0.4") @@ -296,7 +297,7 @@ def test_conflicting_version_bumps_keep_the_replayed_version( result = run_push_step(clone) assert result.returncode == 0, result.stdout + result.stderr - assert "keeping the replayed version" in result.stdout + assert "replaying only the version bump" in result.stdout assert _subjects(clone, f"origin/{DEFAULT_BRANCH}")[:2] == [ "**CI** bump to 0.0.4", "**CI** bump to 0.0.3", @@ -307,6 +308,56 @@ def test_conflicting_version_bumps_keep_the_replayed_version( assert shown == 'version = "0.0.4"\n' assert not (clone / ".git" / "rebase-merge").exists() + def test_real_content_merged_concurrently_survives_the_version_bump_replay( + self, remote_and_clone, tmp_path + ): + """i2mint/wads#89: a real PR merge racing the bump commit must not lose data. + + Reproduces the exact incident: a PR adds a whole new TOML table to + ``pyproject.toml`` (here standing in for ``[tool.hatch.build]``) and + merges to the default branch while this run's version-bump-and-format + commit is in flight on the same file. The old ``checkout --theirs`` + resolution took the replayed commit's WHOLE file -- the version bump's + pre-merge snapshot -- silently deleting the new table. The fix must keep + the table (from upstream) AND still land the just-published version + number (from the replayed commit) in the same conflict resolution. + """ + origin, clone = remote_and_clone + # This run's own bump commit: only the version line changes (0.0.2 seed + # -> 0.0.3), no other content. + _commit( + clone, + "pyproject.toml", + 'version = "0.0.3"\n', + "**CI** bump to 0.0.3", + ) + # A real, concurrently-merged PR: adds a whole new table, no version + # bump (still 0.0.2, the seed version) -- exactly what #95 looked like + # landing while a stale bump job was mid-flight. + other = tmp_path / "real-merge" + _git("clone", str(origin), str(other), cwd=tmp_path) + _configure(other) + _commit( + other, + "pyproject.toml", + 'version = "0.0.2"\n\n[tool.hatch.build]\nskip-excluded-dirs = true\n', + "feat: add tool.hatch.build", + ) + _git("push", "origin", DEFAULT_BRANCH, cwd=other) + + result = run_push_step(clone) + + assert result.returncode == 0, result.stdout + result.stderr + shown = _git( + "show", f"origin/{DEFAULT_BRANCH}:pyproject.toml", cwd=clone + ).stdout + # Both properties at once: the real merge's table survives, AND the + # version this run published lands (not the pre-merge "0.0.2"). + assert "[tool.hatch.build]" in shown + assert "skip-excluded-dirs = true" in shown + assert 'version = "0.0.3"' in shown + assert not (clone / ".git" / "rebase-merge").exists() + def test_conflicting_replay_fails_cleanly(self, remote_and_clone, tmp_path): """A conflict outside the version files aborts rather than wedging the repo.