Files
git/replay.c
Toon Claes 6af34ada96 replay: offer an option to linearize the commit topology
One of the stated goals of git-replay(1) is to allow implementing the
git-rebase(1) functionality on the server side.

The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
was given. This mode drops merge commits instead of replaying them, and
linearizes the history into a sequence of regular (single-parent)
commits.

Add option `--linearize` to git-replay(1) to do the same. Each replayed
commit is stacked on top of the previously replayed one. When a merge is
encountered, the commits reachable from all of its sides are replayed
into the single line and the merge itself is dropped.

If a ref was pointing to a merge commit, that ref is updated to the
merge's last replayed ancestor.

git-replay(1) accepts multiple revision ranges, for example:

    $ git replay --onto main topic1 topic2

Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'
independently and updates both refs.

For now this is disallowed with option `--linearize`. Linearizing more
than one branch at once would concatenate unrelated histories into a
single line, and update each branch to some point in that line. That
won't be the result most users want, especially because the order
depends on the order of the revision walk, not the order of the branch
names on the command line.

For the same reason disallow the use of `--contained` with
`--linearize`.

Users who want to linearize multiple branches are advised to do this in
separate git-replay(1) invocations. Linearizing multiple branches at
once might be added later.

Note that `--linearize` is not modeled after git-rebase(1)'s
`--rebase-merges[=<mode>]` interface. Recreating merges, by preserving
their topology, is a distinct operation that would be a separate mode.
`--linearize` only drops merges and replays commits linearly. So
git-replay(1) uses its own option rather than reusing that interface.

Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Toon Claes <toon@iotcl.com>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
2026-07-28 11:27:41 -07:00

543 lines
15 KiB
C

#define USE_THE_REPOSITORY_VARIABLE
#include "git-compat-util.h"
#include "environment.h"
#include "hex.h"
#include "merge-ort.h"
#include "object-name.h"
#include "refs.h"
#include "replay.h"
#include "revision.h"
#include "sequencer.h"
#include "strmap.h"
#include "tree.h"
/*
* We technically need USE_THE_REPOSITORY_VARIABLE for DEFAULT_ABBREV, but
* do not want to use the_repository.
*/
#define the_repository DO_NOT_USE_THE_REPOSITORY
enum replay_mode {
REPLAY_MODE_PICK,
REPLAY_MODE_REVERT,
};
static const char *short_commit_name(struct repository *repo,
struct commit *commit)
{
return repo_find_unique_abbrev(repo, &commit->object.oid,
DEFAULT_ABBREV);
}
static struct commit *peel_committish(struct repository *repo,
const char *name,
const char *mode)
{
struct object *obj;
struct object_id oid;
struct commit *commit;
if (repo_get_oid(repo, name, &oid))
die(_("'%s' is not a valid commit-ish for %s"), name, mode);
obj = parse_object_or_die(repo, &oid, name);
commit = (struct commit *)repo_peel_to_type(repo, name, 0, obj,
OBJ_COMMIT);
if (!commit)
die(_("'%s' does not point to a commit for %s"), name, mode);
return commit;
}
static char *get_author(const char *message)
{
size_t len;
const char *a;
a = find_commit_header(message, "author", &len);
if (a)
return xmemdupz(a, len);
return NULL;
}
static void generate_revert_message(struct strbuf *msg,
struct commit *commit,
struct repository *repo)
{
const char *out_enc = get_commit_output_encoding();
const char *message = repo_logmsg_reencode(repo, commit, NULL, out_enc);
const char *subject_start;
int subject_len;
char *subject;
subject_len = find_commit_subject(message, &subject_start);
subject = xmemdupz(subject_start, subject_len);
sequencer_format_revert_message(repo, subject, commit,
commit->parents ? commit->parents->item : NULL,
false, msg);
free(subject);
repo_unuse_commit_buffer(repo, commit, message);
}
static struct commit *create_commit(struct repository *repo,
struct tree *tree,
struct commit *based_on,
struct commit *parent,
enum replay_mode mode)
{
struct object_id ret;
struct object *obj = NULL;
struct commit_list *parents = NULL;
char *author = NULL;
char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
struct commit_extra_header *extra = NULL;
struct strbuf msg = STRBUF_INIT;
const char *out_enc = get_commit_output_encoding();
const char *message = repo_logmsg_reencode(repo, based_on,
NULL, out_enc);
const char *orig_message = NULL;
const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
commit_list_insert(parent, &parents);
extra = read_commit_extra_headers(based_on, exclude_gpgsig);
if (mode == REPLAY_MODE_REVERT) {
generate_revert_message(&msg, based_on, repo);
/* For revert, use current user as author (NULL = use default) */
} else if (mode == REPLAY_MODE_PICK) {
find_commit_subject(message, &orig_message);
strbuf_addstr(&msg, orig_message);
author = get_author(message);
} else {
BUG("unexpected replay mode %d", mode);
}
reset_ident_date();
if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
&ret, author, NULL, sign_commit, extra)) {
error(_("failed to write commit object"));
goto out;
}
obj = parse_object(repo, &ret);
out:
repo_unuse_commit_buffer(repo, based_on, message);
free_commit_extra_headers(extra);
commit_list_free(parents);
strbuf_release(&msg);
free(author);
return (struct commit *)obj;
}
struct ref_info {
struct commit *onto;
struct strset positive_refs;
struct strset negative_refs;
size_t positive_refexprs;
size_t negative_refexprs;
};
static void get_ref_information(struct repository *repo,
struct rev_cmdline_info *cmd_info,
struct ref_info *ref_info)
{
ref_info->onto = NULL;
strset_init(&ref_info->positive_refs);
strset_init(&ref_info->negative_refs);
ref_info->positive_refexprs = 0;
ref_info->negative_refexprs = 0;
/*
* When the user specifies e.g.
* git replay origin/main..mybranch
* git replay ^origin/next mybranch1 mybranch2
* we want to be able to determine where to replay the commits. In
* these examples, the branches are probably based on an old version
* of either origin/main or origin/next, so we want to replay on the
* newest version of that branch. In contrast we would want to error
* out if they ran
* git replay ^origin/master ^origin/next mybranch
* git replay mybranch~2..mybranch
* the first of those because there's no unique base to choose, and
* the second because they'd likely just be replaying commits on top
* of the same commit and not making any difference.
*/
for (size_t i = 0; i < cmd_info->nr; i++) {
struct rev_cmdline_entry *e = cmd_info->rev + i;
struct object_id oid;
const char *refexpr = e->name;
char *fullname = NULL;
int can_uniquely_dwim = 1;
if (*refexpr == '^')
refexpr++;
if (repo_dwim_ref(repo, refexpr, strlen(refexpr), &oid, &fullname, 0) != 1)
can_uniquely_dwim = 0;
if (e->flags & BOTTOM) {
if (can_uniquely_dwim)
strset_add(&ref_info->negative_refs, fullname);
if (!ref_info->negative_refexprs)
ref_info->onto = lookup_commit_reference_gently(repo,
&e->item->oid, 1);
ref_info->negative_refexprs++;
} else {
if (can_uniquely_dwim)
strset_add(&ref_info->positive_refs, fullname);
ref_info->positive_refexprs++;
}
free(fullname);
}
}
static void set_up_branch_mode(struct repository *repo,
char **branch_name,
const char *option_name,
struct ref_info *rinfo,
struct commit **onto)
{
struct object_id oid;
char *fullname = NULL;
if (repo_dwim_ref(repo, *branch_name, strlen(*branch_name),
&oid, &fullname, 0) == 1) {
free(*branch_name);
*branch_name = fullname;
} else {
die(_("argument to %s must be a reference"), option_name);
}
*onto = peel_committish(repo, *branch_name, option_name);
if (rinfo->positive_refexprs > 1)
die(_("'%s' cannot be used with multiple revision ranges "
"because the ordering would be ill-defined"),
option_name);
}
static void set_up_replay_mode(struct repository *repo,
struct rev_cmdline_info *cmd_info,
const char *onto_name,
bool *detached_head,
char **advance_name,
char **revert_name,
struct commit **onto,
struct strset **update_refs)
{
struct ref_info rinfo;
int head_flags = 0;
refs_read_ref_full(get_main_ref_store(repo), "HEAD",
RESOLVE_REF_NO_RECURSE, NULL, &head_flags);
*detached_head = !(head_flags & REF_ISSYMREF);
get_ref_information(repo, cmd_info, &rinfo);
if (!rinfo.positive_refexprs)
die(_("need some commits to replay"));
if (onto_name) {
*onto = peel_committish(repo, onto_name, "--onto");
if (rinfo.positive_refexprs <
strset_get_size(&rinfo.positive_refs))
die(_("all positive revisions given must be references"));
*update_refs = xcalloc(1, sizeof(**update_refs));
**update_refs = rinfo.positive_refs;
memset(&rinfo.positive_refs, 0, sizeof(**update_refs));
} else if (*advance_name) {
set_up_branch_mode(repo, advance_name, "--advance", &rinfo, onto);
} else if (*revert_name) {
set_up_branch_mode(repo, revert_name, "--revert", &rinfo, onto);
} else {
BUG("expected one of onto_name, *advance_name, or *revert_name");
}
strset_clear(&rinfo.negative_refs);
strset_clear(&rinfo.positive_refs);
}
static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,
struct commit *commit,
struct commit *fallback)
{
khint_t pos;
if (!commit)
return fallback;
pos = kh_get_oid_map(replayed_commits, commit->object.oid);
if (pos == kh_end(replayed_commits))
return fallback;
return kh_value(replayed_commits, pos);
}
static void put_mapped_commit(kh_oid_map_t *replayed_commits,
struct commit *commit,
struct commit *new_commit)
{
khint_t pos;
int ret;
pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
if (ret == 0)
BUG("Duplicate rewritten commit: %s",
oid_to_hex(&commit->object.oid));
kh_value(replayed_commits, pos) = new_commit;
}
static struct commit *pick_regular_commit(struct repository *repo,
struct commit *pickme,
struct commit *replayed_base,
struct merge_options *merge_opt,
struct merge_result *result,
enum replay_mode mode,
enum replay_empty_commit_action empty)
{
struct tree *pickme_tree, *base_tree, *replayed_base_tree;
if (pickme->parents)
base_tree = repo_get_commit_tree(repo, pickme->parents->item);
else
base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
pickme_tree = repo_get_commit_tree(repo, pickme);
if (mode == REPLAY_MODE_PICK) {
/* Cherry-pick: normal order */
merge_opt->branch1 = short_commit_name(repo, replayed_base);
merge_opt->branch2 = short_commit_name(repo, pickme);
if (pickme->parents)
merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
else
merge_opt->ancestor = xstrdup("empty tree");
merge_incore_nonrecursive(merge_opt,
base_tree,
replayed_base_tree,
pickme_tree,
result);
free((char *)merge_opt->ancestor);
} else if (mode == REPLAY_MODE_REVERT) {
/* Revert: swap base and pickme to reverse the diff */
const char *pickme_name = short_commit_name(repo, pickme);
merge_opt->branch1 = short_commit_name(repo, replayed_base);
merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
merge_opt->ancestor = pickme_name;
merge_incore_nonrecursive(merge_opt,
pickme_tree,
replayed_base_tree,
base_tree,
result);
free((char *)merge_opt->branch2);
} else {
BUG("unexpected replay mode %d", mode);
}
merge_opt->ancestor = NULL;
merge_opt->branch2 = NULL;
if (!result->clean)
return NULL;
/* Handle commits that become empty */
if (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) &&
!oideq(&pickme_tree->object.oid, &base_tree->object.oid)) {
switch (empty) {
case REPLAY_EMPTY_COMMIT_DROP:
return replayed_base;
case REPLAY_EMPTY_COMMIT_KEEP:
break;
case REPLAY_EMPTY_COMMIT_ABORT:
result->clean = error(_("commit %s became empty after replay"),
oid_to_hex(&pickme->object.oid));
return NULL;
}
}
return create_commit(repo, result->tree, pickme, replayed_base, mode);
}
void replay_result_release(struct replay_result *result)
{
for (size_t i = 0; i < result->updates_nr; i++)
free(result->updates[i].refname);
free(result->updates);
}
void replay_result_queue_update(struct replay_result *result,
const char *refname,
const struct object_id *old_oid,
const struct object_id *new_oid)
{
ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
result->updates[result->updates_nr].refname = xstrdup(refname);
result->updates[result->updates_nr].old_oid = *old_oid;
result->updates[result->updates_nr].new_oid = *new_oid;
result->updates_nr++;
}
int replay_revisions(struct rev_info *revs,
struct replay_revisions_options *opts,
struct replay_result *out)
{
kh_oid_map_t *replayed_commits = NULL;
struct strset *update_refs = NULL;
struct commit *last_commit = NULL;
struct commit *commit;
struct commit *onto = NULL;
struct merge_options merge_opt = { 0 };
struct merge_result result = {
.clean = 1,
};
bool detached_head;
char *advance;
char *revert;
const char *ref;
struct object_id old_oid;
enum replay_mode mode = REPLAY_MODE_PICK;
int ret;
advance = xstrdup_or_null(opts->advance);
revert = xstrdup_or_null(opts->revert);
if (revert)
mode = REPLAY_MODE_REVERT;
set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
&detached_head, &advance, &revert, &onto, &update_refs);
if (opts->linearize &&
update_refs && strset_get_size(update_refs) > 1) {
ret = error(_("'--linearize' cannot be used with multiple revision ranges"));
goto out;
}
if (opts->ref) {
struct object_id oid;
if (update_refs && strset_get_size(update_refs) > 1) {
ret = error(_("'--ref' cannot be used with multiple revision ranges"));
goto out;
}
if (check_refname_format(opts->ref, 0) || !starts_with(opts->ref, "refs/")) {
ret = error(_("'%s' is not a valid refname"), opts->ref);
goto out;
}
ref = opts->ref;
if (!refs_read_ref(get_main_ref_store(revs->repo), opts->ref, &oid))
oidcpy(&old_oid, &oid);
else
oidclr(&old_oid, revs->repo->hash_algo);
} else {
ref = advance ? advance : revert;
oidcpy(&old_oid, &onto->object.oid);
}
if (prepare_revision_walk(revs) < 0) {
ret = error(_("error preparing revisions"));
goto out;
}
init_basic_merge_options(&merge_opt, revs->repo);
merge_opt.show_rename_progress = 0;
last_commit = onto;
replayed_commits = kh_init_oid_map();
while ((commit = get_revision(revs))) {
const struct name_decoration *decoration;
if (commit->parents && commit->parents->next) {
if (!opts->linearize)
die(_("replaying merge commits is not supported yet!"));
/*
* Drop the merge commit: do not pick it, leave
* `last_commit` unchanged, and fall through to the
* rest of the loop. As a result:
* - refs pointing to the merge commit will be updated
* to `last_commit`.
* - the next replayed commit uses `last_commit` as its
* `base`.
*/
} else {
/*
* Decide where to replay this commit onto.
* If the parent commit was replayed already, the replayed result
* can be found in `replayed_commits`. Otherwise fall back to `onto`.
* When reverting, commits are replayed in reverse order and thus
* its parent isn't replayed yet. Therefore revert commits are
* always replayed onto `last_commit`.
* Also when opts->linearize is true, set the base to
* `last_commit` to create a single linear history.
*/
struct commit *parent = commit->parents ? commit->parents->item : NULL;
struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
if (opts->linearize || mode == REPLAY_MODE_REVERT)
base = last_commit;
last_commit = pick_regular_commit(revs->repo, commit, base,
&merge_opt, &result,
mode, opts->empty);
}
if (!last_commit)
break;
/* Record commit -> last_commit mapping */
put_mapped_commit(replayed_commits, commit, last_commit);
/* Update any necessary branches */
if (ref)
continue;
for (decoration = get_name_decoration(&commit->object);
decoration;
decoration = decoration->next)
{
if (decoration->type != DECORATION_REF_LOCAL &&
decoration->type != DECORATION_REF_HEAD)
continue;
/*
* We only need to update HEAD separately in case it's
* detached. If it's not we'd already update the branch
* it is pointing to.
*/
if (decoration->type == DECORATION_REF_HEAD && !detached_head)
continue;
if (!opts->contained &&
!strset_contains(update_refs, decoration->name))
continue;
replay_result_queue_update(out, decoration->name,
&commit->object.oid,
&last_commit->object.oid);
}
}
if (result.clean < 0) {
ret = -1;
goto out;
}
if (!result.clean) {
ret = 1;
goto out;
}
if (ref)
replay_result_queue_update(out, ref, &old_oid,
&last_commit->object.oid);
ret = 0;
out:
if (update_refs) {
strset_clear(update_refs);
free(update_refs);
}
kh_destroy_oid_map(replayed_commits);
merge_finalize(&merge_opt, &result);
free(advance);
free(revert);
return ret;
}