From d9def7b461d9c08d0bcbff99e6c34424f9bb87dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emin=20=C3=96zata?= Date: Fri, 17 Jul 2026 02:32:04 +0300 Subject: [PATCH] stash: add 'reword' subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stash entries accumulate with the default "WIP on " message when they are created in a hurry, and there is no way to relabel the ones worth keeping afterwards. The only option is dropping an entry and re-storing it by hand, which moves it to the top of the stash list and gets fiddly for deeper entries. Add 'git stash reword []', which defaults to the latest entry like the other subcommands do. The name follows the verb git already uses for changing only a message, as in the reword command of 'git rebase -i' and in 'git history reword'. It reads the whole reflog and writes it back in a single transaction, with the new message on the target and every other entry left as it was. Position, contents, timestamps and the reflog chain all stay put. Before touching anything, the command inspects the target entry and the ones above it, and refuses to start if any of them does not look like a stash commit; that can only happen when refs/stash was written to by hand. The rewrite clears the reflog before writing the new one, so if the transaction fails afterwards, the command reports the object id of each collected entry and re-stores it best-effort. Whatever it fails to re-store can be recovered with 'git stash store'. This was proposed before: in 2010, as a "git reflog update" command that edited reflog entries in place [1]. When it came up again in 2013 [2], Junio rejected it on the grounds that reflogs are append-only recovery logs, and that whoever really cares about a stash message can pop and re-stash [3]. Michael Haggerty pointed out in that thread that refs/stash does not fit the description: its reflog is the primary data store for stash entries, and 'git stash drop' rewrites it all the time [4]. So this patch rewrites refs/stash directly, the way 'git stash drop' already does, through ref_transaction_update_reflog(), which 'git remote rename' and 'git refs migrate' already use to rewrite existing reflogs. Name the target by index (stash@{1}); the command rejects time-based selectors, because it needs the entry's position in the reflog. Writing the reflog back at once keeps the cost linear in its length whatever that position is, and spares the reftable backend from emitting and compacting a table per entry, which is what dropping and re-storing the entries one by one would cost. [1] https://lore.kernel.org/git/20100620093142.GF24805@occam.hewgill.net/ [2] https://lore.kernel.org/git/loom.20130104T192132-16@post.gmane.org/ [3] https://lore.kernel.org/git/7vbod4tynt.fsf@alter.siamese.dyndns.org/ [4] https://lore.kernel.org/git/50ED2C78.1030300@alum.mit.edu/ Signed-off-by: Emin Özata --- Documentation/git-stash.adoc | 10 +- builtin/stash.c | 201 +++++++++++++++++++++++++ contrib/completion/git-completion.bash | 4 +- t/t3903-stash.sh | 79 ++++++++++ 4 files changed, 290 insertions(+), 4 deletions(-) diff --git a/Documentation/git-stash.adoc b/Documentation/git-stash.adoc index 50bb89f48362a4..16a2a015f33cc9 100644 --- a/Documentation/git-stash.adoc +++ b/Documentation/git-stash.adoc @@ -25,6 +25,7 @@ git stash create [] git stash store [(-m | --message) ] [-q | --quiet] git stash export (--print | --to-ref ) [...] git stash import +git stash reword [-q | --quiet] [] DESCRIPTION ----------- @@ -163,6 +164,11 @@ with no conflicts. created by `export`, and add them to the list of stashes. To replace the existing stashes, use `clear` first. +`reword [-q | --quiet] []`:: + Change the message of a single stash entry. The entry keeps its + position, its contents and its reflog timestamp. __ must + name an entry by index (e.g. `stash@{1}`). + OPTIONS ------- `-a`:: @@ -258,7 +264,7 @@ literally (including newlines and quotes). `-q`:: `--quiet`:: This option is only valid for `apply`, `drop`, `pop`, `push`, - `save`, `store` commands. + `reword`, `save`, `store` commands. + Quiet, suppress feedback messages. @@ -292,7 +298,7 @@ For more details, see the 'pathspec' entry in linkgit:gitglossary[7]. __:: This option is only valid for `apply`, `branch`, `drop`, `pop`, - `show`, and `export` commands. + `show`, `export`, and `reword` commands. + A reference of the form `stash@{}`. When no __ is given, the latest stash is assumed (that is, `stash@{0}`). diff --git a/builtin/stash.c b/builtin/stash.c index c4809f299a313b..61471cf510d887 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -4,9 +4,11 @@ #include "abspath.h" #include "config.h" #include "environment.h" +#include "date.h" #include "gettext.h" #include "hash.h" #include "hex.h" +#include "ident.h" #include "object-name.h" #include "parse-options.h" #include "refs.h" @@ -63,6 +65,8 @@ N_("git stash export (--print | --to-ref ) [...]") #define BUILTIN_STASH_IMPORT_USAGE \ N_("git stash import ") +#define BUILTIN_STASH_REWORD_USAGE \ + N_("git stash reword [-q | --quiet] []") #define BUILTIN_STASH_CLEAR_USAGE \ "git stash clear" @@ -80,6 +84,7 @@ static const char * const git_stash_usage[] = { BUILTIN_STASH_STORE_USAGE, BUILTIN_STASH_EXPORT_USAGE, BUILTIN_STASH_IMPORT_USAGE, + BUILTIN_STASH_REWORD_USAGE, NULL }; @@ -143,6 +148,11 @@ static const char * const git_stash_import_usage[] = { NULL }; +static const char * const git_stash_reword_usage[] = { + BUILTIN_STASH_REWORD_USAGE, + NULL +}; + static const char ref_stash[] = "refs/stash"; static struct strbuf stash_index_path = STRBUF_INIT; @@ -1190,6 +1200,196 @@ static int store_stash(int argc, const char **argv, const char *prefix, return ret; } +struct reword_entry { + struct object_id old_oid; + struct object_id new_oid; + char *committer; + char *msg; +}; + +struct reword_data { + struct reword_entry *entries; + size_t nr, alloc; +}; + +static int collect_reword_entries(const char *refname UNUSED, + struct object_id *old_oid, + struct object_id *new_oid, + const char *committer, + timestamp_t timestamp, + int tz, const char *msg, + void *cb_data) +{ + struct reword_data *data = cb_data; + const char *eol = strchrnul(msg, '\n'); + struct reword_entry *entry; + struct ident_split ident; + + ALLOC_GROW(data->entries, data->nr + 1, data->alloc); + entry = &data->entries[data->nr]; + oidcpy(&entry->old_oid, old_oid); + oidcpy(&entry->new_oid, new_oid); + entry->msg = xstrndup(msg, eol - msg); + + if (split_ident_line(&ident, committer, strlen(committer)) < 0) { + entry->committer = xstrdup(committer); + } else { + struct strbuf name = STRBUF_INIT, mail = STRBUF_INIT; + const char *date = show_date(timestamp, tz, DATE_MODE(NORMAL)); + + strbuf_add(&name, ident.name_begin, + ident.name_end - ident.name_begin); + strbuf_add(&mail, ident.mail_begin, + ident.mail_end - ident.mail_begin); + entry->committer = xstrdup(fmt_ident(name.buf, mail.buf, + WANT_BLANK_IDENT, date, 0)); + strbuf_release(&name); + strbuf_release(&mail); + } + + data->nr++; + return 0; +} + +static int parse_stash_index(const char *revision, size_t *idx) +{ + const char *num = strstr(revision, "@{"); + char *end; + + if (!num || !isdigit(num[2])) + return -1; + *idx = strtoumax(num + 2, &end, 10); + if (*end != '}' || end[1]) + return -1; + + return 0; +} + +static int do_reword_stash(struct stash_info *info, size_t idx, + const char *reworded_msg, int quiet) +{ + struct ref_store *refs = get_main_ref_store(the_repository); + struct ref_transaction *transaction = NULL; + struct reword_data data = { 0 }; + struct strbuf err = STRBUF_INIT; + uint64_t index = 0; + size_t i; + int ret = -1; + + refs_for_each_reflog_ent_reverse(refs, ref_stash, + collect_reword_entries, &data); + if (data.nr <= idx) { + error(_("%s does not exist"), info->revision.buf); + goto cleanup; + } + + if (!oideq(&info->w_commit, &data.entries[idx].new_oid)) { + error(_("%s changed concurrently; try again"), + info->revision.buf); + goto cleanup; + } + + for (i = 0; i <= idx; i++) { + struct commit *stash = lookup_commit_reference(the_repository, + &data.entries[i].new_oid); + + if (!stash || check_stash_topology(the_repository, stash)) { + error(_("%s does not look like a stash commit"), + oid_to_hex(&data.entries[i].new_oid)); + goto cleanup; + } + } + + if (refs_delete_reflog(refs, ref_stash)) { + error(_("could not rewrite %s"), ref_stash); + goto cleanup; + } + + transaction = ref_store_transaction_begin(refs, 0, &err); + if (!transaction) + goto restore; + + for (i = data.nr; i-- > 0; ) { + if (ref_transaction_update_reflog(transaction, ref_stash, + &data.entries[i].new_oid, + &data.entries[i].old_oid, + data.entries[i].committer, + i == idx ? reworded_msg : + data.entries[i].msg, + index++, &err)) + goto restore; + } + + if (ref_transaction_commit(transaction, &err)) + goto restore; + + ret = 0; + if (!quiet) + printf_ln(_("Reworded %s (%s)"), info->revision.buf, + oid_to_hex(&data.entries[idx].new_oid)); + goto cleanup; + +restore: + if (err.len) + error("%s", err.buf); + ref_transaction_free(transaction); + transaction = NULL; + for (i = data.nr; i-- > 0; ) + if (do_store_stash(&data.entries[i].new_oid, + data.entries[i].msg, 1)) + warning(_("could not restore stash entry %s; " + "recover it with 'git stash store %s'"), + oid_to_hex(&data.entries[i].new_oid), + oid_to_hex(&data.entries[i].new_oid)); +cleanup: + ref_transaction_free(transaction); + strbuf_release(&err); + for (i = 0; i < data.nr; i++) { + free(data.entries[i].committer); + free(data.entries[i].msg); + } + free(data.entries); + return ret; +} + +static int reword_stash(int argc, const char **argv, const char *prefix, + struct repository *repo UNUSED) +{ + int ret = -1; + int quiet = 0; + size_t idx; + struct stash_info info = STASH_INFO_INIT; + struct option options[] = { + OPT__QUIET(&quiet, N_("be quiet, only report errors")), + OPT_END() + }; + + argc = parse_options(argc, argv, prefix, options, + git_stash_reword_usage, 0); + + if (!argc) + usage_with_options(git_stash_reword_usage, options); + + if (!argv[0][strspn(argv[0], " \t\r\n")]) { + ret = error(_("stash message cannot be empty")); + goto cleanup; + } + + if (get_stash_info_assert(&info, argc - 1, argv + 1)) + goto cleanup; + + if (parse_stash_index(info.revision.buf, &idx)) { + error(_("cannot reword '%s': name the entry by index, " + "like 'stash@{1}'"), info.revision.buf); + goto cleanup; + } + + ret = do_reword_stash(&info, idx, argv[0], quiet); +cleanup: + free_stash_info(&info); + return ret; +} + static void add_pathspecs(struct strvec *args, const struct pathspec *ps) { int i; @@ -2472,6 +2672,7 @@ int cmd_stash(int argc, OPT_SUBCOMMAND("push", &fn, push_stash_unassumed), OPT_SUBCOMMAND("export", &fn, export_stash), OPT_SUBCOMMAND("import", &fn, import_stash), + OPT_SUBCOMMAND("reword", &fn, reword_stash), OPT_SUBCOMMAND_F("save", &fn, save_stash, PARSE_OPT_NOCOMPLETE), OPT_END() }; diff --git a/contrib/completion/git-completion.bash b/contrib/completion/git-completion.bash index e8757877104eb9..261c6bf101597f 100644 --- a/contrib/completion/git-completion.bash +++ b/contrib/completion/git-completion.bash @@ -3465,7 +3465,7 @@ _git_sparse_checkout () _git_stash () { - local subcommands='push list show apply clear drop pop create branch import export' + local subcommands='push list show apply clear drop pop create branch import export reword' local subcommand="$(__git_find_on_cmdline "$subcommands save")" if [ -z "$subcommand" ]; then @@ -3508,7 +3508,7 @@ _git_stash () import,*) __git_complete_refs ;; - show,*|apply,*|drop,*|pop,*|export,*) + show,*|apply,*|drop,*|pop,*|export,*|reword,*) __gitcomp_nl "$(__git stash list \ | sed -n -e 's/:.*//p')" ;; diff --git a/t/t3903-stash.sh b/t/t3903-stash.sh index ecc35aae82a5fe..07fbbddac8ae68 100755 --- a/t/t3903-stash.sh +++ b/t/t3903-stash.sh @@ -1831,4 +1831,83 @@ test_expect_success 'stash show --include-untracked includes untracked files' ' test_grep "untracked" actual ' +test_expect_success 'reword a stash entry' ' + git stash clear && + >file-to-reword && + git add file-to-reword && + git stash push -m "original message" && + git stash reword "new message" stash@{0} >out && + test_grep "Reworded stash@{0}" out && + git stash list >list && + test_grep "stash@{0}: new message" list && + test_grep ! "original message" list +' + +test_expect_success 'reword defaults to the latest stash entry' ' + git stash reword "default target" >out && + test_grep "Reworded refs/stash@{0}" out && + git stash list >list && + test_grep "stash@{0}: default target" list +' + +test_expect_success 'reword a deeper stash entry keeps positions and states' ' + git stash clear && + for i in 1 2 3 + do + >file$i && + git add file$i && + git stash push -m "message $i" || return 1 + done && + git rev-parse stash@{0} stash@{1} stash@{2} >expect && + git stash reword "reworded middle" stash@{1} && + git rev-parse stash@{0} stash@{1} stash@{2} >actual && + test_cmp expect actual && + git stash list >list && + test_grep "stash@{0}: On.*message 3" list && + test_grep "stash@{1}: reworded middle" list && + test_grep "stash@{2}: On.*message 1" list +' + +test_expect_success 'reword the deepest stash entry' ' + git rev-parse stash@{0} stash@{1} stash@{2} >expect && + git stash reword "reworded deepest" stash@{2} && + git rev-parse stash@{0} stash@{1} stash@{2} >actual && + test_cmp expect actual && + git stash list >list && + test_grep "stash@{2}: reworded deepest" list +' + +test_expect_success 'reword accepts a bare index and honors --quiet' ' + git stash reword -q "quietly reworded" 1 >out && + test_must_be_empty out && + git stash list >list && + test_grep "stash@{1}: quietly reworded" list +' + +test_expect_success 'reword rejects bad arguments' ' + test_must_fail git stash reword "no such entry" stash@{99} && + test_must_fail git stash reword "" && + test_must_fail git stash reword " " && + test_must_fail git stash reword "not a stash" HEAD && + test_must_fail git stash reword "not an index" "stash@{now}" && + test_expect_code 129 git stash reword && + git stash list >list && + test_grep "stash@{1}: quietly reworded" list +' + +test_expect_success 'reword refuses to rewrite a non-stash reflog entry' ' + git stash clear && + >real-a && + git add real-a && + git stash push -m "real A" && + git update-ref -m junk --create-reflog refs/stash HEAD && + >real-b && + git add real-b && + git stash push -m "real B" && + git stash list >expect && + test_must_fail git stash reword "reworded A" stash@{2} && + git stash list >actual && + test_cmp expect actual +' + test_done