diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc index 2ba8121795..4eac732fd2 100644 --- a/Documentation/git-history.adoc +++ b/Documentation/git-history.adoc @@ -8,6 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history SYNOPSIS -------- [synopsis] +git history drop [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)] git history fixup [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)] git history reword [--dry-run] [--update-refs=(branches|head)] git history split [--dry-run] [--update-refs=(branches|head)] [--] [...] @@ -51,13 +52,28 @@ be stateful operations. The limitation can be lifted once (if) Git learns about first-class conflicts. When using `fixup` with `--empty=drop`, dropping the root commit is not yet -supported. +supported. Likewise, `drop` cannot remove the root commit or a merge commit. COMMANDS -------- The following commands are available to rewrite history in different ways: +`drop `:: + Remove the specified commit from the history. All descendants of the + commit are replayed directly onto its parent. ++ +The root commit cannot be dropped as that may lead to edge cases where refs +end up with no commits anymore. Merge commits cannot be dropped either; see +LIMITATIONS. ++ +If `HEAD` points at a commit that is to be rewritten, the index and working +tree are updated to match the new `HEAD`. The command aborts before any +references are updated in case local modifications would be overwritten. ++ +If replaying any descendant would result in a conflict, the command aborts +with an error. + `fixup `:: Apply the currently staged changes to the specified commit. This is similar in nature to `git commit --fixup=` followed by `git @@ -170,6 +186,26 @@ The staged addition of `unrelated.txt` has been incorporated into the `first` commit. All descendant commits have been replayed on top of the rewritten history. +Drop a commit +~~~~~~~~~~~~~ + +---------- +$ git log --oneline +abc1234 (HEAD -> main) third +def5678 second +ghi9012 first + +$ git history drop def5678 + +$ git log --oneline +jkl3456 (HEAD -> main) third +ghi9012 first +---------- + +The `second` commit has been removed from the history, and `third` has been +replayed directly on top of `first`. All branches that pointed at the dropped +commit have been moved to its parent. + Split a commit ~~~~~~~~~~~~~~ diff --git a/builtin/history.c b/builtin/history.c index 091465a59e..5342cc805f 100644 --- a/builtin/history.c +++ b/builtin/history.c @@ -17,13 +17,17 @@ #include "read-cache.h" #include "refs.h" #include "replay.h" +#include "reset.h" #include "revision.h" #include "sequencer.h" #include "strvec.h" #include "tree.h" +#include "tree-walk.h" #include "unpack-trees.h" #include "wt-status.h" +#define GIT_HISTORY_DROP_USAGE \ + N_("git history drop [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)]") #define GIT_HISTORY_FIXUP_USAGE \ N_("git history fixup [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]") #define GIT_HISTORY_REWORD_USAGE \ @@ -333,21 +337,17 @@ static int handle_ref_update(struct ref_transaction *transaction, NULL, NULL, 0, reflog_msg, err); } -static int handle_reference_updates(struct rev_info *revs, - enum ref_action action, - struct commit *original, - struct commit *rewritten, - const char *reflog_msg, - int dry_run, - enum replay_empty_commit_action empty) +static int compute_pending_ref_updates(struct rev_info *revs, + enum ref_action action, + struct commit *original, + struct commit *rewritten, + enum replay_empty_commit_action empty, + struct replay_result *result) { const struct name_decoration *decoration; struct replay_revisions_options opts = { .empty = empty, }; - struct replay_result result = { 0 }; - struct ref_transaction *transaction = NULL; - struct strbuf err = STRBUF_INIT; char hex[GIT_MAX_HEXSZ + 1]; bool detached_head; int head_flags = 0; @@ -359,34 +359,13 @@ static int handle_reference_updates(struct rev_info *revs, opts.onto = oid_to_hex_r(hex, &rewritten->object.oid); - ret = replay_revisions(revs, &opts, &result); + ret = replay_revisions(revs, &opts, result); if (ret) - goto out; + return ret; if (action != REF_ACTION_BRANCHES && action != REF_ACTION_HEAD) BUG("unsupported ref action %d", action); - if (!dry_run) { - transaction = ref_store_transaction_begin(get_main_ref_store(revs->repo), 0, &err); - if (!transaction) { - ret = error(_("failed to begin ref transaction: %s"), err.buf); - goto out; - } - } - - for (size_t i = 0; i < result.updates_nr; i++) { - ret = handle_ref_update(transaction, - result.updates[i].refname, - &result.updates[i].new_oid, - &result.updates[i].old_oid, - reflog_msg, &err); - if (ret) { - ret = error(_("failed to update ref '%s': %s"), - result.updates[i].refname, err.buf); - goto out; - } - } - /* * `replay_revisions()` only updates references that are * ancestors of `rewritten`, so we need to manually @@ -414,14 +393,43 @@ static int handle_reference_updates(struct rev_info *revs, !detached_head) continue; + ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc); + result->updates[result->updates_nr].refname = xstrdup(decoration->name); + result->updates[result->updates_nr].old_oid = original->object.oid; + result->updates[result->updates_nr].new_oid = rewritten->object.oid; + result->updates_nr++; + } + + return 0; +} + +static int apply_pending_ref_updates(struct repository *repo, + const struct replay_result *result, + const char *reflog_msg, + int dry_run) +{ + struct ref_transaction *transaction = NULL; + struct strbuf err = STRBUF_INIT; + int ret; + + if (!dry_run) { + transaction = ref_store_transaction_begin(get_main_ref_store(repo), + 0, &err); + if (!transaction) { + ret = error(_("failed to begin ref transaction: %s"), err.buf); + goto out; + } + } + + for (size_t i = 0; i < result->updates_nr; i++) { ret = handle_ref_update(transaction, - decoration->name, - &rewritten->object.oid, - &original->object.oid, + result->updates[i].refname, + &result->updates[i].new_oid, + &result->updates[i].old_oid, reflog_msg, &err); if (ret) { ret = error(_("failed to update ref '%s': %s"), - decoration->name, err.buf); + result->updates[i].refname, err.buf); goto out; } } @@ -435,11 +443,33 @@ static int handle_reference_updates(struct rev_info *revs, out: ref_transaction_free(transaction); - replay_result_release(&result); strbuf_release(&err); return ret; } +static int handle_reference_updates(struct rev_info *revs, + enum ref_action action, + struct commit *original, + struct commit *rewritten, + const char *reflog_msg, + int dry_run, + enum replay_empty_commit_action empty) +{ + struct replay_result result = { 0 }; + int ret; + + ret = compute_pending_ref_updates(revs, action, original, rewritten, + empty, &result); + if (ret) + goto out; + + ret = apply_pending_ref_updates(revs->repo, &result, reflog_msg, dry_run); + +out: + replay_result_release(&result); + return ret; +} + static int commit_became_empty(struct repository *repo, struct commit *original, struct tree *result) @@ -975,12 +1005,194 @@ out: return ret; } +static int update_worktree(struct repository *repo, + const struct commit *old_head, + const struct commit *new_head, + bool dry_run) +{ + struct reset_head_opts opts = { + .oid_from = &old_head->object.oid, + .oid = &new_head->object.oid, + .flags = RESET_HEAD_SKIP_REF_UPDATES, + }; + if (dry_run) + opts.flags |= RESET_HEAD_DRY_RUN; + return reset_head(repo, &opts); +} + +static int find_head_tree_change(struct repository *repo, + const struct replay_result *result, + struct commit **old_head, + struct commit **new_head, + bool *changed) +{ + const struct replay_ref_update *head_update = NULL; + struct commit *old_head_commit, *new_head_commit; + struct tree *old_head_tree, *new_head_tree; + const char *head_target; + int head_flags; + + *changed = false; + + head_target = refs_resolve_ref_unsafe(get_main_ref_store(repo), + "HEAD", RESOLVE_REF_NO_RECURSE, + NULL, &head_flags); + if (!head_target) + return error(_("cannot look up HEAD")); + if (!(head_flags & REF_ISSYMREF)) + head_target = "HEAD"; + + for (size_t i = 0; i < result->updates_nr; i++) { + if (!strcmp(result->updates[i].refname, head_target)) { + head_update = &result->updates[i]; + break; + } + } + + if (!head_update) + return 0; + + old_head_commit = lookup_commit_reference(repo, &head_update->old_oid); + new_head_commit = lookup_commit_reference(repo, &head_update->new_oid); + if (!old_head_commit || !new_head_commit) + return error(_("cannot resolve HEAD commit")); + + old_head_tree = repo_get_commit_tree(repo, old_head_commit); + new_head_tree = repo_get_commit_tree(repo, new_head_commit); + if (!old_head_tree || !new_head_tree) + return error(_("cannot resolve tree for HEAD")); + + if (oideq(&old_head_tree->object.oid, &new_head_tree->object.oid)) + return 0; + + *old_head = old_head_commit; + *new_head = new_head_commit; + *changed = true; + + return 0; +} + +static int cmd_history_drop(int argc, + const char **argv, + const char *prefix, + struct repository *repo) +{ + const char * const usage[] = { + GIT_HISTORY_DROP_USAGE, + NULL, + }; + enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP; + enum ref_action action = REF_ACTION_DEFAULT; + int dry_run = 0; + struct option options[] = { + OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)", + N_("control which refs should be updated"), + PARSE_OPT_NONEG, parse_ref_action), + OPT_BOOL('n', "dry-run", &dry_run, + N_("perform a dry-run without updating any refs")), + OPT_CALLBACK_F(0, "empty", &empty, "(drop|keep|abort)", + N_("how to handle descendants that become empty"), + PARSE_OPT_NONEG, parse_opt_empty), + OPT_END(), + }; + struct strbuf reflog_msg = STRBUF_INIT; + struct commit *original, *rewritten; + struct rev_info revs = { 0 }; + struct replay_result result = { 0 }; + struct commit *old_head, *new_head; + bool head_moves = false; + int ret; + + argc = parse_options(argc, argv, prefix, options, usage, 0); + if (argc != 1) { + ret = error(_("command expects a single revision")); + goto out; + } + repo_config(repo, git_default_config, NULL); + + if (action == REF_ACTION_DEFAULT) + action = REF_ACTION_BRANCHES; + + original = lookup_commit_reference_by_name(argv[0]); + if (!original) { + ret = error(_("commit cannot be found: %s"), argv[0]); + goto out; + } + + if (!original->parents) { + ret = error(_("cannot drop root commit %s: " + "it has no parent to replay onto"), + argv[0]); + goto out; + } else if (original->parents->next) { + ret = error(_("cannot drop merge commit: %s"), argv[0]); + goto out; + } + + ret = setup_revwalk(repo, action, original, &revs); + if (ret) + goto out; + + rewritten = original->parents->item; + + ret = compute_pending_ref_updates(&revs, action, original, rewritten, + empty, &result); + if (ret) { + ret = error(_("failed replaying descendants")); + goto out; + } + + /* + * If HEAD will move as a result of the rewrite then we'll have to + * merge in the changes into the worktree and index. This merge can of + * course conflict, which will cause the whole operation to abort. + * + * If we had already updated the refs at that point then we'd have an + * inconsistent repository state. So we first perform a dry-run merge + * here before updating refs. + */ + if (!dry_run && !is_bare_repository()) { + ret = find_head_tree_change(repo, &result, &old_head, + &new_head, &head_moves); + if (ret < 0) + goto out; + + if (head_moves && update_worktree(repo, old_head, new_head, true) < 0) { + ret = error(_("dropping this commit would " + "overwrite local changes; aborting")); + goto out; + } + } + + strbuf_addf(&reflog_msg, "drop: dropping %s", argv[0]); + ret = apply_pending_ref_updates(repo, &result, reflog_msg.buf, dry_run); + if (ret < 0) { + ret = error(_("failed to update references")); + goto out; + } + + if (head_moves && update_worktree(repo, old_head, new_head, false) < 0) { + ret = error(_("could not update working tree to new commit %s"), + oid_to_hex(&new_head->object.oid)); + goto out; + } + + ret = 0; + +out: + replay_result_release(&result); + strbuf_release(&reflog_msg); + release_revisions(&revs); + return ret; +} + int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo) { const char * const usage[] = { + GIT_HISTORY_DROP_USAGE, GIT_HISTORY_FIXUP_USAGE, GIT_HISTORY_REWORD_USAGE, GIT_HISTORY_SPLIT_USAGE, @@ -988,6 +1200,7 @@ int cmd_history(int argc, }; parse_opt_subcommand_fn *fn = NULL; struct option options[] = { + OPT_SUBCOMMAND("drop", &fn, cmd_history_drop), OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup), OPT_SUBCOMMAND("reword", &fn, cmd_history_reword), OPT_SUBCOMMAND("split", &fn, cmd_history_split), diff --git a/builtin/rebase.c b/builtin/rebase.c index fa4f5d9306..6351a3aa32 100644 --- a/builtin/rebase.c +++ b/builtin/rebase.c @@ -1876,7 +1876,7 @@ int cmd_rebase(int argc, options.reflog_action, options.onto_name); ropts.oid = &options.onto->object.oid; ropts.orig_head = &options.orig_head->object.oid; - ropts.flags = RESET_HEAD_DETACH | RESET_ORIG_HEAD | + ropts.flags = RESET_HEAD_DETACH | RESET_HEAD_ORIG_HEAD | RESET_HEAD_RUN_POST_CHECKOUT_HOOK; ropts.head_msg = msg.buf; ropts.default_reflog_action = options.reflog_action; diff --git a/read-cache-ll.h b/read-cache-ll.h index 9fb9bedfbf..8a693baf1a 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -309,6 +309,7 @@ int write_locked_index(struct index_state *, struct lock_file *lock, unsigned fl void discard_index(struct index_state *); void move_index_extensions(struct index_state *dst, struct index_state *src); int unmerged_index(const struct index_state *); +int index_state_unmerged_to_stage0(struct index_state *istate); /** * Returns 1 if istate differs from tree, 0 otherwise. If tree is NULL, diff --git a/read-cache.c b/read-cache.c index 267a8bb707..046f00fc5f 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3428,13 +3428,15 @@ out: */ int repo_read_index_unmerged(struct repository *repo) { - struct index_state *istate; - int i; + repo_read_index(repo); + return index_state_unmerged_to_stage0(repo->index); +} + +int index_state_unmerged_to_stage0(struct index_state *istate) +{ int unmerged = 0; - repo_read_index(repo); - istate = repo->index; - for (i = 0; i < istate->cache_nr; i++) { + for (unsigned int i = 0; i < istate->cache_nr; i++) { struct cache_entry *ce = istate->cache[i]; struct cache_entry *new_ce; int len; diff --git a/reset.c b/reset.c index 46e30e6394..05eb80216c 100644 --- a/reset.c +++ b/reset.c @@ -1,5 +1,3 @@ -#define USE_THE_REPOSITORY_VARIABLE - #include "git-compat-util.h" #include "cache-tree.h" #include "gettext.h" @@ -13,13 +11,14 @@ #include "unpack-trees.h" #include "hook.h" -static int update_refs(const struct reset_head_opts *opts, +static int update_refs(struct repository *repo, + const struct reset_head_opts *opts, const struct object_id *oid, const struct object_id *head) { unsigned detach_head = opts->flags & RESET_HEAD_DETACH; unsigned run_hook = opts->flags & RESET_HEAD_RUN_POST_CHECKOUT_HOOK; - unsigned update_orig_head = opts->flags & RESET_ORIG_HEAD; + unsigned update_orig_head = opts->flags & RESET_HEAD_ORIG_HEAD; const struct object_id *orig_head = opts->orig_head; const char *switch_to_branch = opts->branch; const char *reflog_branch = opts->branch_msg; @@ -42,19 +41,19 @@ static int update_refs(const struct reset_head_opts *opts, prefix_len = msg.len; if (update_orig_head) { - if (!repo_get_oid(the_repository, "ORIG_HEAD", &oid_old_orig)) + if (!repo_get_oid(repo, "ORIG_HEAD", &oid_old_orig)) old_orig = &oid_old_orig; if (head) { if (!reflog_orig_head) { strbuf_addstr(&msg, "updating ORIG_HEAD"); reflog_orig_head = msg.buf; } - refs_update_ref(get_main_ref_store(the_repository), + refs_update_ref(get_main_ref_store(repo), reflog_orig_head, "ORIG_HEAD", orig_head ? orig_head : head, old_orig, 0, UPDATE_REFS_MSG_ON_ERR); } else if (old_orig) - refs_delete_ref(get_main_ref_store(the_repository), + refs_delete_ref(get_main_ref_store(repo), NULL, "ORIG_HEAD", old_orig, 0); } @@ -64,23 +63,23 @@ static int update_refs(const struct reset_head_opts *opts, reflog_head = msg.buf; } if (!switch_to_branch) - ret = refs_update_ref(get_main_ref_store(the_repository), + ret = refs_update_ref(get_main_ref_store(repo), reflog_head, "HEAD", oid, head, detach_head ? REF_NO_DEREF : 0, UPDATE_REFS_MSG_ON_ERR); else { - ret = refs_update_ref(get_main_ref_store(the_repository), + ret = refs_update_ref(get_main_ref_store(repo), reflog_branch ? reflog_branch : reflog_head, switch_to_branch, oid, NULL, 0, UPDATE_REFS_MSG_ON_ERR); if (!ret) - ret = refs_update_symref(get_main_ref_store(the_repository), + ret = refs_update_symref(get_main_ref_store(repo), "HEAD", switch_to_branch, reflog_head); } if (!ret && run_hook) - run_hooks_l(the_repository, "post-checkout", - oid_to_hex(head ? head : null_oid(the_hash_algo)), + run_hooks_l(repo, "post-checkout", + oid_to_hex(head ? head : null_oid(repo->hash_algo)), oid_to_hex(oid), "1", NULL); strbuf_release(&msg); return ret; @@ -92,12 +91,16 @@ int reset_head(struct repository *r, const struct reset_head_opts *opts) const char *switch_to_branch = opts->branch; unsigned reset_hard = opts->flags & RESET_HEAD_HARD; unsigned refs_only = opts->flags & RESET_HEAD_REFS_ONLY; - unsigned update_orig_head = opts->flags & RESET_ORIG_HEAD; + unsigned update_orig_head = opts->flags & RESET_HEAD_ORIG_HEAD; + unsigned dry_run = opts->flags & RESET_HEAD_DRY_RUN; + unsigned skip_ref_updates = opts->flags & RESET_HEAD_SKIP_REF_UPDATES; struct object_id *head = NULL, head_oid; struct tree_desc desc[2] = { { NULL }, { NULL } }; struct lock_file lock = LOCK_INIT; struct unpack_trees_options unpack_tree_opts = { 0 }; struct tree *tree; + struct index_state scratch_index = INDEX_STATE_INIT(r); + struct index_state *istate; const char *action; int ret = 0, nr = 0; @@ -110,12 +113,18 @@ int reset_head(struct repository *r, const struct reset_head_opts *opts) if (opts->branch_msg && !opts->branch) BUG("branch reflog message given without a branch"); - if (!refs_only && repo_hold_locked_index(r, &lock, LOCK_REPORT_ON_ERROR) < 0) { + if (skip_ref_updates && (opts->branch || refs_only)) + BUG("asked to perform ref updates and skip them at the same time"); + + if (!refs_only && !dry_run && repo_hold_locked_index(r, &lock, LOCK_REPORT_ON_ERROR) < 0) { ret = -1; goto leave_reset_head; } - if (!repo_get_oid(r, "HEAD", &head_oid)) { + if (opts->oid_from) { + oidcpy(&head_oid, opts->oid_from); + head = &head_oid; + } else if (!repo_get_oid(r, "HEAD", &head_oid)) { head = &head_oid; } else if (!oid || !reset_hard) { ret = error(_("could not determine HEAD revision")); @@ -125,26 +134,42 @@ int reset_head(struct repository *r, const struct reset_head_opts *opts) if (!oid) oid = &head_oid; - if (refs_only) - return update_refs(opts, oid, head); + if (refs_only) { + if (!dry_run) + return update_refs(r, opts, oid, head); + return 0; + } + + if (dry_run) { + if (read_index_from(&scratch_index, r->index_file, r->gitdir) < 0 || + index_state_unmerged_to_stage0(&scratch_index) < 0) { + ret = error(_("could not read index")); + goto leave_reset_head; + } + + istate = &scratch_index; + } else { + if (repo_read_index_unmerged(r) < 0) { + ret = error(_("could not read index")); + goto leave_reset_head; + } + istate = r->index; + } action = reset_hard ? "reset" : "checkout"; setup_unpack_trees_porcelain(&unpack_tree_opts, action); unpack_tree_opts.head_idx = 1; - unpack_tree_opts.src_index = r->index; - unpack_tree_opts.dst_index = r->index; + unpack_tree_opts.src_index = istate; + unpack_tree_opts.dst_index = istate; unpack_tree_opts.fn = reset_hard ? oneway_merge : twoway_merge; - unpack_tree_opts.update = 1; + unpack_tree_opts.update = !dry_run; + unpack_tree_opts.dry_run = dry_run; unpack_tree_opts.merge = 1; unpack_tree_opts.preserve_ignored = 0; /* FIXME: !overwrite_ignore */ - unpack_tree_opts.skip_cache_tree_update = 1; init_checkout_metadata(&unpack_tree_opts.meta, switch_to_branch, oid, NULL); - if (reset_hard) + if (reset_hard) { + unpack_tree_opts.skip_cache_tree_update = 1; unpack_tree_opts.reset = UNPACK_RESET_PROTECT_UNTRACKED; - - if (repo_read_index_unmerged(r) < 0) { - ret = error(_("could not read index")); - goto leave_reset_head; } if (!reset_hard && !fill_tree_descriptor(r, &desc[nr++], &head_oid)) { @@ -163,25 +188,31 @@ int reset_head(struct repository *r, const struct reset_head_opts *opts) goto leave_reset_head; } - tree = repo_parse_tree_indirect(the_repository, oid); + if (dry_run) + goto leave_reset_head; + + tree = repo_parse_tree_indirect(r, oid); if (!tree) { ret = error(_("unable to read tree (%s)"), oid_to_hex(oid)); goto leave_reset_head; } - prime_cache_tree(r, r->index, tree); + if (reset_hard) + prime_cache_tree(r, r->index, tree); if (write_locked_index(r->index, &lock, COMMIT_LOCK) < 0) { ret = error(_("could not write index")); goto leave_reset_head; } - if (oid != &head_oid || update_orig_head || switch_to_branch) - ret = update_refs(opts, oid, head); + if (!skip_ref_updates && + (oid != &head_oid || update_orig_head || switch_to_branch)) + ret = update_refs(r, opts, oid, head); leave_reset_head: rollback_lock_file(&lock); clear_unpack_trees_porcelain(&unpack_tree_opts); + release_index(&scratch_index); while (nr) free((void *)desc[--nr].buffer); return ret; diff --git a/reset.h b/reset.h index a28f81829d..51ce114543 100644 --- a/reset.h +++ b/reset.h @@ -6,22 +6,42 @@ #define GIT_REFLOG_ACTION_ENVIRONMENT "GIT_REFLOG_ACTION" -/* Request a detached checkout */ -#define RESET_HEAD_DETACH (1<<0) -/* Request a reset rather than a checkout */ -#define RESET_HEAD_HARD (1<<1) -/* Run the post-checkout hook */ -#define RESET_HEAD_RUN_POST_CHECKOUT_HOOK (1<<2) -/* Only update refs, do not touch the worktree */ -#define RESET_HEAD_REFS_ONLY (1<<3) -/* Update ORIG_HEAD as well as HEAD */ -#define RESET_ORIG_HEAD (1<<4) +enum reset_head_flags { + /* Request a detached checkout */ + RESET_HEAD_DETACH = (1 << 0), + + /* Request a reset rather than a checkout */ + RESET_HEAD_HARD = (1 << 1), + + /* Run the post-checkout hook */ + RESET_HEAD_RUN_POST_CHECKOUT_HOOK = (1 << 2), + + /* Only update refs, do not touch the worktree */ + RESET_HEAD_REFS_ONLY = (1 << 3), + + /* Update ORIG_HEAD as well as HEAD */ + RESET_HEAD_ORIG_HEAD = (1 << 4), + + /* + * Perform a dry-run by performing the operation without updating + * any user-visible state. + */ + RESET_HEAD_DRY_RUN = (1 << 5), + + /* Skip updating any references, only update the worktree and index. */ + RESET_HEAD_SKIP_REF_UPDATES = (1 << 6), +}; struct reset_head_opts { /* * The commit to checkout/reset to. Defaults to HEAD. */ const struct object_id *oid; + /* + * The commit to checkout/reset from when doing a two-way merge. This + * is used as one of the sides to merge. + */ + const struct object_id *oid_from; /* * Optional value to set ORIG_HEAD. Defaults to HEAD. */ @@ -33,7 +53,7 @@ struct reset_head_opts { /* * Flags defined above. */ - unsigned flags; + enum reset_head_flags flags; /* * Optional reflog message for branch, defaults to head_msg. */ @@ -45,7 +65,7 @@ struct reset_head_opts { const char *head_msg; /* * Optional reflog message for ORIG_HEAD, if this omitted and flags - * contains RESET_ORIG_HEAD then default_reflog_action must be given. + * contains RESET_HEAD_ORIG_HEAD then default_reflog_action must be given. */ const char *orig_head_msg; /* diff --git a/sequencer.c b/sequencer.c index 1378a6e303..49f069e302 100644 --- a/sequencer.c +++ b/sequencer.c @@ -4885,7 +4885,7 @@ static int checkout_onto(struct repository *r, struct replay_opts *opts, struct reset_head_opts ropts = { .oid = onto, .orig_head = orig_head, - .flags = RESET_HEAD_DETACH | RESET_ORIG_HEAD | + .flags = RESET_HEAD_DETACH | RESET_HEAD_ORIG_HEAD | RESET_HEAD_RUN_POST_CHECKOUT_HOOK, .head_msg = reflog_message(opts, "start", "checkout %s", onto_name), diff --git a/t/meson.build b/t/meson.build index 26247d96d8..871695776e 100644 --- a/t/meson.build +++ b/t/meson.build @@ -400,6 +400,7 @@ integration_tests = [ 't3451-history-reword.sh', 't3452-history-split.sh', 't3453-history-fixup.sh', + 't3454-history-drop.sh', 't3500-cherry.sh', 't3501-revert-cherry-pick.sh', 't3502-cherry-pick-merge.sh', diff --git a/t/t3454-history-drop.sh b/t/t3454-history-drop.sh new file mode 100755 index 0000000000..37d8413e7e --- /dev/null +++ b/t/t3454-history-drop.sh @@ -0,0 +1,513 @@ +#!/bin/sh + +test_description='tests for git-history drop subcommand' + +. ./test-lib.sh +. "$TEST_DIRECTORY/lib-log-graph.sh" + +expect_graph () { + cat >expect && + lib_test_cmp_graph --format=%s "$@" +} + +expect_log () { + git log --format="%s" "$@" >actual && + cat >expect && + test_cmp expect actual +} + +test_expect_success 'errors on missing commit argument' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + test_must_fail git history drop 2>err && + test_grep "command expects a single revision" err + ) +' + +test_expect_success 'errors on too many arguments' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + test_must_fail git history drop HEAD HEAD 2>err && + test_grep "command expects a single revision" err + ) +' + +test_expect_success 'errors on unknown revision' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + test_must_fail git history drop does-not-exist 2>err && + test_grep "commit cannot be found: does-not-exist" err + ) +' + +test_expect_success 'errors with invalid --empty= value' ' + test_when_finished "rm -rf repo" && + git init repo && + test_commit -C repo initial && + test_commit -C repo second && + test_must_fail git -C repo history drop --empty=bogus HEAD 2>err && + test_grep "unrecognized.*--empty.*bogus" err +' + +test_expect_success 'drops a commit in the middle and replays descendants' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + test_commit second && + test_commit third && + + git symbolic-ref HEAD >expect && + git history drop HEAD~ && + git symbolic-ref HEAD >actual && + test_cmp expect actual && + + expect_log <<-\EOF && + third + first + EOF + + test_must_fail git show HEAD:second.t && + test_path_is_missing second.t && + + git reflog >reflog && + test_grep "drop: dropping HEAD~" reflog + ) +' + +test_expect_success 'drops the HEAD commit' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + test_commit second && + + git history drop HEAD && + + expect_log <<-\EOF + first + EOF + ) +' + +test_expect_success 'drops a commit on detached HEAD' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + test_commit second && + test_commit third && + git checkout --detach HEAD && + + git history drop HEAD~ && + + expect_log <<-\EOF + third + first + EOF + ) +' + +# Note: in this case it would actually be fine to drop the root commit, as we +# do have a descendant commit, and no reference points to the root commit +# directly. So this is something that we may relax eventually. +test_expect_success 'refuses to drop the root commit' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + test_commit second && + + test_must_fail git history drop HEAD~ 2>err && + test_grep "cannot drop root commit" err + ) +' + +# In contrast to the above case, we actually don't want to drop the root commit +# here as that would cause us to end up with an empty commit graph. +test_expect_success 'refuses to drop the root commit when branch becomes empty' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + + test_must_fail git history drop HEAD 2>err && + test_grep "cannot drop root commit" err + ) +' + +test_expect_success 'refuses to drop a merge commit' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit base && + git branch branch && + test_commit ours && + git switch branch && + test_commit theirs && + git switch - && + git merge theirs && + + test_must_fail git history drop HEAD 2>err && + test_grep "cannot drop merge commit" err + ) +' + +test_expect_success 'refuses when descendants contain a merge commit' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit base && + test_commit middle && + git branch branch && + test_commit ours && + git switch branch && + test_commit theirs && + git switch - && + git merge theirs && + + test_must_fail git history drop middle 2>err && + test_grep "replaying merge commits is not supported yet" err + ) +' + +test_expect_success 'works in a bare repository' ' + test_when_finished "rm -rf repo repo.git" && + + git init repo && + test_commit -C repo first && + test_commit -C repo second && + test_commit -C repo third && + + git clone --bare repo repo.git && + ( + cd repo.git && + + git history drop HEAD~ && + expect_log <<-\EOF + third + first + EOF + ) +' + +test_expect_success 'updates branches on other lines of descent' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit base && + test_commit target && + git branch theirs && + test_commit ours && + git switch theirs && + test_commit theirs && + + expect_graph --branches <<-\EOF && + * theirs + | * ours + |/ + * target + * base + EOF + + git history drop target && + + expect_graph --branches <<-\EOF + * ours + | * theirs + |/ + * base + EOF + ) +' + +test_expect_success 'moves branch pointing at dropped commit to its parent' ' + test_when_finished "rm -rf repo" && + git init repo --initial-branch=main && + ( + cd repo && + test_commit first && + test_commit second && + git branch points-at-second && + test_commit third && + + git rev-parse first >expect && + git history drop second && + git rev-parse points-at-second >actual && + test_cmp expect actual && + + expect_log --format="%s %D" --branches <<-\EOF + third HEAD -> main + first tag: first, points-at-second + EOF + ) +' + +test_expect_success '--dry-run prints ref updates without modifying repo' ' + test_when_finished "rm -rf repo" && + git init repo --initial-branch=main && + ( + cd repo && + test_commit base && + git branch branch && + test_commit middle && + test_commit ours && + git switch branch && + test_commit theirs && + + git refs list >refs-expect && + git history drop --dry-run main~ >updates && + git refs list >refs-actual && + test_cmp refs-expect refs-actual && + test_grep "update refs/heads/main" updates && + + git update-ref --stdin err && + test_grep "rewritten commit must be an ancestor of HEAD" err && + + expect_graph --branches <<-\EOF && + * theirs + | * ours + |/ + * target + * base + EOF + + git switch main && + git history drop --update-refs=head target && + + expect_graph --branches <<-\EOF + * ours + | * theirs + | * target + |/ + * base + EOF + ) +' + +test_expect_success 'conflict with replayed commit aborts cleanly' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit base && + test_commit conflict-a file && + test_commit conflict-b file && + + git refs list >refs-expect && + test_must_fail git history drop HEAD~ 2>err && + test_grep "failed replaying descendants" err && + git refs list >refs-actual && + test_cmp refs-expect refs-actual + ) +' + +# Build a history where a descendant of the drop target reverts the change +# introduced by the drop target. After dropping, the descendant's diff applies +# against a tree that already lacks the change, so it becomes empty. +setup_empty_descendant_repo () { + git init "$1" && + ( + cd "$1" && + echo C1 >file && + git add file && + git commit -m "base" && + git tag base && + echo C2 >file && + git add file && + git commit -m "drop-me" && + git tag drop-me && + test_commit middle && + echo C1 >file && + git add file && + git commit -m "revert-drop-me" && + git tag revert-drop-me + ) +} + +test_expect_success '--empty=drop drops descendants that become empty' ' + test_when_finished "rm -rf repo" && + setup_empty_descendant_repo repo && + ( + cd repo && + + git history drop --empty=drop drop-me && + + expect_log <<-\EOF + middle + base + EOF + ) +' + +test_expect_success '--empty=keep keeps descendants that become empty' ' + test_when_finished "rm -rf repo" && + setup_empty_descendant_repo repo && + ( + cd repo && + + git history drop --empty=keep drop-me && + + expect_log <<-\EOF && + revert-drop-me + middle + base + EOF + git diff HEAD~ HEAD >diff && + test_must_be_empty diff + ) +' + +test_expect_success '--empty=abort errors out when a descendant becomes empty' ' + test_when_finished "rm -rf repo" && + setup_empty_descendant_repo repo && + ( + cd repo && + + test_must_fail git history drop --empty=abort drop-me 2>err && + test_grep "became empty after replay" err + ) +' + +test_expect_success 'updates index and worktree when HEAD moves' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + test_commit second && + test_commit third && + + git history drop second && + + # Worktree should no longer contain second.t. + test_path_is_missing second.t && + test_path_is_file first.t && + test_path_is_file third.t && + + # Index and worktree should both match the new HEAD. + git status --porcelain --untracked-files=no >status && + test_must_be_empty status + ) +' + +test_expect_success 'updates worktree when dropping HEAD itself' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + test_commit second && + + git history drop HEAD && + + test_path_is_missing second.t && + test_path_is_file first.t && + + git status --porcelain --untracked-files=no >status && + test_must_be_empty status + ) +' + +test_expect_success 'preserves unrelated unstaged modifications' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + echo first-content >unrelated.txt && + git add unrelated.txt && + git commit -m "add unrelated" && + test_commit second && + test_commit third && + + echo locally-modified >unrelated.txt && + + git diff >diff-expect && + git history drop second && + git diff >diff-actual && + test_cmp diff-expect diff-actual && + test_path_is_missing second.t + ) +' + +test_expect_success 'preserves unrelated staged changes' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + echo first-content >unrelated.txt && + git add unrelated.txt && + git commit -m "add unrelated" && + test_commit second && + test_commit third && + + echo staged-change >unrelated.txt && + git add unrelated.txt && + + git diff --cached >diff-expect && + git history drop second && + git diff --cached >diff-actual && + test_cmp diff-expect diff-actual && + test_path_is_missing second.t + ) +' + +test_expect_success 'aborts when local modifications would be overwritten' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit base && + test_commit conflict && + + echo local-edit >conflict.t && + git diff >diff-expect && + test_must_fail git history drop HEAD 2>err && + test_grep "would overwrite local changes" err && + git diff >diff-actual && + test_cmp diff-expect diff-actual + ) +' + +test_done