Merge branch 'ps/history-drop' into seen

The experimental "git history" command has been taught a new "drop"
subcommand to remove a commit and replay its descendants onto its
parent.

* ps/history-drop:
  builtin/history: implement "drop" subcommand
  builtin/history: split handling of ref updates into two phases
  reset: stop assuming that the caller passes in a clean index
  reset: allow the caller to specify the current HEAD object
  reset: introduce ability to skip reference updates
  reset: introduce dry-run mode
  reset: modernize flags passed to `reset_head()`
  reset: drop `USE_THE_REPOSITORY_VARIABLE`
  read-cache: split out function to drop unmerged entries to stage 0
This commit is contained in:
Junio C Hamano
2026-06-04 09:04:50 +09:00
10 changed files with 905 additions and 88 deletions

View File

@@ -8,6 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history
SYNOPSIS
--------
[synopsis]
git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)]
git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
@@ -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 <commit>`::
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 <commit>`::
Apply the currently staged changes to the specified commit. This is
similar in nature to `git commit --fixup=<commit>` 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
~~~~~~~~~~~~~~

View File

@@ -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 <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)]")
#define GIT_HISTORY_FIXUP_USAGE \
N_("git history fixup <commit> [--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),

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

91
reset.c
View File

@@ -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;

44
reset.h
View File

@@ -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;
/*

View File

@@ -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),

View File

@@ -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',

513
t/t3454-history-drop.sh Executable file
View File

@@ -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 <updates &&
expect_log main <<-\EOF
ours
base
EOF
)
'
test_expect_success '--update-refs=head updates only HEAD' '
test_when_finished "rm -rf repo" &&
git init repo --initial-branch=main &&
(
cd repo &&
test_commit base &&
test_commit target &&
git branch theirs &&
test_commit ours &&
git switch theirs &&
test_commit theirs &&
# When told to update HEAD only, the command refuses to
# rewrite commits that are not an ancestor of HEAD.
test_must_fail git history drop --update-refs=head main 2>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