Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions actions/git-commit/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:<path>") 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
Expand All @@ -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."
Expand Down
14 changes: 14 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name> ->
# wads/data/skills/<name> 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/<name> 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 = [
Expand Down
55 changes: 53 additions & 2 deletions wads/tests/test_git_commit_push_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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",
Expand All @@ -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.

Expand Down
Loading