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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ jobs:

Fast-forward pushes are skipped. Each side is diffed against its own merge
base with the base branch, so a rebase onto a newer base is not mistaken for
changes. Inputs and outputs are described in [action.yml](action.yml).
changes. The summary line says how many commits the series had before and
after, and whether its base is the same, a descendant or an ancestor of the
old one, or diverged from it. A base branch that was itself rewritten, as a
parent in a stack, is recognized from its force pushes in the repository's
activity log. Inputs and outputs are described in [action.yml](action.yml).

`pull_request_target` gives fork PRs a token that can comment. It is safe here
because nothing from the PR runs: the checkout is the base branch, the action
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ branding:
color: purple
inputs:
token:
description: "Token with `pull-requests: write`."
description: "Token with `contents: read` and `pull-requests: write`."
default: ${{ github.token }}
before:
description: Head commit before the push.
Expand Down
34 changes: 29 additions & 5 deletions post-range-diff-comment.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# BEFORE, AFTER head commit before and after the push
# BASE_REF the pull request's base branch
# PR_NUMBER the pull request number
# GH_TOKEN token with pull-requests: write
# GH_TOKEN token with contents: read and pull-requests: write
# CREATION_FACTOR optional, passed to --creation-factor
# MAX_COMMENT_BYTES cap on the comment body
# POST_COMMENT "false" to render without posting
Expand Down Expand Up @@ -42,17 +42,39 @@ if git merge-base --is-ancestor "$BEFORE" "$AFTER"; then
fi
echo "forced=true" >>"$OUT"

# When the base branch was itself rewritten, as a parent in a stack, its old
# tips are unreachable from it, and a series left on one would seem to fork
# from further back. GitHub's activity log stands in for the reflog that
# `git merge-base --fork-point` reads.
base_tips=("origin/$BASE_REF")
if rewound="$(gh api --paginate --method GET --jq '.[].before' "repos/$REPO/activity" \
-f ref="refs/heads/$BASE_REF" -f activity_type=force_push -F per_page=100)"; then
for tip in $rewound; do
if git fetch --quiet --no-tags origin "$tip" 2>/dev/null; then
base_tips+=("$tip")
fi
done
else
echo "::warning::could not list force pushes of $BASE_REF; if it was rewritten, its old commits will be counted in the series"
fi

# Diff each side against where it forked from the base branch, so a rebase
# onto a newer base does not drag the base's new commits into the comparison.
# Fall back to the symmetric difference if either side is unrelated to base.
if old_base="$(git merge-base "origin/$BASE_REF" "$BEFORE")" &&
new_base="$(git merge-base "origin/$BASE_REF" "$AFTER")"; then
# Fall back to the symmetric difference if either side is unrelated to base,
# or if a tip of the base contains it: its range would be empty, which
# range-diff rejects.
if old_base="$(git merge-base "$BEFORE" "${base_tips[@]}")" &&
new_base="$(git merge-base "$AFTER" "${base_tips[@]}")" &&
[[ $old_base != "$(git rev-parse "$BEFORE^{commit}")" ]] &&
[[ $new_base != "$(git rev-parse "$AFTER^{commit}")" ]]; then
old_range="$old_base..$BEFORE"
new_range="$new_base..$AFTER"
read -r base_behind base_ahead < <(git rev-list --left-right --count "$old_base...$new_base")
else
echo "::warning::no merge base with $BASE_REF; using $BEFORE...$AFTER"
echo "::warning::no fork point from $BASE_REF; using $BEFORE...$AFTER"
old_range="$AFTER..$BEFORE"
new_range="$BEFORE..$AFTER"
old_base="" new_base="" base_behind=0 base_ahead=0
fi

args=(--no-color)
Expand All @@ -70,6 +92,8 @@ render() {
python3 "$HERE/render.py" \
--repo-url "$SERVER/$REPO" --before "$BEFORE" --after "$AFTER" \
--old-range "$pretty_old" --new-range "$pretty_new" \
--old-base "$old_base" --new-base "$new_base" \
--base-behind "$base_behind" --base-ahead "$base_ahead" \
--pushed-at "$PUSHED_AT" --max-bytes "$1" <"$WORK/range-diff.txt"
}
render "${MAX_COMMENT_BYTES:-65000}" >"$WORK/comment.md"
Expand Down
57 changes: 56 additions & 1 deletion render.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ def main(argv: list[str]) -> int:
required=True,
help="second range-diff argument, shown for reproduction",
)
ap.add_argument(
"--old-base",
required=True,
help="where the old series forked from the base, empty if unknown",
)
ap.add_argument(
"--new-base", required=True, help="where the new series forks from the base"
)
ap.add_argument(
"--base-behind",
type=int,
required=True,
help="commits in the old base missing from the new one",
)
ap.add_argument(
"--base-ahead",
type=int,
required=True,
help="commits in the new base missing from the old one",
)
ap.add_argument(
"--max-bytes",
type=int,
Expand All @@ -72,6 +92,10 @@ def main(argv: list[str]) -> int:
after=args.after,
old_range=args.old_range,
new_range=args.new_range,
old_base=args.old_base,
new_base=args.new_base,
base_behind=args.base_behind,
base_ahead=args.base_ahead,
max_bytes=args.max_bytes,
pushed_at=args.pushed_at,
)
Expand Down Expand Up @@ -118,18 +142,27 @@ def render(
new_range: str,
max_bytes: int,
pushed_at: str,
old_base: str,
new_base: str,
base_behind: int,
base_ahead: int,
) -> str:
counts = {op: sum(1 for p in pairs if p.op == op) for op in OP_LABEL}
tally = (
", ".join(f"{n} {OP_LABEL[op]}" for op, n in counts.items() if n)
or "no commits"
)
overview = [tally, describe_series(pairs)]
if old_base:
overview.append(
describe_base(old_base, new_base, base_behind, base_ahead, repo_url)
)
head = [
f"<!-- post-range-diff-comment before={before} after={after} -->",
"<details>",
(
f"<summary><b>range-diff</b> for force push {sha_html(before, repo_url)} → "
f"{sha_html(after, repo_url)} {relative_time(pushed_at)}: {tally}</summary>"
f"{sha_html(after, repo_url)} {relative_time(pushed_at)}: {'; '.join(overview)}</summary>"
),
"",
f"<sup>reproduce: <code>git range-diff {html.escape(old_range)} {html.escape(new_range)}</code></sup>",
Expand Down Expand Up @@ -190,6 +223,28 @@ def render_interdiffs(pairs: list[Pair]) -> str:
return "\n\n".join(blocks)


def describe_series(pairs: list[Pair]) -> str:
old = sum(p.lnum != "-" for p in pairs)
new = sum(p.rnum != "-" for p in pairs)
return commits(new) if old == new else f"{old} → {commits(new)}"


def describe_base(old: str, new: str, behind: int, ahead: int, repo_url: str) -> str:
if old == new:
return f"same base {sha_html(old, repo_url)}"
if not behind:
relation = f"descendant, {commits(ahead)} ahead"
elif not ahead:
relation = f"ancestor, {commits(behind)} behind"
else:
relation = f"diverged, {ahead} ahead and {behind} behind"
return f"base {sha_html(old, repo_url)} → {sha_html(new, repo_url)} ({relation})"


def commits(n: int) -> str:
return f"{n} commit" if n == 1 else f"{n} commits"


def number_width(pairs: list[Pair]) -> int:
return max((len(n) for p in pairs for n in (p.lnum, p.rnum)), default=1)

Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/basic.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!-- post-range-diff-comment before=deecb4b7a41ec39b40cdfc935ab28eb769c3aead after=031a72677e2ee12123ab2c8ef794bc3d0c521d8b -->
<details>
<summary><b>range-diff</b> for force push <a href="https://github.com/fungi-protocol/post-range-diff-comment/commit/deecb4b7a41ec39b40cdfc935ab28eb769c3aead">deecb4b7a4</a> → <a href="https://github.com/fungi-protocol/post-range-diff-comment/commit/031a72677e2ee12123ab2c8ef794bc3d0c521d8b">031a72677e</a> <relative-time datetime="2026-09-17T00:10:17Z">September 17, 2026 00:10 UTC</relative-time>: 1 modified, 1 unchanged, 1 dropped, 1 added</summary>
<summary><b>range-diff</b> for force push <a href="https://github.com/fungi-protocol/post-range-diff-comment/commit/deecb4b7a41ec39b40cdfc935ab28eb769c3aead">deecb4b7a4</a> → <a href="https://github.com/fungi-protocol/post-range-diff-comment/commit/031a72677e2ee12123ab2c8ef794bc3d0c521d8b">031a72677e</a> <relative-time datetime="2026-09-17T00:10:17Z">September 17, 2026 00:10 UTC</relative-time>: 1 modified, 1 unchanged, 1 dropped, 1 added; 3 commits; base <a href="https://github.com/fungi-protocol/post-range-diff-comment/commit/1111111111111111111111111111111111111111">1111111111</a> → <a href="https://github.com/fungi-protocol/post-range-diff-comment/commit/2222222222222222222222222222222222222222">2222222222</a> (descendant, 12 commits ahead)</summary>

<sup>reproduce: <code>git range-diff 9b127fb^..deecb4b 9b127fb^..031a726</code></sup>

Expand Down
69 changes: 62 additions & 7 deletions tests/test_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,27 @@ def git(self, *args, cwd=None):
["git", *args], cwd=cwd or self.origin, env=self.env, text=True
).strip()

def commit(self, content, message, amend=False):
(self.origin / "file").write_text(content)
self.git("add", "file")
def commit(self, content, message, amend=False, path="file"):
(self.origin / path).write_text(content)
self.git("add", path)
self.git("commit", "-q", "-m", message, *(["--amend"] if amend else []))
return self.git("rev-parse", "HEAD")

def run_action(self, *, shallow=False, packaged=False, forced=True):
def run_action(
self,
*,
shallow=False,
packaged=False,
forced=True,
base_ref="main",
rewound=(),
):
# Stands in for the activity log: prints the old tips of the base branch.
stub = self.root / "stub"
stub.mkdir()
gh = stub / "gh"
gh.write_text("#!/bin/sh\n" + "".join(f"echo {tip}\n" for tip in rewound))
gh.chmod(0o755)
checkout = self.root / "checkout"
self.git(
"clone",
Expand All @@ -57,13 +71,14 @@ def run_action(self, *, shallow=False, packaged=False, forced=True):
self.env,
BEFORE=self.before,
AFTER=self.after,
BASE_REF="main",
BASE_REF=base_ref,
PR_NUMBER="1",
GITHUB_REPOSITORY="test/repo",
GITHUB_SERVER_URL="https://github.com",
GITHUB_OUTPUT=str(output),
GITHUB_STEP_SUMMARY=str(summary),
POST_COMMENT="false",
PATH=f"{stub}{os.pathsep}{os.environ['PATH']}",
)
command = (
[PACKAGE]
Expand All @@ -86,12 +101,52 @@ def run_action(self, *, shallow=False, packaged=False, forced=True):
self.assertIn("<b>range-diff</b>", result.stdout)
self.assertIn(self.before, result.stdout)
self.assertIn(self.after, result.stdout)
self.assertEqual(summary.read_text(), result.stdout)
self.assertTrue(result.stdout.endswith(summary.read_text()))
else:
self.assertFalse(summary.exists())
return result.stdout

def test_force_push_full_checkout(self):
self.run_action()
self.assertIn("; 1 commit; same base <a", self.run_action())

def test_rebase_onto_newer_base(self):
self.git("checkout", "-q", "main")
self.commit("other\n", "advance base", path="other")
self.git("checkout", "-q", "topic")
self.git("rebase", "-q", "main")
self.after = self.git("rev-parse", "HEAD")
self.assertIn("(descendant, 1 commit ahead)", self.run_action())

def test_rewritten_parent_in_a_stack(self):
self.git("checkout", "-q", "-b", "child")
self.before = self.commit("child\n", "child", path="child")
self.git("checkout", "-q", "topic")
old_parent = self.after
new_parent = self.commit("newer\n", "change", amend=True)
self.git("checkout", "-q", "child")
self.git("rebase", "-q", "--onto", "topic", "child~1")
self.after = self.git("rev-parse", "HEAD")
collected = "0123456789" * 4
out = self.run_action(base_ref="topic", rewound=[collected, old_parent])
self.assertIn(f"git range-diff {old_parent[:7]}", out)
self.assertIn("1 unchanged; 1 commit; base <a", out)
self.assertIn(f'{new_parent}">', out)
self.assertIn("(diverged, 1 ahead and 1 behind)", out)

def test_unrelated_base_is_not_described(self):
self.git("checkout", "-q", "main")
self.commit("rewritten\n", "base", amend=True, path="other")
self.git("checkout", "-q", "topic")
self.git("rebase", "-q", "--onto", "main", "topic~1")
self.after = self.git("rev-parse", "HEAD")
self.assertNotIn("base <a", self.run_action())

def test_series_an_old_base_tip_contained(self):
self.git("checkout", "-q", "-b", "child")
self.before = self.commit("child\n", "child", path="child")
self.git("branch", "merged", self.before)
self.after = self.commit("child again\n", "child", amend=True, path="child")
self.run_action(base_ref="topic", rewound=[self.before])

def test_force_push_shallow_checkout(self):
self.run_action(shallow=True)
Expand Down
20 changes: 20 additions & 0 deletions tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
REPO = "https://github.com/fungi-protocol/post-range-diff-comment"
BEFORE = "deecb4b7a41ec39b40cdfc935ab28eb769c3aead"
AFTER = "031a72677e2ee12123ab2c8ef794bc3d0c521d8b"
OLD_BASE = "1111111111111111111111111111111111111111"
NEW_BASE = "2222222222222222222222222222222222222222"


def render_basic(max_bytes=65000):
Expand All @@ -22,6 +24,10 @@ def render_basic(max_bytes=65000):
new_range="9b127fb^..031a726",
max_bytes=max_bytes,
pushed_at="2026-09-17T00:10:17Z",
old_base=OLD_BASE,
new_base=NEW_BASE,
base_behind=0,
base_ahead=12,
)


Expand Down Expand Up @@ -67,6 +73,20 @@ def test_golden(self):
expected = (FIXTURES / "basic.md").read_text()
self.assertEqual(render_basic(), expected)

def test_series_length(self):
grown = render.parse("1: aaaa = 1: aaaa a\n-: ---- > 2: bbbb b\n")
self.assertEqual(render.describe_series(grown), "1 → 2 commits")
self.assertEqual(render.describe_series(grown[:1]), "1 commit")

def test_base_relation(self):
def base(new, behind, ahead):
return render.describe_base(OLD_BASE, new, behind, ahead, REPO)

self.assertTrue(base(OLD_BASE, 0, 0).startswith("same base <a"))
self.assertIn("(descendant, 1 commit ahead)", base(NEW_BASE, 0, 1))
self.assertIn("(ancestor, 2 commits behind)", base(NEW_BASE, 2, 0))
self.assertIn("(diverged, 3 ahead and 2 behind)", base(NEW_BASE, 2, 3))

def test_subject_is_escaped(self):
pairs = render.parse("1: aaaa = 1: aaaa <script>&\n")
out = render.render_listing(pairs, REPO)
Expand Down
Loading