Default PR merge messages to the PR body #262
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/merge-default-message"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
fj pr mergesends an empty message body when--messageis omitted, and Forgejo does not fallback to the PR description the way its web merge dialog does. So the two merge paths produce
different commits from the same pull request, and the difference is the entire description. This
fleet merges through the CLI, so every PR body merged that way has been lost from
mainpermanently, silently: the merge succeeds and the commit looks normal.
Confirming test, both arms, on this repo about an hour apart with the same tool, style and account:
Fixes rasterstate/fleet#913, which counted the damage: ten consecutive merges on
fjord-os/repo-daemon-asyncall landed empty, and onrasterstate/fleetthe empty-bodied commitsare exactly the CLI ones.
What changed
merge()defaults the merge-commit message to the pull request body when--messageis notgiven. An explicit
--messagestill wins, including--message ""for a deliberately empty body.Pull::bodynow deserializes JSONnullto the default rather than relying on#[serde(default)]alone, so a bodyless PR produces an empty message instead of leaking a placeholder.
Testing
cargo fmt --all,cargo clippy --locked --all-targetsandcargo test --lockedpass. The newtest was run against the old merge behaviour and fails there, so it covers the defect rather than
the fix.
Open questions for review
Raising these because the author hit its context limit before answering them, and I pushed the
branch rather than lose the work.
merge(), so it applies to every style, not only squash. Squash iswhere the loss was measured. Whether a
mergeorrebase-mergecommit should also carry the PRbody is a real question and it is currently answered by construction rather than deliberately.
GETper merge that omits--message. That looks acceptable for aninteractive command, but nothing measured it.
The author answered the two open questions from the description before its context ran out.
Recording them here so they are not lost, and flagging that they are its account of its own change
rather than something I verified.
api::pull::merge, so it applies to every style and to--auto, because they all share that request path. That is the "answered by construction" Iflagged, now stated deliberately. Whether a
mergeorrebase-mergecommit should carry the PRbody is still a judgement call worth a reviewer's opinion; the measured loss was on squash.
GEThappens only whenMergeOptions.messageisNone. An explicit--message, including--message "", skips it.Both are checkable against the diff and should be checked rather than taken from this comment.
APPROVE
Head reviewed:
cc8f1a99dbStephen pushed this branch and opened PR #262 after codex-8 wrote the commit and hit its context limit. I reviewed the code as the first gate on that pushed head.
The behavior is covered at the request boundary, not only in a helper. I ran
cargo test --locked pull_merge_: 38 merge-related tests passed, includingpull_merge_defaults_message_to_pull_body,pull_merge_explicit_empty_message_overrides_pull_body_default,pull_merge_defaults_null_pull_body_to_empty_message, andpull_merge_auto_posts_merge_when_checks_succeed. I also rancargo test --locked pull_get_decodes_null_body_as_empty: 1 passed. If the defaulting were not affecting the actual merge endpoint request, the wiremockPOST /api/v1/repos/o/r/pulls/12/mergeexpectations containingMergeMessageFieldfrom the PR body would have missed and produced 404s. If--message ""still consulted the PR body, the test'sGETexpectation with.expect(0)would have failed and the POST body would not have matched the empty message. If JSON null were not handled, the decode test would have failed before merge.I proved the tests are not vacuous by temporarily reverting only the implementation hunks in
src/api/pull_core.rswhile leaving the new tests in place, then restoring them. With the old merge behavior,cargo test --locked pull_merge_failed three tests:pull_merge_defaults_message_to_pull_body,pull_merge_defaults_null_pull_body_to_empty_message, andpull_merge_auto_posts_merge_when_checks_succeed; each failed because the expected merge POST was not matched and wiremock returned 404. With the old deserialize behavior,cargo test --locked pull_get_decodes_null_body_as_emptyfailed withinvalid type: null, expected a string. After restoration, both commands passed again. If the tests were vacuous, the reverted implementation would have stayed green.The open question about styles is answered by construction and I think the construction is acceptable. CLI parsing turns
--style merge,--style rebase,--style rebase-merge, and--style squashinto the sameapi::pull::mergecall. In that function the default body is fetched beforeMergeBodyis assembled, andstyle.as_str()is only theDofield.--autois also justmerge_when_checks_succeedon the same body. I rancargo test --locked merge_, which includesmerge_style_as_str_maps_each_variant,merge_style_maps_to_api_style, andmerge_body_sets_auto_field_when_scheduling. If another path bypassedapi::pull::merge, these tests would not prove the end-to-end CLI path, so I also checkedsrc/cli/pr.rs: the merge command calls onlyapi::pull::mergeunless--disable-autois used, which callscancel_auto_mergeinstead. Applying the default to all merge styles is the right default for this CLI: an absent message should preserve the PR description rather than silently discard it, while--message ""remains the explicit opt-out. For rebase-like styles, this is no worse than the explicit message field users could already send; if Forgejo ignores it for a style, the behavior is still not silent data loss.The extra GET fires only when
MergeOptions.messageisNone. The code is a directmatch opts.message: theSomearm returns the supplied value and does not callget; theNonearm callsget(...).await?.body. The explicit-empty test proves theSome("")case by mounting a GET with.expect(0)and passing. If the extra GET also fired for explicit messages, that test would fail despite the final POST body being correct. One extra request is acceptable for an interactive merge command because it prevents permanent loss of the PR body, and explicit--message, including the empty string, keeps the old no-GET path reachable.The new GET fails closed.
get(client, owner, name, number).await?.bodyuses?, so a failed PR read aborts before the merge POST is sent. That is the right failure mode: proceeding with an empty message would recreate the silent loss this PR fixes. If the code failed open, there would be a fallback branch orunwrap_or_defaultaround the GET and a later POST withMergeMessageField: ""; there is none.I ran
cargo check --locked: passed. I did not push or merge from this throwaway clone.stephen referenced this pull request2026-09-06 18:18:29 +00:00