{"thread":{"id":"65802","subject":"[PATCH 0/2] commit: preserve commit hash on a no-op amend","startedAt":"2026-06-13T09:16:32Z","lastAt":"2026-06-13T16:16:26Z","messageCount":7,"participants":["Harald Nordgren via GitGitGadget","Johannes Sixt","Ben Knoble","Junio C Hamano","Harald Nordgren"],"isPatch":true,"patchVersion":1,"patchTotal":2},"messages":[{"id":"545440","messageId":"pull.2334.git.git.1781342189.gitgitgadget@gmail.com","threadId":"65802","inReplyTo":null,"subject":"[PATCH 0/2] commit: preserve commit hash on a no-op amend","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-13T09:16:27Z","receivedAt":"2026-06-13T09:16:32Z","isPatch":true,"body":"git commit --amend --no-edit rewrote the commit and moved the branch tip\neven when nothing changed, because the committer date was reset to \"now\".\nReuse the existing committer date so a no-op amend keeps the commit hash and\nleaves the branch untouched.\n\nA real change (tree, message, author, committer, or signing) still rewrites\nas before.\n\nHarald Nordgren (2):\n  commit: extract commit_index_files_or_die() helper\n  commit: keep the commit on a no-op amend\n\n Documentation/git-commit.adoc         |   6 ++\n builtin/commit.c                      |  69 ++++++++++++++-\n t/t7501-commit-basic-functionality.sh | 119 ++++++++++++++++++++++++++\n 3 files changed, 190 insertions(+), 4 deletions(-)\n\n\nbase-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2334%2FHaraldNordgren%2Famend-noop-keeps-commit-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2334/HaraldNordgren/amend-noop-keeps-commit-v1\nPull-Request: https://github.com/git/git/pull/2334\n-- \ngitgitgadget\n"},{"id":"545441","messageId":"ddb720a8bded60b6efa35e571dff0e23ffb7b1a6.1781342189.git.gitgitgadget@gmail.com","threadId":"65802","inReplyTo":"pull.2334.git.git.1781342189.gitgitgadget@gmail.com","subject":"[PATCH 1/2] commit: extract commit_index_files_or_die() helper","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-13T09:16:28Z","receivedAt":"2026-06-13T09:16:34Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nA later change adds a second caller that commits the index lock and dies\non failure, so wrap that into a helper to avoid duplicating its message.\n\nNo functional change intended.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/commit.c | 13 +++++++++----\n 1 file changed, 9 insertions(+), 4 deletions(-)\n\ndiff --git a/builtin/commit.c b/builtin/commit.c\nindex 28f6174503..1a51450660 100644\n--- a/builtin/commit.c\n+++ b/builtin/commit.c\n@@ -248,6 +248,14 @@ static int commit_index_files(void)\n \treturn err;\n }\n \n+static void commit_index_files_or_die(void)\n+{\n+\tif (commit_index_files())\n+\t\tdie(_(\"repository has been updated, but unable to write\\n\"\n+\t\t      \"new index file. Check that disk is not full and quota is\\n\"\n+\t\t      \"not exceeded, and then \\\"git restore --staged :/\\\" to recover.\"));\n+}\n+\n /*\n  * Take a union of paths in the index and the named tree (typically, \"HEAD\"),\n  * and return the paths that match the given pattern in list.\n@@ -1954,10 +1962,7 @@ int cmd_commit(int argc,\n \tunlink(git_path_merge_mode(the_repository));\n \tunlink(git_path_squash_msg(the_repository));\n \n-\tif (commit_index_files())\n-\t\tdie(_(\"repository has been updated, but unable to write\\n\"\n-\t\t      \"new index file. Check that disk is not full and quota is\\n\"\n-\t\t      \"not exceeded, and then \\\"git restore --staged :/\\\" to recover.\"));\n+\tcommit_index_files_or_die();\n \n \tgit_test_write_commit_graph_or_die(the_repository->objects->sources);\n \n-- \ngitgitgadget\n\n"},{"id":"545442","messageId":"44be2f98d62d73a690c295c9b3c782aa9cebb0e8.1781342189.git.gitgitgadget@gmail.com","threadId":"65802","inReplyTo":"pull.2334.git.git.1781342189.gitgitgadget@gmail.com","subject":"[PATCH 2/2] commit: keep the commit on a no-op amend","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-13T09:16:29Z","receivedAt":"2026-06-13T09:16:35Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\"git commit --amend --no-edit\" reset the committer date to \"now\" and\nrewrote the commit even when nothing else changed, moving the branch tip\nto a new hash for an effective no-op.\n\nBuild the amended commit reusing the existing committer date: if that\nreproduces the current commit, leave the branch alone, report \"nothing\nto amend\", and skip the reflog entry and the post-commit and post-rewrite\nhooks.\n\nSigning always rewrites the commit, since its signature cannot reproduce\nthe original, so it skips this detection.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-commit.adoc         |   6 ++\n builtin/commit.c                      |  56 ++++++++++++\n t/t7501-commit-basic-functionality.sh | 119 ++++++++++++++++++++++++++\n 3 files changed, 181 insertions(+)\n\ndiff --git a/Documentation/git-commit.adoc b/Documentation/git-commit.adoc\nindex 8329c1034b..c433c60929 100644\n--- a/Documentation/git-commit.adoc\n+++ b/Documentation/git-commit.adoc\n@@ -282,6 +282,12 @@ variable (see linkgit:git-config[1]).\n \tparents and author as the current one (the `--reset-author`\n \toption can countermand this).\n +\n+If the amended commit would be identical to the original (its tree,\n+message, author, parents, and committer are all unchanged), the original\n+committer date is kept so that the commit, and thus the branch tip, is\n+left untouched. A commit that is being signed (`-S`, or `commit.gpgsign`)\n+is always rewritten, since its signature cannot reproduce the original.\n++\n --\n It is a rough equivalent for:\n \ndiff --git a/builtin/commit.c b/builtin/commit.c\nindex 1a51450660..e330a53d5c 100644\n--- a/builtin/commit.c\n+++ b/builtin/commit.c\n@@ -17,6 +17,7 @@\n #include \"dir.h\"\n #include \"editor.h\"\n #include \"environment.h\"\n+#include \"ident.h\"\n #include \"diff.h\"\n #include \"commit.h\"\n #include \"add-interactive.h\"\n@@ -760,6 +761,49 @@ static void prepare_amend_commit(struct commit *commit, struct strbuf *sb,\n \trepo_unuse_commit_buffer(the_repository, commit, buffer);\n }\n \n+/*\n+ * Rebuild the amended commit reusing the existing committer date and report\n+ * whether it reproduces the current commit. Because the committer date is the\n+ * only field that an amend would otherwise replace with \"now\", an exact match\n+ * means everything else (tree, message, author, parents, committer identity)\n+ * is unchanged too.\n+ */\n+static int amend_is_noop(struct commit *current_head,\n+\t\t\t\t const struct strbuf *message,\n+\t\t\t\t const struct commit_list *parents,\n+\t\t\t\t const char *author,\n+\t\t\t\t const struct commit_extra_header *extra,\n+\t\t\t\t struct object_id *oid)\n+{\n+\tconst char *buffer, *committer_line;\n+\tsize_t len;\n+\tstruct ident_split ident;\n+\tstruct strbuf date = STRBUF_INIT;\n+\tint unchanged = 0;\n+\n+\tbuffer = repo_get_commit_buffer(the_repository, current_head, NULL);\n+\tcommitter_line = find_commit_header(buffer, \"committer\", &len);\n+\tif (committer_line && !split_ident_line(&ident, committer_line, len) &&\n+\t    ident.date_begin) {\n+\t\tconst char *committer;\n+\n+\t\tstrbuf_add(&date, ident.date_begin,\n+\t\t\t   ident.tz_end - ident.date_begin);\n+\t\tcommitter = fmt_ident(getenv(\"GIT_COMMITTER_NAME\"),\n+\t\t\t\t      getenv(\"GIT_COMMITTER_EMAIL\"),\n+\t\t\t\t      WANT_COMMITTER_IDENT, date.buf,\n+\t\t\t\t      IDENT_STRICT);\n+\t\tif (!commit_tree_extended(message->buf, message->len,\n+\t\t\t\t\t  &the_repository->index->cache_tree->oid,\n+\t\t\t\t\t  parents, oid, author, committer, NULL,\n+\t\t\t\t\t  extra))\n+\t\t\tunchanged = oideq(oid, &current_head->object.oid);\n+\t}\n+\trepo_unuse_commit_buffer(the_repository, current_head, buffer);\n+\tstrbuf_release(&date);\n+\treturn unchanged;\n+}\n+\n static void change_data_free(void *util, const char *str UNUSED)\n {\n \tstruct wt_status_change_data *d = util;\n@@ -1943,6 +1987,18 @@ int cmd_commit(int argc,\n \t\tappend_merge_tag_headers(parents, &tail);\n \t}\n \n+\tif (amend && current_head && !sign_commit &&\n+\t    amend_is_noop(current_head, &sb, parents, author_ident.buf,\n+\t\t\t  extra, &oid)) {\n+\t\tcommit_index_files_or_die();\n+\t\tif (!quiet)\n+\t\t\tfprintf(stderr,\n+\t\t\t\t_(\"nothing to amend; %s left unchanged\\n\"),\n+\t\t\t\trepo_find_unique_abbrev(the_repository, &oid,\n+\t\t\t\t\t\t\tDEFAULT_ABBREV));\n+\t\tgoto cleanup;\n+\t}\n+\n \tif (commit_tree_extended(sb.buf, sb.len, &the_repository->index->cache_tree->oid,\n \t\t\t\t parents, &oid, author_ident.buf, NULL,\n \t\t\t\t sign_commit, extra)) {\ndiff --git a/t/t7501-commit-basic-functionality.sh b/t/t7501-commit-basic-functionality.sh\nindex a37509f004..160edb9c0a 100755\n--- a/t/t7501-commit-basic-functionality.sh\n+++ b/t/t7501-commit-basic-functionality.sh\n@@ -11,6 +11,7 @@ export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n \n . ./test-lib.sh\n . \"$TEST_DIRECTORY/lib-diff.sh\"\n+. \"$TEST_DIRECTORY/lib-gpg.sh\"\n \n author='The Real Author <someguy@his.email.org>'\n \n@@ -654,6 +655,124 @@ test_expect_success 'amend commit to fix author' '\n \n '\n \n+test_expect_success 'amend --no-edit that changes nothing keeps the commit' '\n+\tgit reset --hard &&\n+\told=$(git rev-parse HEAD) &&\n+\ttest_tick &&\n+\tgit commit --amend --no-edit 2>err &&\n+\ttest_cmp_rev $old HEAD &&\n+\ttest_grep \"nothing to amend\" err\n+'\n+\n+test_expect_success 'amend --no-edit keeps the commit out of the reflog' '\n+\tgit reset --hard &&\n+\tgit rev-parse HEAD@{0} >before &&\n+\ttest_tick &&\n+\tgit commit --amend --no-edit &&\n+\tgit rev-parse HEAD@{0} >after &&\n+\ttest_cmp before after\n+'\n+\n+test_expect_success 'amend --signoff is idempotent once signed off' '\n+\tgit reset --hard &&\n+\ttest_tick &&\n+\tgit commit --amend --no-edit --signoff &&\n+\tsigned=$(git rev-parse HEAD) &&\n+\tgit log -1 --format=%B | grep \"^Signed-off-by:\" &&\n+\ttest_tick &&\n+\tgit commit --amend --no-edit --signoff &&\n+\ttest_cmp_rev $signed HEAD\n+'\n+\n+test_expect_success 'amend that changes the tree still rewrites the commit' '\n+\tgit reset --hard &&\n+\told=$(git rev-parse HEAD) &&\n+\techo changed >>file &&\n+\tgit add file &&\n+\ttest_tick &&\n+\tgit commit --amend --no-edit &&\n+\ttest_cmp_rev ! $old HEAD\n+'\n+\n+test_expect_success 'amend that changes the committer still rewrites the commit' '\n+\tgit reset --hard &&\n+\told=$(git rev-parse HEAD) &&\n+\ttest_tick &&\n+\tGIT_COMMITTER_EMAIL=other@example.com \\\n+\t\tgit commit --amend --no-edit &&\n+\ttest_cmp_rev ! $old HEAD\n+'\n+\n+test_expect_success 'amend that changes only the message still rewrites the commit' '\n+\tgit reset --hard &&\n+\told=$(git rev-parse HEAD) &&\n+\ttest_tick &&\n+\tgit commit --amend -m \"new message\" &&\n+\ttest_cmp_rev ! $old HEAD &&\n+\techo \"new message\" >expect &&\n+\tgit log -1 --format=%s >actual &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'amend --allow-empty of an empty commit that changes nothing keeps it' '\n+\ttest_when_finished \"git reset --hard parent && git tag -d parent\" &&\n+\tgit tag parent &&\n+\tgit commit --allow-empty -m \"empty\" &&\n+\told=$(git rev-parse HEAD) &&\n+\ttest_tick &&\n+\tgit commit --amend --no-edit --allow-empty 2>err &&\n+\ttest_cmp_rev $old HEAD &&\n+\ttest_grep \"nothing to amend\" err\n+'\n+\n+test_expect_success GPG 'amend --no-edit of a signed commit is not a no-op' '\n+\tgit reset --hard &&\n+\ttest_tick &&\n+\tgit commit --amend --no-edit -S &&\n+\tsigned=$(git rev-parse HEAD) &&\n+\tgit verify-commit HEAD &&\n+\ttest_tick &&\n+\tgit commit --amend --no-edit -S &&\n+\ttest_cmp_rev ! $signed HEAD &&\n+\tgit verify-commit HEAD\n+'\n+\n+test_expect_success GPG 'amend --no-edit with commit.gpgsign is not a no-op' '\n+\tgit reset --hard &&\n+\ttest_tick &&\n+\told=$(git rev-parse HEAD) &&\n+\tgit -c commit.gpgsign=true commit --amend --no-edit &&\n+\ttest_cmp_rev ! $old HEAD &&\n+\tgit verify-commit HEAD\n+'\n+\n+test_expect_success 'amend --reset-author rewrites the commit' '\n+\tgit reset --hard &&\n+\told=$(git rev-parse HEAD) &&\n+\ttest_tick &&\n+\tgit commit --amend --no-edit --reset-author &&\n+\ttest_cmp_rev ! $old HEAD\n+'\n+\n+test_expect_success 'amend --date rewrites the commit' '\n+\tgit reset --hard &&\n+\told=$(git rev-parse HEAD) &&\n+\ttest_tick &&\n+\tgit commit --amend --no-edit --date=\"@1234567890 +0000\" &&\n+\ttest_cmp_rev ! $old HEAD\n+'\n+\n+test_expect_success 'amend that changes nothing skips the post-commit hook' '\n+\ttest_when_finished \"rm -f post-commit.ran\" &&\n+\ttest_hook post-commit <<-\\EOF &&\n+\t>post-commit.ran\n+\tEOF\n+\tgit reset --hard &&\n+\ttest_tick &&\n+\tgit commit --amend --no-edit &&\n+\ttest_path_is_missing post-commit.ran\n+'\n+\n test_expect_success 'git commit <file> with dirty index' '\n \techo tacocat >elif &&\n \techo tehlulz >chz &&\n-- \ngitgitgadget\n"},{"id":"545444","messageId":"355d4f1a-147c-47e3-ab09-06810d7910c0@kdbg.org","threadId":"65802","inReplyTo":"pull.2334.git.git.1781342189.gitgitgadget@gmail.com","subject":"Re: [PATCH 0/2] commit: preserve commit hash on a no-op amend","fromName":"Johannes Sixt","fromEmail":"j6t@kdbg.org","sentAt":"2026-06-13T09:59:11Z","receivedAt":"2026-06-13T09:59:20Z","isPatch":true,"body":"Am 13.06.26 um 11:16 schrieb Harald Nordgren via GitGitGadget:\n> git commit --amend --no-edit rewrote the commit and moved the branch tip\n> even when nothing changed, because the committer date was reset to \"now\".\n> Reuse the existing committer date so a no-op amend keeps the commit hash and\n> leaves the branch untouched.\n\n`git commit --amend --no-edit` is a way to set the committer timestamp\nto the current time without changing other aspects of the commit. This\ntakes away this ability, doesn't it?\n\nIs this keyed to --no-edit? Why is this mode special? Wouldn't it be an\nidentical case when the commit message is passed to the editor, but\ncomes back unchanged?\n\nAn invocation of `git commit` asks to \"please make a new commit\". But in\nthe suggested mode, no new commit is created. Shouldn't this then be\nregarded as failure?\n\nWhat happens with the current branch? Is it left unchanged (no ref\nupdate occurs) or is it changed (a ref update occurs, but it happens to\nbe a no-op)? And does this then generate a reflog entry?\n\nThe updated documentation says about signed commits (note: I am totally\nclueless about commit signing procedures):\n\n> A commit that is being signed (`-S`, or `commit.gpgsign`)\n> is always rewritten, since its signature cannot reproduce the original.\n\nBut if the commit doesn't change in any way, why should the signature be\ninvalidated, rewritten, or updated?\n\n-- Hannes\n\n"},{"id":"545450","messageId":"C33C3BEB-6353-4461-8E7A-72DB663F8BEE@gmail.com","threadId":"65802","inReplyTo":"355d4f1a-147c-47e3-ab09-06810d7910c0@kdbg.org","subject":"Re: [PATCH 0/2] commit: preserve commit hash on a no-op amend","fromName":"Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-06-13T14:07:38Z","receivedAt":"2026-06-13T14:07:50Z","isPatch":true,"body":"> Le 13 juin 2026 à 05:59, Johannes Sixt <j6t@kdbg.org> a écrit :\n> \n> ﻿Am 13.06.26 um 11:16 schrieb Harald Nordgren via GitGitGadget:\n>> git commit --amend --no-edit rewrote the commit and moved the branch tip\n>> even when nothing changed, because the committer date was reset to \"now\".\n>> Reuse the existing committer date so a no-op amend keeps the commit hash and\n>> leaves the branch untouched.\n> \n> `git commit --amend --no-edit` is a way to set the committer timestamp\n> to the current time without changing other aspects of the commit. This\n> takes away this ability, doesn't it?\n\nIndeed. This is a convenient formula to force CI re-runs in certain environments, and so on."},{"id":"545458","messageId":"xmqq33yqfnsa.fsf@gitster.g","threadId":"65802","inReplyTo":"pull.2334.git.git.1781342189.gitgitgadget@gmail.com","subject":"Re: [PATCH 0/2] commit: preserve commit hash on a no-op amend","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-13T15:44:21Z","receivedAt":"2026-06-13T15:44:23Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> git commit --amend --no-edit rewrote the commit and moved the branch tip\n> even when nothing changed, because the committer date was reset to \"now\".\n> Reuse the existing committer date so a no-op amend keeps the commit hash and\n> leaves the branch untouched.\n>\n> A real change (tree, message, author, committer, or signing) still rewrites\n> as before.\n\nI think this change brings nothing but regression.\n\nIsn't it obvious that \"commit --amend --no-edit\" without updating\nany tree contents would record exactly the same contents as before,\nwithout a \"real change\" (as you said above), to any and all users,\nexpert and casual alike?\n\nThe end-user who runs such a command must have a reason to do so.\nThe *ONLY* valid reason anybody might want to such an amend is to\nmake sure the result is a new object, even if it records otherwise\nthe same content.\n\nWhy would they want to do so?  Perhaps it is so that future merges\nof the topic branch that contains the commit will work more smoothly\ninto an integration branch that had earlier merged the topic branch,\nand then that earlier merge was reverted.  This change will rob an\neffective way to ensure a successful final merge in a workflow to\n(1) merge a topic, (2) revert the topic, (3) update near the tip of\nthe topic while keeping earlier topic intact, and then (4) merge the\nresult again.\n\nSo, no.  I do not think this is a good change.\n\nLet's digress and imagine an alternate universe where rebase/commit\n--amend/history were \"smart\" from day one.  These command in such a\nhypothetical world may not be capable of refreshing an existing\ncommit without making any \"real change\".\n\nMaking a change to these commands to _optionally_ allow them to\nrecreate an otherwise unchanged commit, so that it will get a new\nobject name, would be a welcome change that would allow users who\nwould use \"commit --amend --no-edit\" with today's system for such a\nuse case.  \n\nAnd that would have been a logical evolution of the system in such a\nhypothetical world.\n\nBut the thing is, we do not live in such a world.\n\nIf we still think that alternate hypothetical world is a better\nplace, we'd need to actively move things around, carefully designing\nthe transition to avoid harming existing users along the way, to get\nthere.  Changing the behaviour all of a sudden and breaking existing\nworkflows is not something we do around here.\n\nOne way to get to such a world might be:\n\n * Introduce an \"committer timestamp is a trashable information\"\n   option, and teach commands like \"commit --amend\", \"rebase\", and\n   \"history\" to cheat and yield the existing commit without\n   refreshing when they are asked to recreate an existing commit\n   while the option is in effect.  Give people the opposite\n   \"committer timestamp is not trashable information\" option, so an\n   earlier \"is trashable\" option on the command line can be\n   countermanded by giving it later on the command line.\n\n * Have users discuss if \"is trashable\" is a better default, and\n   gain consensus to make it the default in a future version of Git.\n   Advertise the fact that we achieved consensus LOUDLY, while\n   telling dissidents that \"is not trashable\" option will forever be\n   available for them.\n\n * At a big version boundary, switch the default.\n\nAnd I do not think I in principle would object to the first step of\nsuch a three step process.\n\nThanks.\n"},{"id":"545460","messageId":"CAHwyqnXDa96+NiVanYyJb7R+XyOz6ooS+SW+b2OdKx979gUNaw@mail.gmail.com","threadId":"65802","inReplyTo":"xmqq33yqfnsa.fsf@gitster.g","subject":"Re: [PATCH 0/2] commit: preserve commit hash on a no-op amend","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-13T16:15:48Z","receivedAt":"2026-06-13T16:16:26Z","isPatch":true,"body":"Interesting discussions! This sounds like showstopper, seems more\nreasonable to leave this topic for now.\n\nI just want to share that I've been running this for years to\nre-trigger CI (because up until a few days ago I didn't realize that\nthe hash did indeed change even when nothing had changed), I had the\nwrong mental model for commit hashes:\n\n    git commit --amend --no-edit --date=\"now\"\n\n\nHarald\n"}]}