diff --git a/Documentation/git-stash.adoc b/Documentation/git-stash.adoc index 50bb89f48362a4..03f2e03096be55 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 rename [-q | --quiet] [] DESCRIPTION ----------- @@ -163,6 +164,12 @@ with no conflicts. created by `export`, and add them to the list of stashes. To replace the existing stashes, use `clear` first. +`rename [-q | --quiet] []`:: + Change the message of a single stash entry. The entry keeps its + position and its contents. __ must name an entry by + index (e.g. `stash@{1}`); renaming refreshes the reflog + timestamps of the entry and of the entries above it. + OPTIONS ------- `-a`:: @@ -258,7 +265,7 @@ literally (including newlines and quotes). `-q`:: `--quiet`:: This option is only valid for `apply`, `drop`, `pop`, `push`, - `save`, `store` commands. + `rename`, `save`, `store` commands. + Quiet, suppress feedback messages. @@ -292,7 +299,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 `rename` 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..94e66d6074acdb 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -63,6 +63,8 @@ N_("git stash export (--print | --to-ref ) [...]") #define BUILTIN_STASH_IMPORT_USAGE \ N_("git stash import ") +#define BUILTIN_STASH_RENAME_USAGE \ + N_("git stash rename [-q | --quiet] []") #define BUILTIN_STASH_CLEAR_USAGE \ "git stash clear" @@ -80,6 +82,7 @@ static const char * const git_stash_usage[] = { BUILTIN_STASH_STORE_USAGE, BUILTIN_STASH_EXPORT_USAGE, BUILTIN_STASH_IMPORT_USAGE, + BUILTIN_STASH_RENAME_USAGE, NULL }; @@ -143,6 +146,11 @@ static const char * const git_stash_import_usage[] = { NULL }; +static const char * const git_stash_rename_usage[] = { + BUILTIN_STASH_RENAME_USAGE, + NULL +}; + static const char ref_stash[] = "refs/stash"; static struct strbuf stash_index_path = STRBUF_INIT; @@ -820,18 +828,12 @@ static int reflog_is_empty(const char *refname) refname, reject_reflog_ent, NULL); } -static int do_drop_stash(struct stash_info *info, int quiet) +static int drop_reflog_entry(const char *revision) { - if (!reflog_delete(info->revision.buf, - EXPIRE_REFLOGS_REWRITE | EXPIRE_REFLOGS_UPDATE_REF, - 0)) { - if (!quiet) - printf_ln(_("Dropped %s (%s)"), info->revision.buf, - oid_to_hex(&info->w_commit)); - } else { - return error(_("%s: Could not drop stash entry"), - info->revision.buf); - } + if (reflog_delete(revision, + EXPIRE_REFLOGS_REWRITE | EXPIRE_REFLOGS_UPDATE_REF, + 0)) + return error(_("%s: Could not drop stash entry"), revision); if (reflog_is_empty(ref_stash)) do_clear_stash(); @@ -839,6 +841,18 @@ static int do_drop_stash(struct stash_info *info, int quiet) return 0; } +static int do_drop_stash(struct stash_info *info, int quiet) +{ + if (drop_reflog_entry(info->revision.buf)) + return -1; + + if (!quiet) + printf_ln(_("Dropped %s (%s)"), info->revision.buf, + oid_to_hex(&info->w_commit)); + + return 0; +} + static int get_stash_info_assert(struct stash_info *info, int argc, const char **argv) { @@ -1190,6 +1204,166 @@ static int store_stash(int argc, const char **argv, const char *prefix, return ret; } +struct rename_entry { + struct object_id oid; + char *msg; +}; + +struct rename_data { + struct rename_entry *entries; + size_t nr, alloc; + size_t want; +}; + +static int collect_rename_entries(const char *refname UNUSED, + struct object_id *old_oid UNUSED, + struct object_id *new_oid, + const char *committer UNUSED, + timestamp_t timestamp UNUSED, + int tz UNUSED, const char *msg, + void *cb_data) +{ + struct rename_data *data = cb_data; + const char *eol = strchrnul(msg, '\n'); + + ALLOC_GROW(data->entries, data->nr + 1, data->alloc); + oidcpy(&data->entries[data->nr].oid, new_oid); + data->entries[data->nr].msg = xstrndup(msg, eol - msg); + data->nr++; + + return data->nr >= data->want; +} + +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 store_rename_entry(struct rename_entry *entry, const char *msg) +{ + if (!do_store_stash(&entry->oid, msg, 1)) + return 0; + warning(_("could not restore stash entry %s; " + "recover it with 'git stash store %s'"), + oid_to_hex(&entry->oid), oid_to_hex(&entry->oid)); + return -1; +} + +static int do_rename_stash(struct stash_info *info, size_t idx, + const char *msg, int quiet) +{ + struct rename_data data = { .want = idx + 1 }; + size_t i, missing = 0; + int ret = -1; + + refs_for_each_reflog_ent_reverse(get_main_ref_store(the_repository), + ref_stash, collect_rename_entries, + &data); + if (data.nr <= idx) { + error(_("%s does not exist"), info->revision.buf); + goto cleanup; + } + + if (!oideq(&info->w_commit, &data.entries[idx].oid)) { + error(_("%s changed concurrently; try again"), + info->revision.buf); + goto cleanup; + } + + /* refuse up front; do_store_stash() would die halfway through */ + for (i = 0; i < data.nr; i++) { + struct commit *stash = lookup_commit_reference(the_repository, + &data.entries[i].oid); + + if (!stash || check_stash_topology(the_repository, stash)) { + error(_("%s does not look like a stash commit"), + oid_to_hex(&data.entries[i].oid)); + goto cleanup; + } + } + + while (missing <= idx) { + if (drop_reflog_entry("stash@{0}")) + goto restore; + missing++; + } + + ret = 0; + while (missing) { + i = missing - 1; + if (store_rename_entry(&data.entries[i], + i == idx ? msg : data.entries[i].msg)) + ret = -1; + missing--; + } + + if (!ret && !quiet) + printf_ln(_("Renamed %s (%s)"), info->revision.buf, + oid_to_hex(&data.entries[idx].oid)); + goto cleanup; + +restore: + /* dropping failed midway; put the dropped entries back */ + while (missing) { + store_rename_entry(&data.entries[missing - 1], + data.entries[missing - 1].msg); + missing--; + } +cleanup: + for (i = 0; i < data.nr; i++) + free(data.entries[i].msg); + free(data.entries); + return ret; +} + +static int rename_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_rename_usage, 0); + + if (!argc) + usage_with_options(git_stash_rename_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; + + /* positions must stay stable across the drop-and-store sequence */ + if (parse_stash_index(info.revision.buf, &idx)) { + error(_("cannot rename '%s': name the entry by index, " + "like 'stash@{1}'"), info.revision.buf); + goto cleanup; + } + + ret = do_rename_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 +2646,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("rename", &fn, rename_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..08c53cea49010e 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 rename' 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,*|rename,*) __gitcomp_nl "$(__git stash list \ | sed -n -e 's/:.*//p')" ;; diff --git a/t/t3903-stash.sh b/t/t3903-stash.sh index ecc35aae82a5fe..f175302c1a455e 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 'rename a stash entry' ' + git stash clear && + >file-to-rename && + git add file-to-rename && + git stash push -m "original message" && + git stash rename "new message" stash@{0} >out && + test_grep "Renamed stash@{0}" out && + git stash list >list && + test_grep "stash@{0}: new message" list && + test_grep ! "original message" list +' + +test_expect_success 'rename defaults to the latest stash entry' ' + git stash rename "default target" >out && + test_grep "Renamed refs/stash@{0}" out && + git stash list >list && + test_grep "stash@{0}: default target" list +' + +test_expect_success 'rename 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 rename "renamed 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}: renamed middle" list && + test_grep "stash@{2}: On.*message 1" list +' + +test_expect_success 'rename the deepest stash entry' ' + git rev-parse stash@{0} stash@{1} stash@{2} >expect && + git stash rename "renamed 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}: renamed deepest" list +' + +test_expect_success 'rename accepts a bare index and honors --quiet' ' + git stash rename -q "quietly renamed" 1 >out && + test_must_be_empty out && + git stash list >list && + test_grep "stash@{1}: quietly renamed" list +' + +test_expect_success 'rename rejects bad arguments' ' + test_must_fail git stash rename "no such entry" stash@{99} && + test_must_fail git stash rename "" && + test_must_fail git stash rename " " && + test_must_fail git stash rename "not a stash" HEAD && + test_must_fail git stash rename "not an index" "stash@{now}" && + test_expect_code 129 git stash rename && + git stash list >list && + test_grep "stash@{1}: quietly renamed" list +' + +test_expect_success 'rename 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 rename "renamed A" stash@{2} && + git stash list >actual && + test_cmp expect actual +' + test_done