From c27d368b92f321e6f91704f554dccbc18df5b075 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Thu, 5 Mar 2026 12:14:59 -0500 Subject: [PATCH 1/6] Extended checks and docs for proper commit message format and edition. Thanks to Tim Schilling for the review. Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> --- .github/pull_request_template.md | 2 +- .github/workflows/check_commit_messages.yml | 36 ++++++++++++++ .../contributing/committing-code.txt | 36 +++++++++----- .../writing-code/working-with-git.txt | 49 ++++++++++++++++--- 4 files changed, 103 insertions(+), 20 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7efa2f1384e5..0d30fbbced01 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -16,7 +16,7 @@ Provide a concise overview of the issue or rationale behind the proposed changes - [ ] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/). - [ ] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)). - [ ] This PR targets the `main` branch. -- [ ] The commit message is written in past tense, mentions the ticket number, and ends with a period. +- [ ] The commit message is written in past tense, mentions the ticket number, and ends with a period (see [guidelines](https://docs.djangoproject.com/en/dev/internals/contributing/committing-code/#committing-guidelines)). - [ ] I have checked the "Has patch" ticket flag in the Trac system. - [ ] I have added or updated relevant tests. - [ ] I have added or updated relevant docs, including release notes if applicable. diff --git a/.github/workflows/check_commit_messages.yml b/.github/workflows/check_commit_messages.yml index 32f155846452..590cc58de3f5 100644 --- a/.github/workflows/check_commit_messages.yml +++ b/.github/workflows/check_commit_messages.yml @@ -68,3 +68,39 @@ jobs: fi echo "✅ All commits have the required prefix." + + check-commit-suffix: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Fetch relevant branches + run: | + git fetch origin $BASE:base + git fetch origin pull/${{ github.event.pull_request.number }}/head:pr + + - name: Check commit messages suffix + run: | + COMMITS=$(git rev-list base..pr) + echo "Checking commit messages for trailing period" + FAIL=0 + for SHA in $COMMITS; do + MSG=$(git log -1 --pretty=%s $SHA) + echo "Checking commit $SHA: $MSG" + if [[ "${MSG: -1}" != "." ]]; then + echo "❌ Commit $SHA must end with a period." + echo "Refer to the guidelines linked in the PR checklist for commit message format." + echo "For how to rewrite commit messages, see https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/working-with-git/#editing-commit-messages" + FAIL=1 + fi + done + + if [[ $FAIL -eq 1 ]]; then + echo "One or more commit messages are missing a trailing period." + exit 1 + fi + + echo "✅ All commits have the required trailing period." diff --git a/docs/internals/contributing/committing-code.txt b/docs/internals/contributing/committing-code.txt index 3cf25549267c..413745e6442e 100644 --- a/docs/internals/contributing/committing-code.txt +++ b/docs/internals/contributing/committing-code.txt @@ -3,8 +3,10 @@ Committing code =============== This section is addressed to the mergers and to anyone interested in knowing -how code gets committed into Django. If you're a community member who wants to -contribute code to Django, look at +how code gets committed into Django. The :ref:`committing-guidelines` apply +to all contributors, with or without commit rights. + +If you're a community member who wants to contribute code to Django, look at :doc:`/internals/contributing/writing-code/working-with-git` instead. .. _handling-pull-requests: @@ -102,8 +104,8 @@ community, getting work done, and having a usable commit history. Committing guidelines ===================== -In addition, please follow the following guidelines when committing code to -Django's Git repository: +These guidelines apply to all commits to Django's Git repository, whether +submitted by a contributor via a pull request or landed directly by a merger: * Never change the published history of ``django/django`` branches by force pushing. If you absolutely must (for security reasons for example), first @@ -119,16 +121,27 @@ Django's Git repository: discussions immediately, so you may have to wait a couple of days before getting a response. -* Write detailed commit messages in the past tense, not present tense. +* Write detailed commit messages in the past tense, not present tense, and + end the subject line with a period. - * Good: "Fixed Unicode bug in RSS API." - * Bad: "Fixes Unicode bug in RSS API." - * Bad: "Fixing Unicode bug in RSS API." + * Correct: "Fixed Unicode bug in RSS API." + * Incorrect: "Fixes Unicode bug in RSS API." (present tense) + * Incorrect: "Fixing Unicode bug in RSS API." ("-ing" form) + * Incorrect: "Fixed Unicode bug in RSS API" (missing trailing period) The commit message should be in lines of 72 chars maximum. There should be a subject line, separated by a blank line and then paragraphs of 72 char - lines. The limits are soft. For the subject line, shorter is better. In the - body of the commit message more detail is better than less: + lines. The limits are soft. For the subject line, shorter is better. + + In the body of the commit message more detail is better than less, and should + explain *why* the change was made, not *what* was changed or *how*. The code + itself shows what changed; the commit message should provide the context and + reasoning that the code cannot. + + Credit the contributors in the commit message: "Thanks A for the report and B + for review." Use git's `Co-Authored-By`_ as appropriate. + + For example: .. code-block:: none @@ -138,8 +151,7 @@ Django's Git repository: specific tasks. Added guidelines of how to use Git, GitHub, and how to use pull request together with Trac instead. - Credit the contributors in the commit message: "Thanks A for the report and B - for review." Use git's `Co-Authored-By`_ as appropriate. + Thanks to Full Name for the report, and to Reviewer for reviews. .. _Co-Authored-By: https://docs.github.com/en/pull-requests/committing-changes-to-your-project/creating-and-editing-commits/creating-a-commit-with-multiple-authors diff --git a/docs/internals/contributing/writing-code/working-with-git.txt b/docs/internals/contributing/writing-code/working-with-git.txt index 15ab7054aa55..3691b9cf2fa8 100644 --- a/docs/internals/contributing/writing-code/working-with-git.txt +++ b/docs/internals/contributing/writing-code/working-with-git.txt @@ -111,7 +111,7 @@ necessary: .. code-block:: shell - git commit -m 'Added two more tests for edge cases' + git commit -m 'Added two more tests for edge cases.' Publishing work --------------- @@ -163,11 +163,46 @@ will deal with your pull request has only two options: merge it or close it. For this reason, it isn't useful to make a pull request until the code is ready for merging -- or sufficiently close that a merger will finish it themselves. +.. _editing-commit-messages: + +Editing commit messages +----------------------- + +To change the message of the most recent commit, run: + +.. code-block:: shell + + git commit --amend + +This opens an editor with the current commit message. Edit it, save, and close +to update the commit. + +To change the message of an earlier commit, use the "reword" option in +interactive rebase. For example, to reword one of the last three commits: + +.. code-block:: shell + + git rebase -i HEAD~3 + +This opens an editor listing the three commits, each prefixed with the word +"pick". Change "pick" to "reword" (or "r") on the line of the commit you want +to change, then save and close. A new editor will open for each commit marked +as "reword", allowing you to update the message. + +See :ref:`committing-guidelines` for the required commit message format. + +After rewriting a commit that has already been pushed to GitHub, you will need +to force-push your branch: + +.. code-block:: shell + + git push --force-with-lease origin ticket_xxxxx + Rebasing branches ----------------- -In the example above, you created two commits, the "Fixed ticket_xxxxx" commit -and "Added two more tests" commit. +In the example above, you created two commits, the "Fixed #xxxxx -- ..." +commit and the "Added two more tests ..." commit. We do not want to have the entire history of your working process in your repository. Your commit "Added two more tests" would be unhelpful noise. @@ -208,7 +243,7 @@ the changes: .. code-block:: shell - git push -f origin ticket_xxxxx + git push --force-with-lease origin ticket_xxxxx Note that this will rewrite history of ticket_xxxxx - if you check the commit hashes before and after the operation at GitHub you will notice that the commit @@ -264,10 +299,10 @@ of: .. code-block:: text - Made changes asked in review by + Made changes asked in review by . - - Fixed whitespace errors in foobar - - Reworded the docstring of bar() + - Fixed whitespace errors in foobar. + - Reworded the docstring of bar(). Finally, push your work back to your GitHub repository. Since you didn't touch the public commits during the rebase, you should not need to force-push: From 07c38764db1ba6a39ff0b3b224288e13c304f1c9 Mon Sep 17 00:00:00 2001 From: Unai Loidi Date: Wed, 22 Oct 2025 12:22:47 +0200 Subject: [PATCH 2/6] Fixed #36679 -- Fixed Basque date formats to use parenthetical declension suffixes. Basque (eu) grammar requires conditional suffixes on years and day articles that depend on the final sound of the preceding word. Since Django's format strings are static, the CLDR parenthetical convention ("(e)ko" instead of "ko") is used to express the optionality. Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> --- django/conf/locale/eu/formats.py | 6 +++--- tests/i18n/tests.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/django/conf/locale/eu/formats.py b/django/conf/locale/eu/formats.py index 61b16fbc6f69..e707f931c70c 100644 --- a/django/conf/locale/eu/formats.py +++ b/django/conf/locale/eu/formats.py @@ -2,10 +2,10 @@ # # The *_FORMAT strings use the Django date format syntax, # see https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date -DATE_FORMAT = r"Y\k\o N j\a" +DATE_FORMAT = r"Y(\e)\k\o N\k j" TIME_FORMAT = "H:i" -DATETIME_FORMAT = r"Y\k\o N j\a, H:i" -YEAR_MONTH_FORMAT = r"Y\k\o F" +DATETIME_FORMAT = r"Y(\e)\k\o N\k j, H:i" +YEAR_MONTH_FORMAT = r"Y(\e)\k\o F" MONTH_DAY_FORMAT = r"F\r\e\n j\a" SHORT_DATE_FORMAT = "Y-m-d" SHORT_DATETIME_FORMAT = "Y-m-d H:i" diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py index e593d97cba14..91e7c95f67a0 100644 --- a/tests/i18n/tests.py +++ b/tests/i18n/tests.py @@ -1179,6 +1179,26 @@ def test_uncommon_locale_formats(self): with translation.override(locale, deactivate=True): self.assertEqual(expected, format_function(*format_args)) + def test_basque_date_formats(self): + # Basque locale uses parenthetical suffixes for conditional declension: + # (e)ko for years and declined month/day forms. + with translation.override("eu", deactivate=True): + self.assertEqual(date_format(self.d), "2009(e)ko abe.k 31") + self.assertEqual( + date_format(self.dt, "DATETIME_FORMAT"), + "2009(e)ko abe.k 31, 20:50", + ) + self.assertEqual( + date_format(self.d, "YEAR_MONTH_FORMAT"), "2009(e)ko abendua" + ) + self.assertEqual(date_format(self.d, "MONTH_DAY_FORMAT"), "abenduaren 31a") + # Day 11 (hamaika in Basque) ends in 'a' as a word, but the + # numeral form does not, so appending 'a' is correct here. + self.assertEqual( + date_format(datetime.date(2009, 12, 11), "MONTH_DAY_FORMAT"), + "abenduaren 11a", + ) + def test_sub_locales(self): """ Check if sublocales fall back to the main locale From d469883546e954c8b51889d8ffc21f0ff9d71d07 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:30:36 -0300 Subject: [PATCH 3/6] Added python script suitable for using as prepare-commit-msg git hook. --- scripts/prepare_commit_msg.py | 115 ++++++++++++++++++++++++++++++ scripts/tests.py | 130 ++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100755 scripts/prepare_commit_msg.py create mode 100644 scripts/tests.py diff --git a/scripts/prepare_commit_msg.py b/scripts/prepare_commit_msg.py new file mode 100755 index 000000000000..bcb5b5faf77f --- /dev/null +++ b/scripts/prepare_commit_msg.py @@ -0,0 +1,115 @@ +#! /usr/bin/env python + +""" +prepare-commit-msg hook for Django's repository. + +Adjusts commit messages on any branch: +- Ensures the summary line ends with a period. + +Additionally, on stable branches: +- Adds the [A.B.x] branch prefix if missing. +- Adds "Backport of from main." when cherry-picking. + +To install: + 1. Ensure the folder `.git/hooks` exists. + 2. Create an executable file `.git/hooks/prepare-commit-msg` with content: + +#!/bin/sh +exec python scripts/prepare_commit_msg.py "$@" + +""" + +import os +import subprocess +import sys + + +def run(cmd): + return subprocess.run(cmd, capture_output=True, text=True).stdout.strip() + + +def process_commit_message(lines, branch, cherry_sha=None): + """Adjust commit message lines for a potential backport. + + - Separates body lines from trailing git comment lines. + - Ensure all lines ends with a period. + - On stable branches, adds the [A.B.x] prefix to the first line if missing. + - If cherry_sha is provided, appends "Backport of from main." to + the body if not already present. + + Returns the modified lines (body + comments). + + """ + # Separate body lines from trailing git comment lines. + comment_start = len(lines) + for i in range(len(lines) - 1, -1, -1): + if lines[i].startswith("#"): + comment_start = i + elif lines[i].strip(): + break + + body_lines = lines[:comment_start] + comment_lines = lines[comment_start:] + + # Strip leading and trailing blank lines from the body. + while body_lines and not body_lines[0].strip(): + body_lines.pop(0) + while body_lines and not body_lines[-1].strip(): + body_lines.pop() + + # Nothing to do if the body is empty. + if not body_lines: + return lines + + summary = body_lines[0].strip() + + # Ensure summary ends with a period. + if not summary.endswith("."): + summary += "." + + # On stable branches, add the [A.B.x] prefix if missing. + prefix = None + if branch.startswith("stable/"): + version = branch[len("stable/") :] + prefix = f"[{version}] " + if not summary.startswith(prefix): + summary = prefix + summary + + # Capitalize the first character of the summary text (after any prefix). + offset = len(prefix) if prefix else 0 + summary = summary[:offset] + summary[offset].upper() + summary[offset + 1 :] + + body_lines[0] = summary + "\n" + + # Add "Backport of from main." if cherry-picking and not present. + if cherry_sha: + backport_note = f"Backport of {cherry_sha} from main." + if backport_note not in "".join(body_lines): + # Strip trailing blank lines, then append note with separator. + while body_lines and not body_lines[-1].strip(): + body_lines.pop() + body_lines.append("\n") + body_lines.append(backport_note + "\n") + + return body_lines + comment_lines + + +if __name__ == "__main__": + msg_path = sys.argv[1] + + with open(msg_path, encoding="utf-8") as f: + lines = f.readlines() + + branch = run(["git", "branch", "--show-current"]) + + cherry_sha = None + git_dir = run(["git", "rev-parse", "--git-dir"]) + cherry_pick_head_path = os.path.join(git_dir, "CHERRY_PICK_HEAD") + if os.path.exists(cherry_pick_head_path): + with open(cherry_pick_head_path, encoding="utf-8") as f: + cherry_sha = f.read().strip() + + result = process_commit_message(lines, branch, cherry_sha) + + with open(msg_path, "w", encoding="utf-8") as f: + f.writelines(result) diff --git a/scripts/tests.py b/scripts/tests.py new file mode 100644 index 000000000000..b36c2937039c --- /dev/null +++ b/scripts/tests.py @@ -0,0 +1,130 @@ +#! /usr/bin/env python + +""" +Tests for scripts utilities. + +Run from within the scripts/ folder with: + +$ python -m unittest tests.py + +Or from the repo root with: + +$ PYTHONPATH=scripts/ python -m unittest scripts/tests.py +""" + +import unittest + +from prepare_commit_msg import process_commit_message + + +class ProcessCommitMessageTests(unittest.TestCase): + def test_non_stable_branch_no_prefix_added(self): + lines = ["Fixed #123 -- Added a feature.\n"] + result = process_commit_message(lines, "main") + self.assertNotIn("[", result[0].split("--")[0]) + + def test_non_stable_branch_period_added(self): + lines = ["Fixed #123 -- Added a feature\n"] + result = process_commit_message(lines, "main") + self.assertIs(result[0].rstrip("\n").endswith("."), True) + + def test_non_stable_branch_with_period_unchanged(self): + lines = ["Fixed #123 -- Added a feature.\n"] + result = process_commit_message(lines, "main") + self.assertEqual(result, lines) + + def test_empty_body_unchanged(self): + lines = ["# This is a comment.\n"] + result = process_commit_message(lines, "stable/5.2.x") + self.assertEqual(result, lines) + + def test_only_blank_lines_unchanged(self): + lines = ["\n", "\n"] + result = process_commit_message(lines, "stable/5.2.x") + self.assertEqual(result, lines) + + def test_adds_stable_prefix(self): + lines = ["Fixed #123 -- Added a feature.\n"] + result = process_commit_message(lines, "stable/5.2.x") + self.assertEqual(result[0], "[5.2.x] Fixed #123 -- Added a feature.\n") + + def test_does_not_double_add_prefix(self): + lines = ["[5.2.x] Fixed #123 -- Added a feature.\n"] + result = process_commit_message(lines, "stable/5.2.x") + self.assertEqual(result[0], "[5.2.x] Fixed #123 -- Added a feature.\n") + + def test_summary_leading_whitespace_no_double_space_before_prefix(self): + lines = [" fixed #123 -- Added a feature.\n"] + result = process_commit_message(lines, "stable/5.2.x") + self.assertEqual(result[0], "[5.2.x] Fixed #123 -- Added a feature.\n") + + def test_capitalizes_first_letter(self): + lines = ["fixed #123 -- Added a feature.\n"] + result = process_commit_message(lines, "main") + self.assertEqual(result[0], "Fixed #123 -- Added a feature.\n") + + def test_capitalizes_first_letter_after_existing_prefix(self): + lines = ["[5.2.x] fixed #123 -- Added a feature.\n"] + result = process_commit_message(lines, "stable/5.2.x") + self.assertTrue(result[0].startswith("[5.2.x] Fixed")) + + def test_adds_trailing_period(self): + lines = ["Fixed #123 -- Added a feature\n"] + result = process_commit_message(lines, "stable/5.2.x") + self.assertIs(result[0].rstrip("\n").endswith("."), True) + + def test_does_not_double_add_trailing_period(self): + lines = ["Fixed #123 -- Added a feature.\n"] + result = process_commit_message(lines, "stable/5.2.x") + self.assertIs(result[0].rstrip("\n").endswith(".."), False) + + def test_adds_backport_note(self): + sha = "abc123def456" + lines = ["Fixed #123 -- Added a feature.\n"] + result = process_commit_message(lines, "stable/5.2.x", cherry_sha=sha) + self.assertIn(f"Backport of {sha} from main.\n", result) + + def test_does_not_double_add_backport_note(self): + sha = "abc123def456" + lines = [ + "Fixed #123 -- Added a feature.\n", + "\n", + f"Backport of {sha} from main.\n", + ] + result = process_commit_message(lines, "stable/5.2.x", cherry_sha=sha) + self.assertEqual(len([line for line in result if "Backport" in line]), 1) + + def test_backport_note_separated_by_blank_line(self): + sha = "abc123def456" + lines = ["Fixed #123 -- Added a feature.\n"] + result = process_commit_message(lines, "stable/5.2.x", cherry_sha=sha) + note_idx = next(i for i, l in enumerate(result) if f"Backport of {sha}" in l) + self.assertEqual(result[note_idx - 1], "\n") + + def test_git_comments_preserved_at_end(self): + sha = "abc123def456" + lines = [ + "Fixed #123 -- Added a feature.\n", + "# Please enter the commit message.\n", + "# Changes to be committed:\n", + ] + result = process_commit_message(lines, "stable/5.2.x", cherry_sha=sha) + self.assertEqual(result[-2], "# Please enter the commit message.\n") + self.assertEqual(result[-1], "# Changes to be committed:\n") + + def test_prefix_and_period_and_backport_combined(self): + sha = "abc123def456" + lines = ["Fixed #123 -- Added a feature\n"] + result = process_commit_message(lines, "stable/5.2.x", cherry_sha=sha) + self.assertEqual(result[0], "[5.2.x] Fixed #123 -- Added a feature.\n") + self.assertIn(f"Backport of {sha} from main.\n", result) + + def test_no_cherry_sha_no_backport_note(self): + lines = ["Fixed #123 -- Added a feature.\n"] + result = process_commit_message(lines, "stable/5.2.x", cherry_sha=None) + self.assertNotIn("Backport of", "".join(result)) + + def test_leading_blank_lines_stripped(self): + lines = ["\n", "Fixed #123 -- Added a feature.\n"] + result = process_commit_message(lines, "stable/5.2.x") + self.assertEqual(result[0], "[5.2.x] Fixed #123 -- Added a feature.\n") From f8665b1a7ff5e98d84f66ad0e958c3f175aa5d8b Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:31:01 -0300 Subject: [PATCH 4/6] Extended committing code docs to add detailed instructions for backports. Thanks to Jacob Walls for the original idea and the review. --- .../contributing/committing-code.txt | 84 +++++++++++++++---- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/docs/internals/contributing/committing-code.txt b/docs/internals/contributing/committing-code.txt index 413745e6442e..fbfd6cf779ff 100644 --- a/docs/internals/contributing/committing-code.txt +++ b/docs/internals/contributing/committing-code.txt @@ -155,17 +155,14 @@ submitted by a contributor via a pull request or landed directly by a merger: .. _Co-Authored-By: https://docs.github.com/en/pull-requests/committing-changes-to-your-project/creating-and-editing-commits/creating-a-commit-with-multiple-authors -* For commits to a branch, prefix the commit message with the branch name. - For example: "[1.4.x] Fixed #xxxxx -- Added support for mind reading." - * Limit commits to the most granular change that makes sense. This means, use frequent small commits rather than infrequent large commits. For example, if implementing feature X requires a small change to library Y, first commit the change to library Y, then commit feature X in a separate commit. This goes a *long way* in helping everyone follow your changes. -* Separate bug fixes from feature changes. Bugfixes may need to be backported - to the stable branch, according to :ref:`supported-versions-policy`. +* Separate bug fixes from feature changes. Bugfixes may need to be + :ref:`backported `. * If your commit closes a ticket in the Django `ticket tracker`_, begin your commit message with the text "Fixed #xxxxx", where "xxxxx" is the @@ -190,33 +187,84 @@ submitted by a contributor via a pull request or landed directly by a merger: is the number of the ticket your commit references. This will automatically post a comment to the appropriate ticket. -* Write commit messages for backports using this pattern: +.. _backports: - .. code-block:: none +Backports +========= - [] Fixed -- +Bug fix backports to stable branches are done exclusively by mergers, following +the :ref:`supported-versions-policy`. A backport consists of cherry-picking a +commit from ``main`` onto the target ``stable/A.B.x`` branch. - Backport of from . +A backport commit must include two things beyond the original commit message: - For example: +* A prefix ``[A.B.x]`` on the subject line, where ``A.B.x`` is the name of the + ``stable/A.B.x`` branch being targeted. - .. code-block:: none +* A suffix ``Backport of from main.`` line in the commit body, pointing + to the original commit hash. + +For example: + +.. code-block:: none [1.3.x] Fixed #17028 -- Changed diveintopython.org -> diveintopython.net. Backport of 80c0cbf1c97047daed2c5b41b296bbc56fe1d7e3 from main. - There's a `script on the wiki - `_ to - automate this. +If the backport also fixes a regression, add a line identifying the commit that +introduced it: - If the commit fixes a regression, include this in the commit message: - - .. code-block:: none +.. code-block:: none Regression in 6ecccad711b52f9273b1acb07a57d3f806e93928. - (use the commit hash where the regression was introduced). +There are three ways to do a backport, depending on your workflow: + +**Option 1: fully manual** + +Cherry-pick the commit onto the stable branch, then amend the commit message to +add the required prefix and backport note: + +.. code-block:: shell + + git cherry-pick + git commit --amend + +If the cherry-pick produces conflicts, resolve them, stage the changes, then +run ``git cherry-pick --continue`` before amending. This option requires no +setup but relies entirely on remembering to format the message correctly. + +**Option 2: using the helper script in the repo** + +The ``scripts/backport.sh`` Bash script automates the cherry-pick and rewrites +the commit message with the correct prefix and backport note. Run it from the +target stable branch: + +.. code-block:: shell + + bash scripts/backport.sh + +This is straightforward for clean cherry-picks, but if conflicts are produced, +you will need to resolve them manually and add the prefix and backport note to +the commit message yourself. + +**Option 3: using the** ``prepare-commit-msg`` **git hook** + +The ``scripts/prepare_commit_msg.py`` Python script can be installed as a +``prepare-commit-msg`` git hook. It automatically adds the ``[A.B.x]`` prefix +to the subject line, appends ``Backport of from main.`` to the commit +body, and ensures the subject line ends with a period, even when the +cherry-pick produces conflicts. To install it, create an executable file +``.git/hooks/prepare-commit-msg`` containing: + +.. code-block:: sh + + #!/bin/sh + exec python scripts/prepare_commit_msg.py "$@" + +Once installed, a plain ``git cherry-pick `` on a stable branch is +sufficient; the hook handles the message formatting in all cases. Reverting commits ================= From 864850b20f7ef89ed2f6bd8baf1a45acc9245a6c Mon Sep 17 00:00:00 2001 From: James Bligh Date: Fri, 6 Mar 2026 15:47:08 +0000 Subject: [PATCH 5/6] Fixed #36968 -- Improved error message when collectstatic can't find a referenced file. --- .../management/commands/collectstatic.py | 6 +++- django/contrib/staticfiles/storage.py | 28 +++++++++++++++---- tests/staticfiles_tests/test_storage.py | 16 ++++++++--- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/django/contrib/staticfiles/management/commands/collectstatic.py b/django/contrib/staticfiles/management/commands/collectstatic.py index 1c3f8ba26d53..6fcff46d93af 100644 --- a/django/contrib/staticfiles/management/commands/collectstatic.py +++ b/django/contrib/staticfiles/management/commands/collectstatic.py @@ -154,7 +154,11 @@ def collect(self): # Add a blank line before the traceback, otherwise it's # too easy to miss the relevant part of the error message. self.stderr.write() - raise processed + # Re-raise exceptions as CommandError and display notes. + message = str(processed) + if hasattr(processed, "__notes__"): + message += "\n" + "\n".join(processed.__notes__) + raise CommandError(message) from processed if processed: self.log( "Post-processed '%s' as '%s'" % (original_path, processed_path), diff --git a/django/contrib/staticfiles/storage.py b/django/contrib/staticfiles/storage.py index c889bcb4a495..e0af40638423 100644 --- a/django/contrib/staticfiles/storage.py +++ b/django/contrib/staticfiles/storage.py @@ -232,6 +232,16 @@ def url_converter(self, name, hashed_files, template=None, comment_blocks=None): if template is None: template = self.default_template + def _line_at_position(content, position): + start = content.rfind("\n", 0, position) + 1 + end = content.find("\n", position) + end = end if end != -1 else len(content) + line_num = content.count("\n", 0, start) + 1 + msg = f"\n{line_num}: {content[start:end]}" + if len(msg) > 79: + return f"\n{line_num}" + return msg + def converter(matchobj): """ Convert the matched URL to a normalized and hashed URL. @@ -276,12 +286,18 @@ def converter(matchobj): # Determine the hashed name of the target file with the storage # backend. - hashed_url = self._url( - self._stored_name, - unquote(target_name), - force=True, - hashed_files=hashed_files, - ) + try: + hashed_url = self._url( + self._stored_name, + unquote(target_name), + force=True, + hashed_files=hashed_files, + ) + except ValueError as exc: + line = _line_at_position(matchobj.string, matchobj.start()) + note = f"{name!r} contains this reference {matched!r} on line {line}" + exc.add_note(note) + raise exc transformed_url = "/".join( url_path.split("/")[:-1] + hashed_url.split("/")[-1:] diff --git a/tests/staticfiles_tests/test_storage.py b/tests/staticfiles_tests/test_storage.py index cdb6fd3c7e2e..9db449bf9df2 100644 --- a/tests/staticfiles_tests/test_storage.py +++ b/tests/staticfiles_tests/test_storage.py @@ -13,7 +13,7 @@ from django.contrib.staticfiles.management.commands.collectstatic import ( Command as CollectstaticCommand, ) -from django.core.management import call_command +from django.core.management import CommandError, call_command from django.test import SimpleTestCase, override_settings from .cases import CollectionTestCase @@ -201,8 +201,10 @@ def test_template_tag_url(self): def test_import_loop(self): finders.get_finder.cache_clear() err = StringIO() - with self.assertRaisesMessage(RuntimeError, "Max post-process passes exceeded"): + msg = "Max post-process passes exceeded" + with self.assertRaisesMessage(CommandError, msg) as cm: call_command("collectstatic", interactive=False, verbosity=0, stderr=err) + self.assertIsInstance(cm.exception.__cause__, RuntimeError) self.assertEqual( "Post-processing 'bar.css, foo.css' failed!\n\n", err.getvalue() ) @@ -367,9 +369,14 @@ def test_post_processing_failure(self): """ finders.get_finder.cache_clear() err = StringIO() - with self.assertRaises(Exception): + with self.assertRaises(CommandError) as cm: call_command("collectstatic", interactive=False, verbosity=0, stderr=err) self.assertEqual("Post-processing 'faulty.css' failed!\n\n", err.getvalue()) + self.assertIsInstance(cm.exception.__cause__, ValueError) + exc_message = str(cm.exception) + self.assertIn("faulty.css", exc_message) + self.assertIn("missing.css", exc_message) + self.assertIn("1:", exc_message) # line 1 reported self.assertPostCondition() @override_settings( @@ -379,8 +386,9 @@ def test_post_processing_failure(self): def test_post_processing_nonutf8(self): finders.get_finder.cache_clear() err = StringIO() - with self.assertRaises(UnicodeDecodeError): + with self.assertRaises(CommandError) as cm: call_command("collectstatic", interactive=False, verbosity=0, stderr=err) + self.assertIsInstance(cm.exception.__cause__, UnicodeDecodeError) self.assertEqual("Post-processing 'nonutf8.css' failed!\n\n", err.getvalue()) self.assertPostCondition() From b33c31d992591bc8e8d20ac156809e4ae5b45375 Mon Sep 17 00:00:00 2001 From: khadyottakale Date: Sun, 22 Feb 2026 14:28:45 +0530 Subject: [PATCH 6/6] Fixed #36940 -- Fixed script name edge case in ASGIRequest.path_info. Paths that happened to begin with the script name were inappropriately stripped, instead of checking that script name preceded a slash. --- django/core/handlers/asgi.py | 9 ++++++--- tests/handlers/tests.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py index c8118e1691f9..9555860a7e21 100644 --- a/django/core/handlers/asgi.py +++ b/django/core/handlers/asgi.py @@ -54,10 +54,13 @@ def __init__(self, scope, body_file): self.path = scope["path"] self.script_name = get_script_prefix(scope) if self.script_name: - # TODO: Better is-prefix checking, slash handling? - self.path_info = scope["path"].removeprefix(self.script_name) + script_name = self.script_name.rstrip("/") + if self.path.startswith(script_name + "/") or self.path == script_name: + self.path_info = self.path[len(script_name) :] + else: + self.path_info = self.path else: - self.path_info = scope["path"] + self.path_info = self.path # HTTP basics. self.method = self.scope["method"].upper() # Ensure query string is encoded correctly. diff --git a/tests/handlers/tests.py b/tests/handlers/tests.py index 83dfd95713b8..625090a66d0c 100644 --- a/tests/handlers/tests.py +++ b/tests/handlers/tests.py @@ -346,6 +346,26 @@ def test_force_script_name(self): self.assertEqual(request.script_name, "/FORCED_PREFIX") self.assertEqual(request.path_info, "/somepath/") + def test_root_path_prefix_boundary(self): + async_request_factory = AsyncRequestFactory() + # When path shares a textual prefix with root_path but not at a + # segment boundary, path_info should be the full path. + request = async_request_factory.request( + **{"path": "/rootprefix/somepath/", "root_path": "/root"} + ) + self.assertEqual(request.path, "/rootprefix/somepath/") + self.assertEqual(request.script_name, "/root") + self.assertEqual(request.path_info, "/rootprefix/somepath/") + + def test_root_path_trailing_slash(self): + async_request_factory = AsyncRequestFactory() + request = async_request_factory.request( + **{"path": "/root/somepath/", "root_path": "/root/"} + ) + self.assertEqual(request.path, "/root/somepath/") + self.assertEqual(request.script_name, "/root/") + self.assertEqual(request.path_info, "/somepath/") + async def test_sync_streaming(self): response = await self.async_client.get("/streaming/") self.assertEqual(response.status_code, 200)