* [PATCH] pull: add --hard mode
@ 2026-08-18 11:34 Artur Bieniek via GitGitGadget
2026-08-18 14:48 ` Junio C Hamano
0 siblings, 1 reply; 2+ messages in thread
From: Artur Bieniek via GitGitGadget @ 2026-08-18 11:34 UTC (permalink / raw)
To: git; +Cc: Artur Bieniek, Artur Bieniek
From: Artur Bieniek <ar2rekb@gmail.com>
Add --hard as an explicit alternative to merge and rebase. After
fetching, require a single integration candidate and reset the current
branch, index, and working tree to it.
Preserve quiet and submodule recursion behavior, and reject options
that cannot be honored by a hard reset. Document the destructive
semantics and cover them in tests.
Signed-off-by: Artur Bieniek <ar2rekb@gmail.com>
---
[RFC] Add git pull --hard mode
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2384%2FArturBieniek4%2Fpull-hard-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2384/ArturBieniek4/pull-hard-v1
Pull-Request: https://github.com/git/git/pull/2384
Documentation/git-pull.adoc | 10 +++++-
builtin/pull.c | 68 ++++++++++++++++++++++++++++++++-----
t/t5521-pull-options.sh | 31 +++++++++++++++++
t/t5572-pull-submodule.sh | 18 ++++++++++
4 files changed, 118 insertions(+), 9 deletions(-)
diff --git a/Documentation/git-pull.adoc b/Documentation/git-pull.adoc
index 88f4fd3926..3a103a1630 100644
--- a/Documentation/git-pull.adoc
+++ b/Documentation/git-pull.adoc
@@ -24,7 +24,7 @@ with no arguments this defaults to the <<UPSTREAM-BRANCHES,upstream>>
for the current branch.
Then it integrates that branch into the current branch.
-There are 4 main options for integrating the remote branch:
+There are 5 main options for integrating the remote branch:
1. `git pull --ff-only` will only do "fast-forward" updates: it
fails if your local branch has diverged from the remote branch.
@@ -32,6 +32,7 @@ There are 4 main options for integrating the remote branch:
2. `git pull --rebase` runs `git rebase`
3. `git pull --no-rebase` runs `git merge`.
4. `git pull --squash` runs `git merge --squash`
+5. `git pull --hard` runs `git reset --hard` to the fetched branch.
You can also set the configuration options `pull.rebase`, `pull.squash`,
or `pull.ff` with your preferred behaviour.
@@ -119,6 +120,13 @@ unless you have read linkgit:git-rebase[1] carefully.
`--no-rebase`::
This is shorthand for `--rebase=false`.
+`--hard`::
+ Reset the current branch, index, and working tree to the fetched branch.
+ The pull must select exactly one branch. Local commits and changes to
+ tracked files are discarded. Untracked files or directories in the way
+ of writing tracked files may also be deleted. This option cannot be
+ combined with merge or rebase options, or with `--append`.
+
Options related to fetching
~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/builtin/pull.c b/builtin/pull.c
index db3ee0aab3..8324f06084 100644
--- a/builtin/pull.c
+++ b/builtin/pull.c
@@ -91,6 +91,7 @@ static char *opt_ff;
static const char *opt_verify_signatures;
static const char *opt_verify;
static int opt_autostash = -1;
+static int opt_hard;
static int config_rebase_autostash;
static int config_pull_autostash = -1;
static int check_trust_level = 1;
@@ -318,7 +319,9 @@ static void NORETURN die_no_merge_candidates(const char *repo, const char **refs
const char *remote = curr_branch ? curr_branch->remote_name : NULL;
if (*refspecs) {
- if (opt_rebase)
+ if (opt_hard)
+ fprintf_ln(stderr, _("There is no candidate for resetting to among the refs that you just fetched."));
+ else if (opt_rebase)
fprintf_ln(stderr, _("There is no candidate for rebasing against among the refs that you just fetched."));
else
fprintf_ln(stderr, _("There are no candidates for merging among the refs that you just fetched."));
@@ -331,7 +334,9 @@ static void NORETURN die_no_merge_candidates(const char *repo, const char **refs
repo);
} else if (!curr_branch) {
fprintf_ln(stderr, _("You are not currently on a branch."));
- if (opt_rebase)
+ if (opt_hard)
+ fprintf_ln(stderr, _("Please specify which branch you want to reset to."));
+ else if (opt_rebase)
fprintf_ln(stderr, _("Please specify which branch you want to rebase against."));
else
fprintf_ln(stderr, _("Please specify which branch you want to merge with."));
@@ -346,7 +351,9 @@ static void NORETURN die_no_merge_candidates(const char *repo, const char **refs
remote_name = _("<remote>");
fprintf_ln(stderr, _("There is no tracking information for the current branch."));
- if (opt_rebase)
+ if (opt_hard)
+ fprintf_ln(stderr, _("Please specify which branch you want to reset to."));
+ else if (opt_rebase)
fprintf_ln(stderr, _("Please specify which branch you want to rebase against."));
else
fprintf_ln(stderr, _("Please specify which branch you want to merge with."));
@@ -358,7 +365,11 @@ static void NORETURN die_no_merge_candidates(const char *repo, const char **refs
fprintf(stderr, "\n");
fprintf_ln(stderr, " git branch --set-upstream-to=%s/%s %s\n",
remote_name, _("<branch>"), curr_branch->name);
- } else
+ } else if (opt_hard)
+ fprintf_ln(stderr, _("Your configuration specifies to reset to the ref '%s'\n"
+ "from the remote, but no such ref was fetched."),
+ curr_branch->merge[0]->src);
+ else
fprintf_ln(stderr, _("Your configuration specifies to merge with the ref '%s'\n"
"from the remote, but no such ref was fetched."),
curr_branch->merge[0]->src);
@@ -570,6 +581,23 @@ static int run_merge(void)
return run_command(&cmd);
}
+static int run_reset(const struct object_id *oid)
+{
+ struct child_process cmd = CHILD_PROCESS_INIT;
+
+ strvec_pushl(&cmd.args, "reset", "--hard", NULL);
+ if (opt_verbosity < 0)
+ strvec_push(&cmd.args, "--quiet");
+ if (recurse_submodules == RECURSE_SUBMODULES_ON ||
+ recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)
+ strvec_push(&cmd.args, "--recurse-submodules");
+ else if (recurse_submodules == RECURSE_SUBMODULES_OFF)
+ strvec_push(&cmd.args, "--no-recurse-submodules");
+ strvec_push(&cmd.args, oid_to_hex(oid));
+ cmd.git_cmd = 1;
+ return run_command(&cmd);
+}
+
/**
* Returns remote's upstream branch for the current branch. If remote is NULL,
* the current branch's configured default remote is used. Returns NULL if
@@ -925,6 +953,8 @@ int cmd_pull(int argc,
PARSE_OPT_NOARG),
OPT_BOOL(0, "autostash", &opt_autostash,
N_("automatically stash/stash pop before and after")),
+ OPT_BOOL(0, "hard", &opt_hard,
+ N_("reset hard to the fetched branch")),
OPT_PASSTHRU_ARGV('s', "strategy", &opt_strategies, N_("strategy"),
N_("merge strategy to use"),
0),
@@ -1022,6 +1052,16 @@ int cmd_pull(int argc,
}
argc = parse_options(argc, argv, prefix, pull_options, pull_usage, 0);
+ if (opt_hard &&
+ (opt_rebase >= 0 || opt_diffstat || opt_log || opt_signoff ||
+ opt_squash || opt_commit || opt_edit || cleanup_arg || opt_ff ||
+ opt_verify_signatures || opt_verify || opt_autostash >= 0 ||
+ opt_strategies.nr || opt_strategy_opts.nr || opt_gpg_sign ||
+ opt_allow_unrelated_histories))
+ die(_("--hard cannot be combined with merge or rebase options"));
+ die_for_incompatible_opt2(opt_hard, "--hard",
+ opt_append && !strcmp(opt_append, "--append"),
+ "--append");
if (opt_autostash == -1)
opt_autostash = config_pull_autostash;
@@ -1037,7 +1077,7 @@ int cmd_pull(int argc,
parse_repo_refspecs(argc, argv, &repo, &refspecs);
- if (!opt_ff) {
+ if (!opt_hard && !opt_ff) {
opt_ff = xstrdup_or_null(config_get_ff());
/*
* A subtle point: opt_ff was set on the line above via
@@ -1056,13 +1096,15 @@ int cmd_pull(int argc,
}
}
- if (opt_rebase < 0)
+ if (opt_hard)
+ opt_rebase = REBASE_FALSE;
+ else if (opt_rebase < 0)
opt_rebase = config_get_rebase(&rebase_unspecified);
- if (repo_read_index_unmerged(the_repository))
+ if (!opt_hard && repo_read_index_unmerged(the_repository))
die_resolve_conflict("pull");
- if (file_exists(git_path_merge_head(the_repository)))
+ if (!opt_hard && file_exists(git_path_merge_head(the_repository)))
die_conclude_merge();
if (repo_get_oid(the_repository, "HEAD", &orig_head))
@@ -1090,6 +1132,16 @@ int cmd_pull(int argc,
if (opt_dry_run)
return 0;
+ if (opt_hard) {
+ get_merge_heads(&merge_heads);
+ if (!merge_heads.nr)
+ die_no_merge_candidates(repo, refspecs);
+ if (merge_heads.nr > 1)
+ die(_("Cannot hard reset to multiple branches."));
+ ret = run_reset(merge_heads.oid);
+ goto cleanup;
+ }
+
if (repo_get_oid(the_repository, "HEAD", &curr_head))
oidclr(&curr_head, the_repository->hash_algo);
diff --git a/t/t5521-pull-options.sh b/t/t5521-pull-options.sh
index 5e420c208c..31bb76b465 100755
--- a/t/t5521-pull-options.sh
+++ b/t/t5521-pull-options.sh
@@ -117,6 +117,37 @@ test_expect_success 'git pull --force' '
)
'
+test_expect_success 'git pull --hard' '
+ test_when_finished "rm -rf hard-parent hard" &&
+ git init hard-parent &&
+ test_commit -C hard-parent base &&
+ git clone hard-parent hard &&
+ test_commit -C hard local &&
+ test_commit -C hard-parent upstream obstruct upstream &&
+ git -C hard-parent branch side &&
+ (
+ cd hard &&
+ echo dirty >base.t &&
+ mkdir obstruct &&
+ echo untracked >obstruct/file &&
+ test_must_fail git pull --hard --ff-only 2>err &&
+ test_grep "cannot be combined" err &&
+ test_must_fail git pull --hard -a 2>err &&
+ test_grep "options .*--hard.* and .*--append.*" err &&
+ test_must_fail git pull --hard origin main side 2>err &&
+ test_grep "Cannot hard reset to multiple branches" err &&
+ git pull --hard &&
+ test_cmp_rev HEAD origin/main &&
+ test_path_is_missing local.t &&
+ test_path_is_file obstruct &&
+ git diff --quiet &&
+ git diff --cached --quiet &&
+ git pull -q --hard >out 2>quiet-err &&
+ test_must_be_empty out &&
+ test_must_be_empty quiet-err
+ )
+'
+
test_expect_success 'git pull --all' '
mkdir clonedmulti &&
(cd clonedmulti && git init &&
diff --git a/t/t5572-pull-submodule.sh b/t/t5572-pull-submodule.sh
index 42d14328b6..f2df277b79 100755
--- a/t/t5572-pull-submodule.sh
+++ b/t/t5572-pull-submodule.sh
@@ -106,6 +106,24 @@ test_expect_success " --[no-]recurse-submodule and submodule.recurse" '
test_path_is_file super/sub/merge_strategy_4.t
'
+test_expect_success 'pull --hard honors submodule recursion' '
+ test_commit -C child hard_recurse &&
+ git -C parent submodule update --remote &&
+ git -C parent add sub &&
+ git -C parent commit -m "update submodule" &&
+
+ git -C super pull --hard --recurse-submodules &&
+ test_path_is_file super/sub/hard_recurse.t &&
+
+ test_commit -C child hard_no_recurse &&
+ git -C parent submodule update --remote &&
+ git -C parent add sub &&
+ git -C parent commit -m "update submodule" &&
+
+ git -C super -c submodule.recurse=true pull --hard --no-recurse-submodules &&
+ test_path_is_missing super/sub/hard_no_recurse.t
+'
+
test_expect_success "fetch.recurseSubmodules option triggers recursive fetch (but not recursive update)" '
test_commit -C child merge_strategy_5 &&
# Omit the parent commit, otherwise this passes with the
base-commit: 745601a9a94110d74769ab605ccd4f61339758d2
--
gitgitgadget
^ permalink raw reply related [flat|nested] 2+ messages in thread* Re: [PATCH] pull: add --hard mode
2026-08-18 11:34 [PATCH] pull: add --hard mode Artur Bieniek via GitGitGadget
@ 2026-08-18 14:48 ` Junio C Hamano
0 siblings, 0 replies; 2+ messages in thread
From: Junio C Hamano @ 2026-08-18 14:48 UTC (permalink / raw)
To: Artur Bieniek via GitGitGadget; +Cc: git, Artur Bieniek, Artur Bieniek
"Artur Bieniek via GitGitGadget" <gitgitgadget@gmail.com> writes:
> From: Artur Bieniek <ar2rekb@gmail.com>
>
> Add --hard as an explicit alternative to merge and rebase. After
> fetching, require a single integration candidate and reset the current
> branch, index, and working tree to it.
There may be a population of users who *never* make changes to their
history or working tree, and always want to "hard reset to the
updated upstream". Doing so would be safe for them because they
create nothing in their tree whose loss matters.
Giving them a convenient and safe way to do so might be worth
considering, but the behavior is already safely and explicitly
achieved by running 'git fetch' followed by 'git reset --hard @{u}',
so I am not sure whether it is worth adding another way to do so.
More importantly, throwing it into 'git pull' feels very wrong.
The core purpose of 'git pull' is history integration. The command
is designed to help those who make their own changes and advance
history. Adding a destructive option to the command makes it easier
for them to trigger it by accident, and unlike the main target of
this new feature, they have things in their tree that they cannot
afford to lose to accidents or mistakes.
So, I am mildly against adding anything of this sort to 'git pull'.
For that matter, I am generally against making it convenient to
discard or destroy history. I prefer to keep these destructive
operations explicit, e.g., "fetch + reset --hard".
Thanks.
^ permalink raw reply [flat|nested] 2+ messages in thread
end of thread, other threads:[~2026-08-18 14:48 UTC | newest]
Thread overview: 2+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-08-18 11:34 [PATCH] pull: add --hard mode Artur Bieniek via GitGitGadget
2026-08-18 14:48 ` Junio C Hamano
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox