mirror of
https://github.com/git/git.git
synced 2026-08-07 08:31:51 +00:00
Merge branch 'hn/history-squash' into seen
The experimental 'git history' command has been taught a new 'squash' subcommand to fold a range of commits into a single commit, with any descendants replayed on top. * hn/history-squash: history: add squash subcommand to fold a range sequencer: share the squash message marker helpers and flags history: give commit_tree_ext a message template history: extract helper for a commit's parent tree
This commit is contained in:
@@ -59,6 +59,10 @@ all advice messages.
|
||||
forceDeleteBranch::
|
||||
Shown when the user tries to delete a not fully merged
|
||||
branch without the force option set.
|
||||
historyUpdateRefs::
|
||||
Shown when `git history squash` refuses because a ref points
|
||||
into the range being folded, to tell the user about
|
||||
`--update-refs=head`.
|
||||
ignoredHook::
|
||||
Shown when a hook is ignored because the hook is not
|
||||
set as executable.
|
||||
|
||||
@@ -12,6 +12,7 @@ git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(
|
||||
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>...]
|
||||
git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>
|
||||
|
||||
DESCRIPTION
|
||||
-----------
|
||||
@@ -43,8 +44,11 @@ at once.
|
||||
LIMITATIONS
|
||||
-----------
|
||||
|
||||
This command does not (yet) work with histories that contain merges. You
|
||||
should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
|
||||
This command does not (yet) replay merge commits onto the rewritten
|
||||
history: if a commit that would be replayed is a merge, the operation is
|
||||
rejected, and you should use linkgit:git-rebase[1] with the
|
||||
`--rebase-merges` flag instead. The `squash` subcommand can still fold merges
|
||||
that lie inside the selected range, subject to the restrictions below.
|
||||
|
||||
Furthermore, the command does not support operations that can result in merge
|
||||
conflicts. This limitation is by design as history rewrites are not intended to
|
||||
@@ -113,6 +117,51 @@ linkgit:gitglossary[7].
|
||||
It is invalid to select either all or no hunks, as that would lead to
|
||||
one of the commits becoming empty.
|
||||
|
||||
`squash <revision-range>`::
|
||||
Fold all commits in _<revision-range>_ into the oldest commit of that
|
||||
range. The resulting commit keeps the oldest commit's authorship and
|
||||
takes the tree of the range's newest commit, so the whole range
|
||||
collapses into a single commit. Commits above the range are replayed
|
||||
on top of the result.
|
||||
+
|
||||
The range is given in the usual `<base>..<tip>` form, where _<base>_ is
|
||||
the commit just below the oldest commit to squash. For example, `git
|
||||
history squash HEAD~3..HEAD` folds the three most recent commits into
|
||||
one, and `git history squash HEAD~5..HEAD~2` squashes an interior range
|
||||
while leaving the two newest commits in place. Several revisions may be
|
||||
given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
|
||||
already on `topic`. Rev-list options may also be given, but any that would
|
||||
change how the range is walked are overridden with a warning.
|
||||
+
|
||||
An editor opens pre-filled with the messages of all the folded commits so you
|
||||
can combine them. With `--no-edit`, the oldest commit's message is preserved
|
||||
instead, except that an `amend!` commit targeting it replaces its message.
|
||||
+
|
||||
The selected commits must form a connected graph with a single tip and must
|
||||
not include a root commit. Every parent of a commit after the oldest one must
|
||||
either be selected or also be a parent of the oldest commit. When the oldest
|
||||
commit is a merge, all of its parents are preserved in the squashed commit.
|
||||
+
|
||||
A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it
|
||||
targets is also in the range, so the fold does not silently absorb a
|
||||
marker meant for a commit outside it. As an exception, a range made up entirely
|
||||
of markers for one target is combined into a single commit. With `--no-edit`,
|
||||
the last `amend!` message is used if there is one; a `squash!` or `amend!` is
|
||||
otherwise refused if folding it would discard its message.
|
||||
+
|
||||
The editor template mirrors `git rebase -i --autosquash`: each `fixup!`,
|
||||
`squash!`, or `amend!` is grouped under the commit it targets rather than
|
||||
shown in commit order. A `fixup!` message is dropped (commented out in full),
|
||||
a `squash!` keeps its body with only the marker subject commented, and an
|
||||
`amend!` replaces its target's message, unless a `squash!` folded into that
|
||||
target first, in which case it keeps its body like a `squash!`.
|
||||
+
|
||||
A local branch descended from a selected commit but not from the range tip
|
||||
cannot be rewritten as a descendant of the result, so with the default
|
||||
`--update-refs=branches` the command refuses. Rerun with `--update-refs=head`
|
||||
to rewrite only the current branch and leave such branches unchanged. Tags
|
||||
and remote-tracking refs are always left unchanged.
|
||||
|
||||
OPTIONS
|
||||
-------
|
||||
|
||||
@@ -122,6 +171,12 @@ OPTIONS
|
||||
objects will be written into the repository, so applying these printed
|
||||
ref updates is generally safe.
|
||||
|
||||
`--edit`::
|
||||
`--no-edit`::
|
||||
For `squash`, open an editor to combine the messages of the folded commits.
|
||||
This is the default; use `--no-edit` to keep the selected message without
|
||||
opening an editor.
|
||||
|
||||
`--reedit-message`::
|
||||
Open an editor to modify the target commit's message.
|
||||
|
||||
|
||||
1
advice.c
1
advice.c
@@ -58,6 +58,7 @@ static struct {
|
||||
[ADVICE_FETCH_SHOW_FORCED_UPDATES] = { "fetchShowForcedUpdates" },
|
||||
[ADVICE_FORCE_DELETE_BRANCH] = { "forceDeleteBranch" },
|
||||
[ADVICE_GRAFT_FILE_DEPRECATED] = { "graftFileDeprecated" },
|
||||
[ADVICE_HISTORY_UPDATE_REFS] = { "historyUpdateRefs" },
|
||||
[ADVICE_IGNORED_HOOK] = { "ignoredHook" },
|
||||
[ADVICE_IMPLICIT_IDENTITY] = { "implicitIdentity" },
|
||||
[ADVICE_MERGE_CONFLICT] = { "mergeConflict" },
|
||||
|
||||
1
advice.h
1
advice.h
@@ -25,6 +25,7 @@ enum advice_type {
|
||||
ADVICE_FETCH_SHOW_FORCED_UPDATES,
|
||||
ADVICE_FORCE_DELETE_BRANCH,
|
||||
ADVICE_GRAFT_FILE_DEPRECATED,
|
||||
ADVICE_HISTORY_UPDATE_REFS,
|
||||
ADVICE_IGNORED_HOOK,
|
||||
ADVICE_IMPLICIT_IDENTITY,
|
||||
ADVICE_MERGE_CONFLICT,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#define USE_THE_REPOSITORY_VARIABLE
|
||||
|
||||
#include "builtin.h"
|
||||
#include "advice.h"
|
||||
#include "cache-tree.h"
|
||||
#include "commit.h"
|
||||
#include "commit-reach.h"
|
||||
@@ -16,6 +17,7 @@
|
||||
#include "path.h"
|
||||
#include "read-cache.h"
|
||||
#include "refs.h"
|
||||
#include "ref-filter.h"
|
||||
#include "replay.h"
|
||||
#include "reset.h"
|
||||
#include "revision.h"
|
||||
@@ -34,6 +36,8 @@
|
||||
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
|
||||
#define GIT_HISTORY_SPLIT_USAGE \
|
||||
N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]")
|
||||
#define GIT_HISTORY_SQUASH_USAGE \
|
||||
N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>")
|
||||
|
||||
static void change_data_free(void *util, const char *str UNUSED)
|
||||
{
|
||||
@@ -108,6 +112,7 @@ enum commit_tree_flags {
|
||||
static int commit_tree_ext(struct repository *repo,
|
||||
const char *action,
|
||||
struct commit *commit_with_message,
|
||||
const char *message_template,
|
||||
const struct commit_list *parents,
|
||||
const struct object_id *old_tree,
|
||||
const struct object_id *new_tree,
|
||||
@@ -137,13 +142,16 @@ static int commit_tree_ext(struct repository *repo,
|
||||
original_author = xmemdupz(ptr, len);
|
||||
find_commit_subject(original_message, &original_body);
|
||||
|
||||
if (!message_template)
|
||||
message_template = original_body;
|
||||
|
||||
if (flags & COMMIT_TREE_EDIT_MESSAGE) {
|
||||
ret = fill_commit_message(repo, old_tree, new_tree,
|
||||
original_body, action, &commit_message);
|
||||
message_template, action, &commit_message);
|
||||
if (ret < 0)
|
||||
goto out;
|
||||
} else {
|
||||
strbuf_addstr(&commit_message, original_body);
|
||||
strbuf_addstr(&commit_message, message_template);
|
||||
}
|
||||
|
||||
original_extra_headers = read_commit_extra_headers(commit_with_message,
|
||||
@@ -164,6 +172,25 @@ out:
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int first_parent_tree_oid(struct repository *repo,
|
||||
struct commit *commit,
|
||||
struct object_id *out)
|
||||
{
|
||||
struct commit *parent = commit->parents ? commit->parents->item : NULL;
|
||||
|
||||
if (!parent) {
|
||||
oidcpy(out, repo->hash_algo->empty_tree);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (repo_parse_commit(repo, parent))
|
||||
return error(_("unable to parse parent commit %s"),
|
||||
oid_to_hex(&parent->object.oid));
|
||||
|
||||
oidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int commit_tree_with_edited_message(struct repository *repo,
|
||||
const char *action,
|
||||
struct commit *original,
|
||||
@@ -171,23 +198,13 @@ static int commit_tree_with_edited_message(struct repository *repo,
|
||||
{
|
||||
struct object_id parent_tree_oid;
|
||||
const struct object_id *tree_oid;
|
||||
struct commit *parent;
|
||||
|
||||
tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
|
||||
|
||||
parent = original->parents ? original->parents->item : NULL;
|
||||
if (parent) {
|
||||
if (repo_parse_commit(repo, parent)) {
|
||||
return error(_("unable to parse parent commit %s"),
|
||||
oid_to_hex(&parent->object.oid));
|
||||
}
|
||||
if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
|
||||
return -1;
|
||||
|
||||
parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
|
||||
} else {
|
||||
oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
|
||||
}
|
||||
|
||||
return commit_tree_ext(repo, action, original, original->parents,
|
||||
return commit_tree_ext(repo, action, original, NULL, original->parents,
|
||||
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
|
||||
}
|
||||
|
||||
@@ -475,18 +492,10 @@ static int commit_became_empty(struct repository *repo,
|
||||
struct commit *original,
|
||||
struct tree *result)
|
||||
{
|
||||
struct commit *parent = original->parents ? original->parents->item : NULL;
|
||||
struct object_id parent_tree_oid;
|
||||
|
||||
if (parent) {
|
||||
if (repo_parse_commit(repo, parent))
|
||||
return error(_("unable to parse parent of %s"),
|
||||
oid_to_hex(&original->object.oid));
|
||||
|
||||
parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
|
||||
} else {
|
||||
oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
|
||||
}
|
||||
if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
|
||||
return -1;
|
||||
|
||||
return oideq(&result->object.oid, &parent_tree_oid);
|
||||
}
|
||||
@@ -674,7 +683,7 @@ static int cmd_history_fixup(int argc,
|
||||
goto out;
|
||||
|
||||
if (!skip_commit) {
|
||||
ret = commit_tree_ext(repo, "fixup", original, original->parents,
|
||||
ret = commit_tree_ext(repo, "fixup", original, NULL, original->parents,
|
||||
&original_tree->object.oid, &merge_result.tree->object.oid,
|
||||
&rewritten, flags);
|
||||
if (ret < 0) {
|
||||
@@ -830,16 +839,9 @@ static int split_commit(struct repository *repo,
|
||||
struct tree *split_tree;
|
||||
int ret;
|
||||
|
||||
if (original->parents) {
|
||||
if (repo_parse_commit(repo, original->parents->item)) {
|
||||
ret = error(_("unable to parse parent commit %s"),
|
||||
oid_to_hex(&original->parents->item->object.oid));
|
||||
goto out;
|
||||
}
|
||||
|
||||
parent_tree_oid = *get_commit_tree_oid(original->parents->item);
|
||||
} else {
|
||||
oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
|
||||
if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {
|
||||
ret = -1;
|
||||
goto out;
|
||||
}
|
||||
original_commit_tree_oid = get_commit_tree_oid(original);
|
||||
|
||||
@@ -892,7 +894,7 @@ static int split_commit(struct repository *repo,
|
||||
* The first commit is constructed from the split-out tree. The base
|
||||
* that shall be diffed against is the parent of the original commit.
|
||||
*/
|
||||
ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
|
||||
ret = commit_tree_ext(repo, "split-out", original, NULL, original->parents, &parent_tree_oid,
|
||||
&split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
|
||||
if (ret < 0) {
|
||||
ret = error(_("failed writing first commit"));
|
||||
@@ -909,7 +911,7 @@ static int split_commit(struct repository *repo,
|
||||
old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;
|
||||
new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
|
||||
|
||||
ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
|
||||
ret = commit_tree_ext(repo, "split-out", original, NULL, parents, old_tree_oid,
|
||||
new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
|
||||
if (ret < 0) {
|
||||
ret = error(_("failed writing second commit"));
|
||||
@@ -1006,6 +1008,611 @@ out:
|
||||
return ret;
|
||||
}
|
||||
|
||||
/*Remember to update object flag allocation in object.h */
|
||||
#define SQUASH_SEEN (1u << 11)
|
||||
#define SQUASH_TIP (1u << 12)
|
||||
#define SQUASH_AMEND_TARGET (1u << 13)
|
||||
|
||||
static bool is_autosquash_subject(const char *s)
|
||||
{
|
||||
return starts_with(s, "amend!") || starts_with(s, "fixup!") ||
|
||||
starts_with(s, "squash!");
|
||||
}
|
||||
|
||||
static bool skip_one_autosquash_prefix(const char *s, const char **out)
|
||||
{
|
||||
if (skip_prefix(s, "amend!", out) || skip_prefix(s, "fixup!", out) ||
|
||||
skip_prefix(s, "squash!", out)) {
|
||||
while (**out == ' ')
|
||||
(*out)++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void truncate_message_to_subject(struct strbuf *msg)
|
||||
{
|
||||
const char *eos = strstr(msg->buf, "\n\n");
|
||||
|
||||
if (eos)
|
||||
strbuf_setlen(msg, eos - msg->buf + 1);
|
||||
}
|
||||
|
||||
struct subject_data {
|
||||
struct strintmap subjects;
|
||||
struct strbuf subject;
|
||||
struct strbuf squash_message;
|
||||
const char *message;
|
||||
bool edit_message;
|
||||
};
|
||||
|
||||
#define SUBJECT_DATA_INIT { \
|
||||
.subjects = STRINTMAP_INIT, \
|
||||
.subject = STRBUF_INIT, \
|
||||
.squash_message = STRBUF_INIT, \
|
||||
}
|
||||
|
||||
static void subject_data_clear(struct subject_data *data)
|
||||
{
|
||||
strintmap_clear(&data->subjects);
|
||||
strbuf_release(&data->subject);
|
||||
strbuf_release(&data->squash_message);
|
||||
}
|
||||
|
||||
static int squash_amend_message(struct repository *repo,
|
||||
struct commit *commit,
|
||||
struct subject_data *data,
|
||||
unsigned flags)
|
||||
{
|
||||
const char *body = data->message + data->subject.len;
|
||||
|
||||
while (isspace(*body))
|
||||
body++;
|
||||
|
||||
if (!*body) {
|
||||
warning(_("ignoring %s (%s): message body is empty"),
|
||||
repo_find_unique_abbrev(repo, &commit->object.oid,
|
||||
DEFAULT_ABBREV),
|
||||
data->subject.buf);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (data->edit_message) {
|
||||
return 0;
|
||||
} else if (flags & SQUASH_AMEND_TARGET) {
|
||||
if (starts_with(data->squash_message.buf, "squash!"))
|
||||
return error(_("squashing %s (%s) would overwrite "
|
||||
"'squash!' message, please combine them "
|
||||
"using '--edit'"),
|
||||
repo_find_unique_abbrev(repo,
|
||||
&commit->object.oid,
|
||||
DEFAULT_ABBREV),
|
||||
data->subject.buf);
|
||||
if (starts_with(data->squash_message.buf, "fixup!"))
|
||||
strbuf_splice(&data->squash_message, 0, 5, "amend!", 5);
|
||||
if (starts_with(data->squash_message.buf, "amend!")) {
|
||||
truncate_message_to_subject(&data->squash_message);
|
||||
strbuf_addch(&data->squash_message, '\n');
|
||||
} else {
|
||||
strbuf_reset(&data->squash_message);
|
||||
}
|
||||
strbuf_addstr(&data->squash_message, body);
|
||||
strbuf_complete_line(&data->squash_message);
|
||||
} else {
|
||||
return error(_("cannot squash %s (%s) that does not target "
|
||||
"base commit without '--edit'"),
|
||||
repo_find_unique_abbrev(repo, &commit->object.oid,
|
||||
DEFAULT_ABBREV),
|
||||
data->subject.buf);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int squash_squash_message(struct repository *repo,
|
||||
struct commit *commit,
|
||||
struct subject_data *data,
|
||||
unsigned flags)
|
||||
{
|
||||
const char *body = data->message + data->subject.len;
|
||||
|
||||
while (isspace(*body))
|
||||
body++;
|
||||
|
||||
if (data->edit_message) {
|
||||
return 0;
|
||||
} else if (flags & SQUASH_AMEND_TARGET) {
|
||||
if (starts_with(data->squash_message.buf, "fixup!")) {
|
||||
truncate_message_to_subject(&data->squash_message);
|
||||
strbuf_splice(&data->squash_message, 0, 5, "squash", 6);
|
||||
}
|
||||
if (starts_with(data->squash_message.buf, "squash!")) {
|
||||
strbuf_addch(&data->squash_message, '\n');
|
||||
strbuf_addstr(&data->squash_message, body);
|
||||
strbuf_complete_line(&data->squash_message);
|
||||
} else {
|
||||
return error(_("squashing %s (%s) would discard its "
|
||||
"message, please combine them using "
|
||||
"'--edit'"),
|
||||
repo_find_unique_abbrev(repo,
|
||||
&commit->object.oid,
|
||||
DEFAULT_ABBREV),
|
||||
data->subject.buf);
|
||||
}
|
||||
} else {
|
||||
return error(_("cannot squash %s (%s) that does not target "
|
||||
"base commit without '--edit'"),
|
||||
repo_find_unique_abbrev(repo, &commit->object.oid,
|
||||
DEFAULT_ABBREV),
|
||||
data->subject.buf);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int squash_check_can_autosquash(struct repository *repo,
|
||||
struct commit *commit,
|
||||
struct subject_data *data,
|
||||
unsigned flags)
|
||||
{
|
||||
commit->object.flags |= flags & SQUASH_AMEND_TARGET;
|
||||
if (starts_with(data->subject.buf, "amend!"))
|
||||
return squash_amend_message(repo, commit, data, flags);
|
||||
else if (starts_with(data->subject.buf, "squash!"))
|
||||
return squash_squash_message(repo, commit, data, flags);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int squash_check_autosquash_subject(struct repository *repo,
|
||||
struct commit *commit,
|
||||
struct subject_data *data)
|
||||
{
|
||||
const char* s = data->subject.buf;
|
||||
struct commit *target;
|
||||
struct hashmap_iter iter;
|
||||
struct strmap_entry *entry;
|
||||
/* Try skipping autosquash prefixes one at a time to allow
|
||||
* squashing
|
||||
* a commit
|
||||
* fixup! fixup! a commit
|
||||
*
|
||||
* where we may have started with
|
||||
* a commit
|
||||
* fixup! a commit
|
||||
* fixup! fixup! a commit
|
||||
*
|
||||
* and squashed the first fixup separately from the second
|
||||
*/
|
||||
while (skip_one_autosquash_prefix(s, &s)) {
|
||||
unsigned flags = strintmap_get(&data->subjects, s);
|
||||
if (flags)
|
||||
return squash_check_can_autosquash(repo, commit, data, flags);
|
||||
}
|
||||
/*
|
||||
* Allow "fixup! <hex object id>", but not "fixup! HEAD^" or
|
||||
* "fixup! main". If the target is not being squshed check the subject
|
||||
* to allow "fixup! abc123" and "fixup! <subject of abc123>" to be
|
||||
* squashed together.
|
||||
*/
|
||||
target = lookup_commit_reference_by_name(s);
|
||||
if (target && istarts_with(oid_to_hex(&target->object.oid), s)) {
|
||||
unsigned flags =
|
||||
target->object.flags & (SQUASH_SEEN | SQUASH_AMEND_TARGET);
|
||||
if (!flags) {
|
||||
const char *subject_start;
|
||||
const char *buffer = repo_logmsg_reencode(repo, target,
|
||||
NULL, NULL);
|
||||
size_t subject_len = find_commit_subject(buffer,
|
||||
&subject_start);
|
||||
char *subject = xmemdupz(subject_start, subject_len);
|
||||
|
||||
flags = strintmap_get(&data->subjects, subject);
|
||||
free(subject);
|
||||
repo_unuse_commit_buffer(repo, target, buffer);
|
||||
}
|
||||
if (flags)
|
||||
return squash_check_can_autosquash(repo, commit,
|
||||
data, flags);
|
||||
}
|
||||
/* Try subject prefix matches */
|
||||
strintmap_for_each_entry(&data->subjects, &iter, entry) {
|
||||
s = data->subject.buf;
|
||||
while(skip_one_autosquash_prefix(s, &s)) {
|
||||
if (starts_with(entry->key, s)) {
|
||||
unsigned value = (intptr_t)entry->value;
|
||||
|
||||
return squash_check_can_autosquash(repo, commit,
|
||||
data, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return error(_("cannot squash %s (%s): its target is not being "
|
||||
"squashed"),
|
||||
repo_find_unique_abbrev(repo, &commit->object.oid,
|
||||
DEFAULT_ABBREV),
|
||||
data->subject.buf);
|
||||
}
|
||||
|
||||
static int squash_check_subject(struct repository *repo,
|
||||
struct commit *commit,
|
||||
struct subject_data *data)
|
||||
{
|
||||
int ret = 0;
|
||||
const char *buf = repo_logmsg_reencode(repo, commit, NULL, NULL);
|
||||
size_t subject_len = find_commit_subject(buf, &data->message);
|
||||
|
||||
strbuf_reset(&data->subject);
|
||||
strbuf_add(&data->subject, data->message, subject_len);
|
||||
|
||||
if (!strintmap_get_size(&data->subjects)) {
|
||||
const char *s;
|
||||
|
||||
strbuf_addstr(&data->squash_message, data->message);
|
||||
strbuf_complete_line(&data->squash_message);
|
||||
/*
|
||||
* Strip a single autosquash prefix to allow squashing
|
||||
* fixup! base
|
||||
* amend! base
|
||||
*/
|
||||
s = data->subject.buf;
|
||||
skip_one_autosquash_prefix(s, &s);
|
||||
strintmap_set(&data->subjects, s, SQUASH_AMEND_TARGET | SQUASH_SEEN);
|
||||
commit->object.flags |= SQUASH_AMEND_TARGET;
|
||||
} else if (is_autosquash_subject(data->subject.buf)) {
|
||||
ret = squash_check_autosquash_subject(repo, commit, data);
|
||||
} else {
|
||||
strintmap_set(&data->subjects, data->subject.buf, SQUASH_SEEN);
|
||||
}
|
||||
repo_unuse_commit_buffer(repo, commit, buf);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int build_squash_message(struct repository *repo,
|
||||
const struct strbuf *todo_buf,
|
||||
struct strbuf *out);
|
||||
|
||||
/*
|
||||
* Resolve a "<base>..<tip>" revision range into the base commit just outside
|
||||
* the range (which becomes the parent of the squashed commit), the oldest
|
||||
* commit contained in the range (whose message the squash reuses), and the
|
||||
* range tip (whose tree becomes the result). A merge inside the range is fine,
|
||||
* but the range must have a single base and must not reach a root commit.
|
||||
*/
|
||||
static int resolve_squash_range(struct repository *repo,
|
||||
bool update_branches,
|
||||
bool edit_message,
|
||||
int argc, const char **argv,
|
||||
struct commit **oldest_out,
|
||||
struct commit **tip_out,
|
||||
char **message_out)
|
||||
{
|
||||
struct rev_info revs;
|
||||
struct subject_data subject_data = SUBJECT_DATA_INIT;
|
||||
struct commit *commit, *oldest = NULL, *tip = NULL;
|
||||
struct strbuf todo_buf = STRBUF_INIT;
|
||||
size_t i;
|
||||
int ret, tip_count = 0;
|
||||
struct ref_filter filter = REF_FILTER_INIT;
|
||||
struct ref_array refs = { 0 };
|
||||
|
||||
repo_init_revisions(repo, &revs, NULL);
|
||||
subject_data.edit_message = edit_message;
|
||||
revs.reverse = 1;
|
||||
revs.topo_order = 1;
|
||||
revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
|
||||
revs.simplify_history = 0;
|
||||
revs.ancestry_path = 1;
|
||||
revs.limited = 1;
|
||||
revs.ancestry_path_implicit_bottoms = 1;
|
||||
|
||||
argc = setup_revisions(argc, argv, &revs, NULL);
|
||||
if (argc > 1) {
|
||||
ret = error(_("unrecognized argument: %s"), argv[1]);
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (revs.reverse != 1 || revs.topo_order != 1 ||
|
||||
revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||
|
||||
revs.simplify_history != 0 || revs.boundary == 1 ||
|
||||
revs.ancestry_path != 1 || revs.limited != 1 ||
|
||||
revs.ancestry_path_implicit_bottoms != 1) {
|
||||
warning(_("ignoring rev-list options that would change how the "
|
||||
"range is walked"));
|
||||
revs.reverse = 1;
|
||||
revs.topo_order = 1;
|
||||
revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
|
||||
revs.simplify_history = 0;
|
||||
revs.boundary = 0;
|
||||
revs.ancestry_path = 1;
|
||||
revs.limited = 1;
|
||||
revs.ancestry_path_implicit_bottoms = 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* A squash needs a base to reparent onto, so the range has to exclude
|
||||
* something, as in "<base>..<tip>". A revision range with no such
|
||||
* bottom commit cannot be squashed.
|
||||
*/
|
||||
for (i = 0; i < revs.cmdline.nr; i++)
|
||||
if (revs.cmdline.rev[i].flags & BOTTOM)
|
||||
break;
|
||||
if (i == revs.cmdline.nr) {
|
||||
ret = error(_("not a '<base>..<tip>' revision range"));
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (prepare_revision_walk(&revs) < 0) {
|
||||
ret = error(_("error preparing revisions"));
|
||||
goto out;
|
||||
}
|
||||
while ((commit = get_revision(&revs))) {
|
||||
struct commit_list *p;
|
||||
|
||||
if (edit_message)
|
||||
strbuf_addf(&todo_buf, "pick %s\n",
|
||||
oid_to_hex(&commit->object.oid));
|
||||
|
||||
if (!commit->parents) {
|
||||
ret = error(_("cannot squash down to root commit"));
|
||||
goto out;
|
||||
}
|
||||
for (p = commit->parents; oldest && p; p = p->next) {
|
||||
struct commit_list *q;
|
||||
struct object *o;
|
||||
bool seen;
|
||||
|
||||
if (repo_parse_commit(repo, p->item)) {
|
||||
ret = error(_("cannot parse commit"));
|
||||
goto out;
|
||||
}
|
||||
o = &p->item->object;
|
||||
seen = o->flags & SQUASH_SEEN;
|
||||
/*
|
||||
* Allow parents that match the parents of the
|
||||
* squashed commit.
|
||||
*/
|
||||
for (q = oldest->parents; !seen && q; q = q->next) {
|
||||
if (p->item == q->item) {
|
||||
seen = true;
|
||||
commit_list_insert(commit, &filter.with_commit);
|
||||
}
|
||||
}
|
||||
if (!seen) {
|
||||
ret = error(_("parent %s of commit %s is "
|
||||
"outside the revision range"),
|
||||
repo_find_unique_abbrev(repo, &o->oid,
|
||||
DEFAULT_ABBREV),
|
||||
repo_find_unique_abbrev(repo, &commit->object.oid,
|
||||
DEFAULT_ABBREV));
|
||||
goto out;
|
||||
}
|
||||
if (o->flags & SQUASH_TIP) {
|
||||
tip_count--;
|
||||
o->flags &= ~SQUASH_TIP;
|
||||
}
|
||||
}
|
||||
if (!oldest) {
|
||||
commit_list_insert(commit, &filter.with_commit);
|
||||
oldest = commit;
|
||||
}
|
||||
if (squash_check_subject(repo, commit, &subject_data)) {
|
||||
ret = -1;
|
||||
goto out;
|
||||
}
|
||||
tip = commit;
|
||||
tip->object.flags |= SQUASH_SEEN | SQUASH_TIP;
|
||||
tip_count++;
|
||||
}
|
||||
clear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP | SQUASH_AMEND_TARGET);
|
||||
reset_revision_walk();
|
||||
if (!tip_count) {
|
||||
ret = error(_("the revision range is empty"));
|
||||
goto out;
|
||||
} else if (tip_count != 1) {
|
||||
ret = error(_("the revision range contains more than one tip "
|
||||
"commit"));
|
||||
goto out;
|
||||
} else if (oldest == tip) {
|
||||
ret = error(_("the revision range holds a single commit; "
|
||||
"nothing to squash"));
|
||||
goto out;
|
||||
} else if (!oldest->parents) {
|
||||
BUG("an in-range commit must have a parent");
|
||||
}
|
||||
commit_list_insert(tip, &filter.no_commit);
|
||||
filter.kind = FILTER_REFS_BRANCHES;
|
||||
if (update_branches &&
|
||||
filter_refs(&refs, &filter, filter.kind)) {
|
||||
ret = error(_("could not filter refs"));
|
||||
goto out;
|
||||
}
|
||||
if (refs.nr) {
|
||||
/*
|
||||
* TODO: list the branches and also check HEADS from other worktrees
|
||||
*/
|
||||
ret = error(_("a branch points to a commit that is being squashed"));
|
||||
advise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,
|
||||
_("Use --update-refs=head to rewrite only "
|
||||
"the current branch and leave such refs "
|
||||
"untouched."));
|
||||
goto out;
|
||||
}
|
||||
if (edit_message) {
|
||||
strbuf_reset(&subject_data.squash_message);
|
||||
ret = build_squash_message(repo, &todo_buf,
|
||||
&subject_data.squash_message);
|
||||
if (ret < 0)
|
||||
goto out;
|
||||
}
|
||||
*oldest_out = oldest;
|
||||
*tip_out = tip;
|
||||
*message_out = strbuf_detach(&subject_data.squash_message, NULL);
|
||||
ret = 0;
|
||||
|
||||
out:
|
||||
strbuf_release(&todo_buf);
|
||||
subject_data_clear(&subject_data);
|
||||
release_revisions(&revs);
|
||||
ref_filter_clear(&filter);
|
||||
ref_array_clear(&refs);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static bool amend_replaces_target(struct todo_list *todo, int target)
|
||||
{
|
||||
int i;
|
||||
|
||||
for (i = target + 1; i < todo->nr &&
|
||||
todo->items[i].command != TODO_PICK; i++) {
|
||||
if (todo->items[i].command == TODO_SQUASH)
|
||||
return false;
|
||||
if (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static int build_squash_message(struct repository *repo,
|
||||
const struct strbuf *todo_buf,
|
||||
struct strbuf *out)
|
||||
{
|
||||
struct todo_list todo = TODO_LIST_INIT;
|
||||
struct replay_opts opts = REPLAY_OPTS_INIT;
|
||||
int i, nr_commits, ret;
|
||||
|
||||
if (todo_list_parse_insn_buffer(repo, &opts, todo_buf->buf, &todo) < 0 ||
|
||||
todo_list_rearrange_squash(&todo) < 0) {
|
||||
ret = error(_("could not prepare the squash message"));
|
||||
goto out;
|
||||
}
|
||||
|
||||
nr_commits = todo.nr;
|
||||
for (i = 0; i < nr_commits; i++) {
|
||||
struct todo_item *item = &todo.items[i];
|
||||
const char *message, *body;
|
||||
size_t commented_len;
|
||||
bool skip, squashing;
|
||||
|
||||
squashing = item->command == TODO_SQUASH ||
|
||||
(item->flags & TODO_REPLACE_FIXUP_MSG);
|
||||
if (item->command == TODO_PICK)
|
||||
skip = amend_replaces_target(&todo, i);
|
||||
else
|
||||
skip = !squashing;
|
||||
|
||||
message = repo_logmsg_reencode(repo, item->commit, NULL, NULL);
|
||||
find_commit_subject(message, &body);
|
||||
|
||||
if (skip)
|
||||
commented_len = strlen(body);
|
||||
else if (squashing)
|
||||
commented_len = squash_subject_comment_len(body, 1);
|
||||
else
|
||||
commented_len = 0;
|
||||
|
||||
if (!i)
|
||||
add_squash_combination_header(out, nr_commits);
|
||||
strbuf_addch(out, '\n');
|
||||
add_squash_message_header(out, i + 1, skip);
|
||||
strbuf_addstr(out, "\n\n");
|
||||
strbuf_add_commented_lines(out, body, commented_len, comment_line_str);
|
||||
strbuf_addstr(out, body + commented_len);
|
||||
strbuf_complete_line(out);
|
||||
|
||||
repo_unuse_commit_buffer(repo, item->commit, message);
|
||||
}
|
||||
|
||||
ret = 0;
|
||||
|
||||
out:
|
||||
todo_list_release(&todo);
|
||||
replay_opts_release(&opts);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int cmd_history_squash(int argc,
|
||||
const char **argv,
|
||||
const char *prefix,
|
||||
struct repository *repo)
|
||||
{
|
||||
const char * const usage[] = {
|
||||
GIT_HISTORY_SQUASH_USAGE,
|
||||
NULL,
|
||||
};
|
||||
enum ref_action action = REF_ACTION_DEFAULT;
|
||||
int dry_run = 0;
|
||||
int edit = 1;
|
||||
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_BOOL('e', "edit", &edit,
|
||||
N_("edit the commit message")),
|
||||
OPT_END(),
|
||||
};
|
||||
struct strbuf reflog_msg = STRBUF_INIT;
|
||||
struct commit *oldest, *tip, *rewritten;
|
||||
const struct object_id *base_tree_oid, *tip_tree_oid;
|
||||
char *message_template = NULL;
|
||||
struct rev_info revs = { 0 };
|
||||
int ret;
|
||||
|
||||
argc = parse_options(argc, argv, prefix, options, usage,
|
||||
PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);
|
||||
if (argc < 2) {
|
||||
ret = error(_("command expects a revision range"));
|
||||
goto out;
|
||||
}
|
||||
repo_config(repo, git_default_config, NULL);
|
||||
|
||||
if (action == REF_ACTION_DEFAULT)
|
||||
action = REF_ACTION_BRANCHES;
|
||||
|
||||
strbuf_addstr(&reflog_msg, "squash: updating ");
|
||||
strbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');
|
||||
|
||||
ret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,
|
||||
edit,
|
||||
argc, argv, &oldest, &tip,
|
||||
&message_template);
|
||||
if (ret < 0)
|
||||
goto out;
|
||||
|
||||
ret = setup_revwalk(repo, action, tip, &revs);
|
||||
if (ret < 0)
|
||||
goto out;
|
||||
|
||||
base_tree_oid = &repo_get_commit_tree(repo,
|
||||
oldest->parents->item)->object.oid;
|
||||
tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
|
||||
|
||||
ret = commit_tree_ext(repo, "squash", oldest, message_template,
|
||||
oldest->parents, base_tree_oid, tip_tree_oid,
|
||||
&rewritten,
|
||||
edit ? COMMIT_TREE_EDIT_MESSAGE : 0);
|
||||
if (ret < 0) {
|
||||
ret = error(_("failed writing squashed commit"));
|
||||
goto out;
|
||||
}
|
||||
|
||||
ret = handle_reference_updates(&revs, action, tip, rewritten,
|
||||
reflog_msg.buf, dry_run,
|
||||
REPLAY_EMPTY_COMMIT_ABORT);
|
||||
if (ret < 0) {
|
||||
ret = error(_("failed replaying descendants"));
|
||||
goto out;
|
||||
}
|
||||
|
||||
ret = 0;
|
||||
|
||||
out:
|
||||
strbuf_release(&reflog_msg);
|
||||
release_revisions(&revs);
|
||||
free(message_template);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int update_worktree(struct repository *repo,
|
||||
const struct commit *old_head,
|
||||
const struct commit *new_head,
|
||||
@@ -1194,6 +1801,7 @@ int cmd_history(int argc,
|
||||
GIT_HISTORY_FIXUP_USAGE,
|
||||
GIT_HISTORY_REWORD_USAGE,
|
||||
GIT_HISTORY_SPLIT_USAGE,
|
||||
GIT_HISTORY_SQUASH_USAGE,
|
||||
NULL,
|
||||
};
|
||||
parse_opt_subcommand_fn *fn = NULL;
|
||||
@@ -1202,6 +1810,7 @@ int cmd_history(int argc,
|
||||
OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
|
||||
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
|
||||
OPT_SUBCOMMAND("split", &fn, cmd_history_split),
|
||||
OPT_SUBCOMMAND("squash", &fn, cmd_history_squash),
|
||||
OPT_END(),
|
||||
};
|
||||
|
||||
|
||||
1
object.h
1
object.h
@@ -74,6 +74,7 @@ void object_array_init(struct object_array *array);
|
||||
* bisect.c: 16
|
||||
* bundle.c: 16
|
||||
* http-push.c: 11-----14
|
||||
* builtin/history.c: 11---13
|
||||
* commit-graph.c: 15
|
||||
* commit-reach.c: 16-------20
|
||||
* builtin/last-modified.c: 1617
|
||||
|
||||
70
sequencer.c
70
sequencer.c
@@ -1880,18 +1880,38 @@ static int is_pick_or_similar(enum todo_command command)
|
||||
}
|
||||
}
|
||||
|
||||
enum todo_item_flags {
|
||||
TODO_EDIT_MERGE_MSG = (1 << 0),
|
||||
TODO_REPLACE_FIXUP_MSG = (1 << 1),
|
||||
TODO_EDIT_FIXUP_MSG = (1 << 2),
|
||||
};
|
||||
|
||||
static const char first_commit_msg_str[] = N_("This is the 1st commit message:");
|
||||
static const char nth_commit_msg_fmt[] = N_("This is the commit message #%d:");
|
||||
static const char skip_first_commit_msg_str[] = N_("The 1st commit message will be skipped:");
|
||||
static const char skip_nth_commit_msg_fmt[] = N_("The commit message #%d will be skipped:");
|
||||
static const char combined_commit_msg_fmt[] = N_("This is a combination of %d commits.");
|
||||
|
||||
void add_squash_combination_header(struct strbuf *buf, int n)
|
||||
{
|
||||
strbuf_addf(buf, "%s ", comment_line_str);
|
||||
strbuf_addf(buf, _(combined_commit_msg_fmt), n);
|
||||
}
|
||||
|
||||
void add_squash_message_header(struct strbuf *buf, int n, int skip)
|
||||
{
|
||||
strbuf_addf(buf, "%s ", comment_line_str);
|
||||
if (n == 1)
|
||||
strbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :
|
||||
_(first_commit_msg_str));
|
||||
else
|
||||
strbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :
|
||||
_(nth_commit_msg_fmt), n);
|
||||
}
|
||||
|
||||
size_t squash_subject_comment_len(const char *body, int squashing)
|
||||
{
|
||||
if (starts_with(body, "amend!") ||
|
||||
(squashing && (starts_with(body, "squash!") ||
|
||||
starts_with(body, "fixup!"))))
|
||||
return commit_subject_length(body);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int is_fixup_flag(enum todo_command command, unsigned flag)
|
||||
{
|
||||
return command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||
|
||||
@@ -2012,20 +2032,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,
|
||||
{
|
||||
struct replay_ctx *ctx = opts->ctx;
|
||||
const char *fixup_msg;
|
||||
size_t commented_len = 0, fixup_off;
|
||||
/*
|
||||
* amend is non-interactive and not normally used with fixup!
|
||||
* or squash! commits, so only comment out those subjects when
|
||||
* squashing commit messages.
|
||||
*/
|
||||
if (starts_with(body, "amend!") ||
|
||||
((command == TODO_SQUASH || seen_squash(ctx)) &&
|
||||
(starts_with(body, "squash!") || starts_with(body, "fixup!"))))
|
||||
commented_len = commit_subject_length(body);
|
||||
size_t commented_len, fixup_off;
|
||||
|
||||
strbuf_addf(buf, "\n%s ", comment_line_str);
|
||||
strbuf_addf(buf, _(nth_commit_msg_fmt),
|
||||
++ctx->current_fixup_count + 1);
|
||||
commented_len = squash_subject_comment_len(body,
|
||||
command == TODO_SQUASH || seen_squash(ctx));
|
||||
|
||||
strbuf_addch(buf, '\n');
|
||||
add_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);
|
||||
strbuf_addstr(buf, "\n\n");
|
||||
strbuf_add_commented_lines(buf, body, commented_len, comment_line_str);
|
||||
/* buf->buf may be reallocated so store an offset into the buffer */
|
||||
@@ -2090,9 +2103,8 @@ static int update_squash_messages(struct repository *r,
|
||||
eol = !starts_with(buf.buf, comment_line_str) ?
|
||||
buf.buf : strchrnul(buf.buf, '\n');
|
||||
|
||||
strbuf_addf(&header, "%s ", comment_line_str);
|
||||
strbuf_addf(&header, _(combined_commit_msg_fmt),
|
||||
ctx->current_fixup_count + 2);
|
||||
add_squash_combination_header(&header,
|
||||
ctx->current_fixup_count + 2);
|
||||
strbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);
|
||||
strbuf_release(&header);
|
||||
if (is_fixup_flag(command, flag) && !seen_squash(ctx))
|
||||
@@ -2116,12 +2128,9 @@ static int update_squash_messages(struct repository *r,
|
||||
repo_unuse_commit_buffer(r, head_commit, head_message);
|
||||
return error(_("cannot write '%s'"), rebase_path_fixup_msg());
|
||||
}
|
||||
strbuf_addf(&buf, "%s ", comment_line_str);
|
||||
strbuf_addf(&buf, _(combined_commit_msg_fmt), 2);
|
||||
strbuf_addf(&buf, "\n%s ", comment_line_str);
|
||||
strbuf_addstr(&buf, is_fixup_flag(command, flag) ?
|
||||
_(skip_first_commit_msg_str) :
|
||||
_(first_commit_msg_str));
|
||||
add_squash_combination_header(&buf, 2);
|
||||
strbuf_addch(&buf, '\n');
|
||||
add_squash_message_header(&buf, 1, is_fixup_flag(command, flag));
|
||||
strbuf_addstr(&buf, "\n\n");
|
||||
if (is_fixup_flag(command, flag))
|
||||
strbuf_add_commented_lines(&buf, body, strlen(body),
|
||||
@@ -2140,9 +2149,8 @@ static int update_squash_messages(struct repository *r,
|
||||
if (command == TODO_SQUASH || is_fixup_flag(command, flag)) {
|
||||
res = append_squash_message(&buf, body, command, opts, flag);
|
||||
} else if (command == TODO_FIXUP) {
|
||||
strbuf_addf(&buf, "\n%s ", comment_line_str);
|
||||
strbuf_addf(&buf, _(skip_nth_commit_msg_fmt),
|
||||
++ctx->current_fixup_count + 1);
|
||||
strbuf_addch(&buf, '\n');
|
||||
add_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);
|
||||
strbuf_addstr(&buf, "\n\n");
|
||||
strbuf_add_commented_lines(&buf, body, strlen(body),
|
||||
comment_line_str);
|
||||
|
||||
30
sequencer.h
30
sequencer.h
@@ -119,6 +119,13 @@ enum todo_command {
|
||||
TODO_COMMENT
|
||||
};
|
||||
|
||||
/* Bits for the "flags" member of struct todo_item */
|
||||
enum todo_item_flags {
|
||||
TODO_EDIT_MERGE_MSG = (1 << 0),
|
||||
TODO_REPLACE_FIXUP_MSG = (1 << 1),
|
||||
TODO_EDIT_FIXUP_MSG = (1 << 2),
|
||||
};
|
||||
|
||||
struct todo_item {
|
||||
enum todo_command command;
|
||||
struct commit *commit;
|
||||
@@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);
|
||||
*/
|
||||
void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);
|
||||
|
||||
/*
|
||||
* Append the "This is a combination of N commits." banner that "git rebase
|
||||
* -i" writes at the top of a squashed commit's message, commented out with
|
||||
* the comment character.
|
||||
*/
|
||||
void add_squash_combination_header(struct strbuf *buf, int n);
|
||||
|
||||
/*
|
||||
* Append the header (1-based N) that "git rebase -i" writes above each message
|
||||
* when squashing, commented out with the comment character. With SKIP it reads
|
||||
* "The ... commit message will be skipped" for a message that is dropped (a
|
||||
* fixup), otherwise "This is the ... commit message".
|
||||
*/
|
||||
void add_squash_message_header(struct strbuf *buf, int n, int skip);
|
||||
|
||||
/*
|
||||
* Return the length of the leading subject of BODY when it should be commented
|
||||
* out in a squash message, or 0 otherwise. An "amend!" subject always
|
||||
* qualifies; "squash!" and "fixup!" subjects only when SQUASHING, since a
|
||||
* plain fixup chain keeps them.
|
||||
*/
|
||||
size_t squash_subject_comment_len(const char *body, int squashing);
|
||||
|
||||
void append_conflicts_hint(struct index_state *istate,
|
||||
struct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);
|
||||
enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,
|
||||
|
||||
@@ -407,6 +407,7 @@ integration_tests = [
|
||||
't3452-history-split.sh',
|
||||
't3453-history-fixup.sh',
|
||||
't3454-history-drop.sh',
|
||||
't3455-history-squash.sh',
|
||||
't3500-cherry.sh',
|
||||
't3501-revert-cherry-pick.sh',
|
||||
't3502-cherry-pick-merge.sh',
|
||||
|
||||
884
t/t3455-history-squash.sh
Executable file
884
t/t3455-history-squash.sh
Executable file
@@ -0,0 +1,884 @@
|
||||
#!/bin/sh
|
||||
|
||||
test_description='tests for git-history squash subcommand'
|
||||
|
||||
. ./test-lib.sh
|
||||
|
||||
stage_file () {
|
||||
printf "%s\n" "$1" >file &&
|
||||
git add file
|
||||
}
|
||||
|
||||
commit_with_message () {
|
||||
printf "%b" "$1" >msg &&
|
||||
git commit --allow-empty -qF msg
|
||||
}
|
||||
|
||||
check_commit_count () {
|
||||
git rev-list --count "$1" >actual &&
|
||||
echo "$2" >expect &&
|
||||
test_cmp expect actual
|
||||
}
|
||||
|
||||
check_log_subjects () {
|
||||
git log --format="%s" "$1" >actual &&
|
||||
cat >expect &&
|
||||
test_cmp expect actual
|
||||
}
|
||||
|
||||
check_log_messages () {
|
||||
git log --format="%B" "$1" >actual &&
|
||||
cat >expect &&
|
||||
test_cmp expect actual
|
||||
}
|
||||
|
||||
# Checks that the author data of two commits matches
|
||||
# Usage: check_commit_author <rev1> <rev2>
|
||||
check_commit_author () {
|
||||
git show -s --format="%an <%ae> %ad" "$1" >expect &&
|
||||
git show -s --format="%an <%ae> %ad" "$2" >actual &&
|
||||
test_cmp expect actual
|
||||
}
|
||||
|
||||
test_expect_success 'setup linear history touching two files' '
|
||||
test_commit base file a start &&
|
||||
GIT_AUTHOR_NAME=One GIT_AUTHOR_EMAIL=one@example.com \
|
||||
test_commit one other x &&
|
||||
GIT_AUTHOR_NAME=Two GIT_AUTHOR_EMAIL=two@example.com \
|
||||
test_commit two file c &&
|
||||
GIT_AUTHOR_NAME=Three GIT_AUTHOR_EMAIL=three@example.com \
|
||||
test_commit three file d
|
||||
'
|
||||
|
||||
test_expect_success 'errors on missing range argument' '
|
||||
test_must_fail git history squash 2>err &&
|
||||
test_grep "expects a revision range" err
|
||||
'
|
||||
|
||||
test_expect_success 'errors on an empty range' '
|
||||
test_must_fail git history squash HEAD..HEAD 2>err &&
|
||||
test_grep "the revision range is empty" err
|
||||
'
|
||||
|
||||
test_expect_success 'errors on a single revision that is not a range' '
|
||||
test_must_fail git history squash HEAD 2>err &&
|
||||
test_grep "not a .*range" err &&
|
||||
test_must_fail git history squash HEAD~1 2>err &&
|
||||
test_grep "not a .*range" err
|
||||
'
|
||||
|
||||
test_expect_success 'errors on a range holding a single commit' '
|
||||
git reset --hard three &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
|
||||
test_must_fail git history squash "HEAD^!" 2>err &&
|
||||
test_grep "single commit; nothing to squash" err &&
|
||||
test_cmp_rev "$head_before" HEAD
|
||||
'
|
||||
|
||||
test_expect_success 'rejects root commit' '
|
||||
# create a disconnected root commit
|
||||
oid=$(git commit-tree -m root three^{tree}) &&
|
||||
# because we pass --ancestry-path when calling setup_revs() it the
|
||||
# revision walk will only include commits decended from $oid so
|
||||
# we need to give it another --ancestry-path commit to actually walk
|
||||
# any commits.
|
||||
test_must_fail git history squash --ancestry-path=start $oid..three 2>err &&
|
||||
echo "error: cannot squash down to root commit" >expect &&
|
||||
test_cmp expect err
|
||||
'
|
||||
|
||||
test_expect_success 'rejects multiple tips' '
|
||||
oid=$(git commit-tree -m tip -p start^0 three^{tree}) &&
|
||||
test_must_fail git history squash ^start $oid three~1 2>err &&
|
||||
echo "error: the revision range contains more than one tip commit" >expect &&
|
||||
test_cmp expect err &&
|
||||
|
||||
git reset --hard three &&
|
||||
git history squash --no-edit ^start three~1 three &&
|
||||
test_cmp_rev HEAD~1 start^0 &&
|
||||
test_cmp_rev HEAD^{tree} three^{tree}
|
||||
'
|
||||
|
||||
test_expect_success 'accepts multiple revision arguments with an exclusion' '
|
||||
git reset --hard three &&
|
||||
git branch -f keep HEAD~2 &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --no-edit start..HEAD ^keep &&
|
||||
|
||||
git reflog -1 --format=%gs >actual &&
|
||||
echo "squash: updating start..HEAD ^keep" >expect &&
|
||||
test_cmp expect actual &&
|
||||
|
||||
check_log_subjects start..HEAD <<-\EOF &&
|
||||
two
|
||||
one
|
||||
EOF
|
||||
test_cmp_rev keep HEAD~1 &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
|
||||
git branch -D keep
|
||||
'
|
||||
|
||||
test_expect_success 'squashes a branch the current branch is not on' '
|
||||
git reset --hard three &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
git checkout -b off-history start &&
|
||||
test_commit --no-tag off-one off a &&
|
||||
test_commit --no-tag off-two off b &&
|
||||
git checkout "$main" &&
|
||||
|
||||
git history squash --no-edit start..off-history &&
|
||||
|
||||
check_commit_count start..off-history 1 &&
|
||||
test_cmp_rev "$head_before" HEAD &&
|
||||
|
||||
git branch -D off-history
|
||||
'
|
||||
|
||||
test_expect_success 'squashes a range into a single commit without changing the tree' '
|
||||
git reset --hard three &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --no-edit --dry-run start.. >out &&
|
||||
predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
|
||||
test_cmp_rev "$head_before" HEAD &&
|
||||
|
||||
git history squash --no-edit start.. &&
|
||||
|
||||
test "$predicted" = "$(git rev-parse HEAD)" &&
|
||||
check_commit_count start..HEAD 1 &&
|
||||
test_cmp_rev start HEAD^ &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
check_log_subjects -1 <<-\EOF &&
|
||||
one
|
||||
EOF
|
||||
git reflog >reflog &&
|
||||
test_grep "squash: updating" reflog
|
||||
'
|
||||
|
||||
test_expect_success 'sanitizes rev-list walk options, before and after --' '
|
||||
git reset --hard three &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --no-edit --date-order start.. 2>err &&
|
||||
test_grep "ignoring rev-list options" err &&
|
||||
test_cmp_rev start HEAD^ &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
|
||||
git reset --hard three &&
|
||||
git history squash --no-edit -- --reverse start.. 2>err &&
|
||||
test_grep "ignoring rev-list options" err &&
|
||||
test_cmp_rev start HEAD^ &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
|
||||
'
|
||||
|
||||
test_expect_success 'squashes an interior range and replays descendants verbatim' '
|
||||
git reset --hard three &&
|
||||
final_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --no-edit start..@~1 &&
|
||||
|
||||
check_log_subjects start..HEAD <<-\EOF &&
|
||||
three
|
||||
one
|
||||
EOF
|
||||
|
||||
test_cmp_rev start HEAD~2 &&
|
||||
test "$final_tree" = "$(git rev-parse HEAD^{tree})"
|
||||
'
|
||||
|
||||
test_expect_success 'squashes when the base is the root commit' '
|
||||
git reset --hard three &&
|
||||
root=$(git rev-list --max-parents=0 HEAD) &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --no-edit "$root.." &&
|
||||
|
||||
check_commit_count "$root..HEAD" 1 &&
|
||||
test_cmp_rev "$root" HEAD^ &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
|
||||
'
|
||||
|
||||
test_expect_success 'squashing a mix of fixups' '
|
||||
git reset --hard three &&
|
||||
echo fix >file &&
|
||||
git commit --fixup=two -a &&
|
||||
echo really fix >file &&
|
||||
git commit --fixup=one -a &&
|
||||
echo really really fix >file &&
|
||||
git commit --fixup=HEAD~1 -a && # fixup! two
|
||||
echo really really really fix >file &&
|
||||
git commit --fixup=HEAD~1 -a && # fixup! one
|
||||
|
||||
# squashing fixup! with a target that is not being squashed fails
|
||||
test_must_fail git history squash one.. 2>err &&
|
||||
test_grep "^error: cannot squash .* (fixup! one): its target is not being squashed" err &&
|
||||
|
||||
# squashing fixup! into fixup! with a different target fails
|
||||
test_must_fail git history squash HEAD~4.. 2>err && # HEAD~4 is fixup! two
|
||||
test_grep "^error: cannot squash .* (fixup! one): its target is not being squashed" err &&
|
||||
|
||||
# squashing a sequence of fixup! commits into their targets
|
||||
git history squash --no-edit start..HEAD~1 &&
|
||||
test_cmp_rev start HEAD~2 &&
|
||||
check_commit_author one HEAD~1 &&
|
||||
test_commit_message HEAD~1 -m one &&
|
||||
|
||||
# squashing "fixup! fixup! <target>" into "<target>"
|
||||
git history squash --no-edit start.. &&
|
||||
test_cmp_rev start HEAD~1 &&
|
||||
check_commit_author one HEAD &&
|
||||
test_commit_message HEAD -m one
|
||||
'
|
||||
|
||||
test_expect_success 'squashing "squash!" messages' '
|
||||
git reset --hard two &&
|
||||
echo fix >file &&
|
||||
git commit --fixup=HEAD -a &&
|
||||
oldest=$(git rev-parse HEAD) &&
|
||||
echo better fix >file &&
|
||||
git commit -a -F - <<-EOF &&
|
||||
squash! $(git rev-parse two)
|
||||
|
||||
Append this
|
||||
EOF
|
||||
|
||||
echo an even better fix >file &&
|
||||
git commit -a -F - <<-EOF &&
|
||||
squash! squash! two
|
||||
|
||||
Append this as well
|
||||
EOF
|
||||
|
||||
# must edit when squashing "squash!" into its target
|
||||
test_must_fail git history squash --no-edit two^.. 2>err &&
|
||||
test_grep "^error: squashing .* (squash! [a-f0-9]*) would discard its message" err &&
|
||||
|
||||
# squashing "squash!" into "fixup!" appends messages and changes
|
||||
# subject prefix
|
||||
git history squash --no-edit two.. &&
|
||||
test_cmp_rev HEAD^ two &&
|
||||
test_commit_message HEAD <<-\EOF &&
|
||||
squash! two
|
||||
|
||||
Append this
|
||||
|
||||
Append this as well
|
||||
EOF
|
||||
check_commit_author "$oldest" HEAD &&
|
||||
|
||||
git commit --allow-empty -F - <<-\EOF &&
|
||||
amend! two
|
||||
|
||||
A new message
|
||||
EOF
|
||||
|
||||
# "amend!" does not replace "squash!"
|
||||
test_must_fail git history squash --no-edit HEAD~2.. 2>err &&
|
||||
test_grep "^error: squashing .* (amend! two) would overwrite .squash!. message" err
|
||||
'
|
||||
|
||||
test_expect_success '--no-edit uses last "amend!" message without an editor' '
|
||||
git reset --hard three &&
|
||||
write_script editor <<-\EOF &&
|
||||
exit 1
|
||||
EOF
|
||||
test_set_editor "$(pwd)/editor" &&
|
||||
echo fix >file &&
|
||||
git commit --author="Fix Me <fix.me@example.com>" --fixup=HEAD -a &&
|
||||
git commit --allow-empty -F - <<-EOF &&
|
||||
amend! $(git rev-parse --short HEAD)
|
||||
|
||||
The first reword
|
||||
|
||||
More detail
|
||||
EOF
|
||||
|
||||
git commit --allow-empty -F - <<-\EOF &&
|
||||
amend! three
|
||||
|
||||
The second reword
|
||||
|
||||
Extra detail
|
||||
EOF
|
||||
|
||||
test_commit WIP &&
|
||||
|
||||
cat >msg <<-EOF &&
|
||||
amend! $(git rev-parse HEAD^ | tr a-f A-F)
|
||||
|
||||
The third reword
|
||||
|
||||
Excruciating detail
|
||||
EOF
|
||||
|
||||
git commit --author="Someone Else <s.else@example.com>" --allow-empty \
|
||||
-F msg &&
|
||||
|
||||
# squashing amend! updates the commit message
|
||||
git history squash --no-edit three^.. &&
|
||||
sed -e 1,2d msg | test_commit_message HEAD &&
|
||||
check_commit_author three HEAD &&
|
||||
test_cmp_rev HEAD^ three^ &&
|
||||
|
||||
# squashing amend! into fixup! updates subject prefix
|
||||
git reset --hard HEAD@{1} &&
|
||||
git history squash --no-edit three.. &&
|
||||
sed "1s/.*/amend! three/" msg | test_commit_message HEAD &&
|
||||
check_commit_author HEAD@{1}~4 HEAD &&
|
||||
test_cmp_rev HEAD^ three &&
|
||||
|
||||
# squashing amend! into amend! keeps original subject line
|
||||
git reset --hard HEAD@{1} &&
|
||||
git history squash --no-edit HEAD~3.. &&
|
||||
sed "1s/.*/amend! three/" msg | test_commit_message HEAD &&
|
||||
test_cmp_rev HEAD~3 three &&
|
||||
|
||||
# all amend! messages must target the first commit
|
||||
git reset --hard HEAD@{1} &&
|
||||
git commit --allow-empty -F - <<-\EOF &&
|
||||
amend! WIP
|
||||
|
||||
The real message
|
||||
EOF
|
||||
|
||||
test_must_fail git history squash --no-edit HEAD~4.. 2>err &&
|
||||
test_grep "^error: cannot squash .* that does not target" err &&
|
||||
|
||||
# amend! message that targets commit that is not in range is rejected
|
||||
test_must_fail git history squash --no-edit HEAD~3.. 2>err &&
|
||||
test_grep "^error: cannot squash .* target is not being squashed" err &&
|
||||
test_set_editor :
|
||||
'
|
||||
|
||||
test_expect_success 'squashing fixups into a merge' '
|
||||
test_when_finished \
|
||||
"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \
|
||||
git branch -D feature" &&
|
||||
git checkout -f start &&
|
||||
test_commit F1 &&
|
||||
git checkout -b feature start &&
|
||||
test_commit F2 &&
|
||||
git merge F1 &&
|
||||
echo fixed >F1.t &&
|
||||
cat >msg <<-EOF &&
|
||||
amend! $(git rev-parse HEAD)
|
||||
|
||||
merge F1 and F2
|
||||
|
||||
reworded
|
||||
EOF
|
||||
|
||||
git commit -a -F msg &&
|
||||
git history squash --no-edit HEAD^^! HEAD &&
|
||||
test_cmp_rev HEAD^1 F2 &&
|
||||
test_cmp_rev HEAD^2 F1 &&
|
||||
test_cmp_rev HEAD@{1}^{tree} HEAD^{tree} &&
|
||||
sed 1,2d msg | test_commit_message HEAD
|
||||
'
|
||||
|
||||
test_expect_success 'edits every message and aborts on an empty result' '
|
||||
git reset --hard start &&
|
||||
stage_file b &&
|
||||
git commit -m "re-one subject" -m "re-one body line" &&
|
||||
test_commit --no-tag re-two file c &&
|
||||
test_commit re-three file d &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
|
||||
write_script empty-editor <<-\EOF &&
|
||||
>"$1"
|
||||
EOF
|
||||
test_set_editor "$(pwd)/empty-editor" &&
|
||||
test_must_fail git history squash start.. 2>err &&
|
||||
test_grep "Aborting commit due to empty commit message" err &&
|
||||
test_cmp_rev "$head_before" HEAD &&
|
||||
|
||||
write_script editor <<-\EOF &&
|
||||
cat "$1" >edited &&
|
||||
echo combined >"$1"
|
||||
EOF
|
||||
test_set_editor "$(pwd)/editor" &&
|
||||
git history squash start.. &&
|
||||
|
||||
cat >expect <<-EOF &&
|
||||
# This is a combination of 3 commits.
|
||||
# This is the 1st commit message:
|
||||
|
||||
re-one subject
|
||||
|
||||
re-one body line
|
||||
|
||||
# This is the commit message #2:
|
||||
|
||||
re-two
|
||||
|
||||
# This is the commit message #3:
|
||||
|
||||
re-three
|
||||
|
||||
# Please enter the commit message for the squash changes. Lines starting
|
||||
# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
|
||||
# Changes to be committed:
|
||||
# modified: file
|
||||
#
|
||||
EOF
|
||||
test_cmp expect edited &&
|
||||
check_log_subjects -1 <<-\EOF
|
||||
combined
|
||||
EOF
|
||||
'
|
||||
|
||||
test_expect_success 'handles fixup!, squash! and amend! like rebase' '
|
||||
git reset --hard start &&
|
||||
test_commit --no-tag mark-base file b &&
|
||||
stage_file c &&
|
||||
commit_with_message "fixup! mark-base\n\nfixup body\n" &&
|
||||
stage_file d &&
|
||||
commit_with_message "squash! mark-base\n\nsquash remark\n" &&
|
||||
stage_file e &&
|
||||
commit_with_message "amend! mark-base\n\namended message\n" &&
|
||||
|
||||
write_script editor <<-\EOF &&
|
||||
cat "$1" >edited
|
||||
EOF
|
||||
test_set_editor "$(pwd)/editor" &&
|
||||
git history squash start.. &&
|
||||
|
||||
cat >expect <<-EOF &&
|
||||
# This is a combination of 4 commits.
|
||||
# This is the 1st commit message:
|
||||
|
||||
mark-base
|
||||
|
||||
# The commit message #2 will be skipped:
|
||||
|
||||
# fixup! mark-base
|
||||
#
|
||||
# fixup body
|
||||
|
||||
# This is the commit message #3:
|
||||
|
||||
# squash! mark-base
|
||||
|
||||
squash remark
|
||||
|
||||
# This is the commit message #4:
|
||||
|
||||
# amend! mark-base
|
||||
|
||||
amended message
|
||||
|
||||
# Please enter the commit message for the squash changes. Lines starting
|
||||
# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
|
||||
# Changes to be committed:
|
||||
# modified: file
|
||||
#
|
||||
EOF
|
||||
test_cmp expect edited &&
|
||||
check_log_messages -1 <<-\EOF
|
||||
mark-base
|
||||
|
||||
squash remark
|
||||
|
||||
amended message
|
||||
|
||||
EOF
|
||||
'
|
||||
|
||||
test_expect_success 'groups fixups under their targets in the editor' '
|
||||
git reset --hard start &&
|
||||
test_commit --no-tag alpha file a1 &&
|
||||
test_commit --no-tag beta file b1 &&
|
||||
stage_file a2 &&
|
||||
commit_with_message "fixup! alpha\n" &&
|
||||
stage_file b2 &&
|
||||
commit_with_message "fixup! beta\n" &&
|
||||
|
||||
write_script editor <<-\EOF &&
|
||||
cat "$1" >edited
|
||||
EOF
|
||||
test_set_editor "$(pwd)/editor" &&
|
||||
git history squash start.. &&
|
||||
|
||||
cat >expect <<-EOF &&
|
||||
# This is a combination of 4 commits.
|
||||
# This is the 1st commit message:
|
||||
|
||||
alpha
|
||||
|
||||
# The commit message #2 will be skipped:
|
||||
|
||||
# fixup! alpha
|
||||
|
||||
# This is the commit message #3:
|
||||
|
||||
beta
|
||||
|
||||
# The commit message #4 will be skipped:
|
||||
|
||||
# fixup! beta
|
||||
|
||||
# Please enter the commit message for the squash changes. Lines starting
|
||||
# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
|
||||
# Changes to be committed:
|
||||
# modified: file
|
||||
#
|
||||
EOF
|
||||
test_cmp expect edited
|
||||
'
|
||||
|
||||
test_expect_success 'lets amend! replace its target message in the editor' '
|
||||
git reset --hard start &&
|
||||
test_commit --no-tag mark-base file b &&
|
||||
stage_file c &&
|
||||
commit_with_message "amend! mark-base\n\namended message\n" &&
|
||||
stage_file d &&
|
||||
commit_with_message "squash! mark-base\n\nsquash remark\n" &&
|
||||
|
||||
write_script editor <<-\EOF &&
|
||||
cat "$1" >edited
|
||||
EOF
|
||||
test_set_editor "$(pwd)/editor" &&
|
||||
git history squash start.. &&
|
||||
|
||||
cat >expect <<-EOF &&
|
||||
# This is a combination of 3 commits.
|
||||
# The 1st commit message will be skipped:
|
||||
|
||||
# mark-base
|
||||
|
||||
# This is the commit message #2:
|
||||
|
||||
# amend! mark-base
|
||||
|
||||
amended message
|
||||
|
||||
# This is the commit message #3:
|
||||
|
||||
# squash! mark-base
|
||||
|
||||
squash remark
|
||||
|
||||
# Please enter the commit message for the squash changes. Lines starting
|
||||
# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
|
||||
# Changes to be committed:
|
||||
# modified: file
|
||||
#
|
||||
EOF
|
||||
test_cmp expect edited &&
|
||||
check_log_messages -1 <<-\EOF
|
||||
amended message
|
||||
|
||||
squash remark
|
||||
|
||||
EOF
|
||||
'
|
||||
|
||||
test_expect_success '--update-refs=head only moves HEAD' '
|
||||
git reset --hard three &&
|
||||
git branch -f other HEAD &&
|
||||
other_before=$(git rev-parse other) &&
|
||||
|
||||
git history squash --no-edit --update-refs=head start.. &&
|
||||
|
||||
check_commit_count start..HEAD 1 &&
|
||||
test_cmp_rev "$other_before" other
|
||||
'
|
||||
|
||||
test_expect_success 'refuses to fold a range a branch points into' '
|
||||
test_when_finished \
|
||||
"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \
|
||||
git branch -D feature" &&
|
||||
git checkout -f -b feature start &&
|
||||
test_commit C1 &&
|
||||
test_commit C2 &&
|
||||
git checkout -b topic-1 start &&
|
||||
test_commit C3 &&
|
||||
test_commit C4 &&
|
||||
git checkout C3 &&
|
||||
test_commit C5 &&
|
||||
git checkout feature &&
|
||||
git merge C5 &&
|
||||
test_commit C6 &&
|
||||
git checkout -b topic-2 C2 &&
|
||||
test_commit C7 &&
|
||||
git checkout feature &&
|
||||
|
||||
test_must_fail git history squash start.. 2>err &&
|
||||
# TODO: check the branch names when we print them (topic-1 & topic-2)
|
||||
test_grep "^error: a branch points to" err &&
|
||||
test_grep "^hint: .* --update-refs=head" err &&
|
||||
test_cmp_rev C6 HEAD &&
|
||||
|
||||
# squash succeeds with --update-refs=head
|
||||
git history squash --no-edit --update-refs=head start.. &&
|
||||
test_cmp_rev start HEAD^ &&
|
||||
test_cmp_rev C6^{tree} HEAD^{tree} &&
|
||||
test_cmp_rev C6 HEAD@{1}
|
||||
'
|
||||
|
||||
test_expect_success 'advice.historyUpdateRefs silences the hint' '
|
||||
git reset --hard three &&
|
||||
git branch -f mid HEAD~1 &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
|
||||
test_must_fail git -c advice.historyUpdateRefs=false \
|
||||
history squash start.. 2>err &&
|
||||
test_grep "^error: a branch points to" err &&
|
||||
test_grep ! "hint:" err &&
|
||||
test_cmp_rev "$head_before" HEAD &&
|
||||
|
||||
git branch -D mid
|
||||
'
|
||||
|
||||
test_expect_success 'leaves tags and remote-tracking refs unchanged' '
|
||||
git reset --hard three &&
|
||||
git tag -f mark HEAD~1 &&
|
||||
git update-ref refs/remotes/origin/mark HEAD~1 &&
|
||||
mark_before=$(git rev-parse mark) &&
|
||||
|
||||
git history squash --no-edit start.. &&
|
||||
|
||||
test_cmp_rev "$mark_before" mark &&
|
||||
test_cmp_rev "$mark_before" refs/remotes/origin/mark &&
|
||||
|
||||
git tag -d mark &&
|
||||
git update-ref -d refs/remotes/origin/mark
|
||||
'
|
||||
|
||||
test_expect_success 'squashes a range whose internal merge has a single base' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
test_commit --no-tag before-side file b &&
|
||||
git checkout -b inner-side &&
|
||||
test_commit --no-tag on-inner-side inner x &&
|
||||
git checkout "$main" &&
|
||||
test_commit --no-tag after-side file c &&
|
||||
git merge --no-ff -m merge inner-side &&
|
||||
git branch -D inner-side &&
|
||||
test_commit --no-tag after-merge file d &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --no-edit start.. &&
|
||||
|
||||
check_commit_count start..HEAD 1 &&
|
||||
check_log_subjects -1 <<-\EOF &&
|
||||
before-side
|
||||
EOF
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
test_path_is_file inner
|
||||
'
|
||||
|
||||
test_expect_success 'folds a merge of a branch that forked at the base' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
git checkout -b base-fork-side &&
|
||||
test_commit --no-tag base-fork-side side x &&
|
||||
git checkout "$main" &&
|
||||
test_commit --no-tag base-fork-main file b &&
|
||||
git merge --no-ff -m "merge base-fork-side" base-fork-side &&
|
||||
git branch -D base-fork-side &&
|
||||
test_commit --no-tag base-fork-tail file c &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --no-edit start.. &&
|
||||
|
||||
check_commit_count start..HEAD 1 &&
|
||||
test_cmp_rev start HEAD^ &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
test_path_is_file side
|
||||
'
|
||||
|
||||
test_expect_success 'refuses a merge whose other parent is outside the range' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
git checkout -b outside-parent &&
|
||||
test_commit --no-tag outside-parent outside x &&
|
||||
git checkout "$main" &&
|
||||
test_commit --no-tag outside-main file b &&
|
||||
base=$(git rev-parse HEAD) &&
|
||||
test_commit --no-tag outside-mid file c &&
|
||||
git merge --no-ff -m "merge outside-parent" outside-parent &&
|
||||
git branch -D outside-parent &&
|
||||
merged=$(git rev-parse HEAD) &&
|
||||
|
||||
test_must_fail git history squash "$base.." 2>err &&
|
||||
test_grep "parent .* of commit .* is outside the revision range" err &&
|
||||
test_cmp_rev "$merged" HEAD
|
||||
'
|
||||
|
||||
test_expect_success 'folds a range whose tip is a merge commit' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
test_commit --no-tag tipmerge-base file b &&
|
||||
git checkout -b tipmerge-side &&
|
||||
test_commit --no-tag tipmerge-side side x &&
|
||||
git checkout "$main" &&
|
||||
test_commit --no-tag tipmerge-main file c &&
|
||||
git merge --no-ff -m "merge tipmerge-side" tipmerge-side &&
|
||||
git branch -D tipmerge-side &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --no-edit start.. &&
|
||||
|
||||
check_commit_count start..HEAD 1 &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
test_path_is_file side
|
||||
'
|
||||
|
||||
test_expect_success 'folds a range whose base is a merge commit' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
git checkout -b basemerge-side &&
|
||||
test_commit --no-tag basemerge-side side x &&
|
||||
git checkout "$main" &&
|
||||
test_commit --no-tag basemerge-main file b &&
|
||||
git merge --no-ff -m "merge basemerge-side" basemerge-side &&
|
||||
git branch -D basemerge-side &&
|
||||
base=$(git rev-parse HEAD) &&
|
||||
test_commit --no-tag basemerge-one file c &&
|
||||
test_commit --no-tag basemerge-two file d &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --no-edit "$base.." &&
|
||||
|
||||
check_commit_count "$base..HEAD" 1 &&
|
||||
test_cmp_rev "$base" HEAD^ &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
|
||||
'
|
||||
|
||||
test_expect_success 'folds a range with two interior merges' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
test_commit --no-tag two-merge-a file a1 &&
|
||||
git checkout -b two-merge-s1 &&
|
||||
test_commit --no-tag two-merge-s1 s1 x &&
|
||||
git checkout "$main" &&
|
||||
git merge --no-ff -m "merge s1" two-merge-s1 &&
|
||||
test_commit --no-tag two-merge-b file b1 &&
|
||||
git checkout -b two-merge-s2 &&
|
||||
test_commit --no-tag two-merge-s2 s2 y &&
|
||||
git checkout "$main" &&
|
||||
git merge --no-ff -m "merge s2" two-merge-s2 &&
|
||||
git branch -D two-merge-s1 two-merge-s2 &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --no-edit start.. &&
|
||||
|
||||
check_commit_count start..HEAD 1 &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
test_path_is_file s1 &&
|
||||
test_path_is_file s2
|
||||
'
|
||||
|
||||
test_expect_success 'folds a range with a nested merge' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
git checkout -b nested-outer &&
|
||||
test_commit --no-tag nested-outer outer x &&
|
||||
git checkout -b nested-inner &&
|
||||
test_commit --no-tag nested-inner inner y &&
|
||||
git checkout nested-outer &&
|
||||
git merge --no-ff -m "merge inner" nested-inner &&
|
||||
git checkout "$main" &&
|
||||
test_commit --no-tag nested-main file b1 &&
|
||||
git merge --no-ff -m "merge outer" nested-outer &&
|
||||
git branch -D nested-outer nested-inner &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --no-edit start.. &&
|
||||
|
||||
check_commit_count start..HEAD 1 &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
test_path_is_file outer &&
|
||||
test_path_is_file inner
|
||||
'
|
||||
|
||||
test_expect_success 'folds a range with an octopus merge' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
test_commit --no-tag octo-base file a1 &&
|
||||
git checkout -b octo-1 &&
|
||||
test_commit --no-tag octo-1 o1 x &&
|
||||
git checkout "$main" &&
|
||||
git checkout -b octo-2 &&
|
||||
test_commit --no-tag octo-2 o2 y &&
|
||||
git checkout "$main" &&
|
||||
git merge --no-ff -m octopus octo-1 octo-2 &&
|
||||
git branch -D octo-1 octo-2 &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --no-edit start.. &&
|
||||
|
||||
check_commit_count start..HEAD 1 &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
test_path_is_file o1 &&
|
||||
test_path_is_file o2
|
||||
'
|
||||
|
||||
test_expect_success 'refuses an octopus merge with an arm forked before the base' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
git checkout -b octo-pre &&
|
||||
test_commit octo-pre-side pside x &&
|
||||
git checkout "$main" &&
|
||||
test_commit octo-pre-main file b1 &&
|
||||
octo_base=$(git rev-parse HEAD) &&
|
||||
git checkout -b octo-within &&
|
||||
test_commit --no-tag octo-within wside y &&
|
||||
git checkout "$main" &&
|
||||
git merge --no-ff -m octopus octo-pre octo-within &&
|
||||
merged=$(git rev-parse HEAD) &&
|
||||
git branch -D octo-pre octo-within &&
|
||||
|
||||
test_must_fail git history squash "$octo_base.." 2>err &&
|
||||
test_grep "parent .* of commit .* is outside the revision range" err &&
|
||||
test_cmp_rev "$merged" HEAD
|
||||
'
|
||||
|
||||
test_expect_success 'refuses when a descendant above the range is a merge' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
test_commit --no-tag desc-one file b &&
|
||||
test_commit --no-tag desc-two file c &&
|
||||
git tag desc-tip &&
|
||||
git checkout -b desc-above &&
|
||||
test_commit --no-tag desc-above above x &&
|
||||
git checkout "$main" &&
|
||||
test_commit --no-tag desc-main file d &&
|
||||
git merge --no-ff -m "merge desc-above" desc-above &&
|
||||
git branch -D desc-above &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
|
||||
test_must_fail git history squash --no-edit start..desc-tip 2>err &&
|
||||
test_grep "merge commits is not supported" err &&
|
||||
test_cmp_rev "$head_before" HEAD
|
||||
'
|
||||
|
||||
test_expect_success 'refuses to fold a range a ref points into at a merge' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
test_commit --no-tag refmerge-base file b &&
|
||||
git checkout -b refmerge-side &&
|
||||
test_commit --no-tag refmerge-side side x &&
|
||||
git checkout "$main" &&
|
||||
test_commit --no-tag refmerge-main file c &&
|
||||
git merge --no-ff -m "interior merge" refmerge-side &&
|
||||
git branch -D refmerge-side &&
|
||||
git branch at-merge HEAD &&
|
||||
test_commit --no-tag refmerge-tail file d &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
|
||||
test_must_fail git history squash start.. 2>err &&
|
||||
# TODO: test for branch nome "at-merge"
|
||||
test_grep "a branch points to a commit" err &&
|
||||
test_cmp_rev "$head_before" HEAD &&
|
||||
|
||||
git branch -D at-merge
|
||||
'
|
||||
|
||||
test_done
|
||||
Reference in New Issue
Block a user