Merge branch 'hn/bisect-reset-when-found' into seen

The 'git bisect' command has been taught a
'--reset-when-found[=<where>]' option that tells the command to
automatically run 'git bisect reset' to jump back to the original
state or to the found culprit.

* hn/bisect-reset-when-found:
  bisect: add --reset-when-found to leave when done
  bisect: let bisect_reset() optionally check out quietly
This commit is contained in:
Junio C Hamano
2026-08-06 11:49:27 -07:00
4 changed files with 285 additions and 14 deletions

View File

@@ -10,7 +10,7 @@ SYNOPSIS
--------
[synopsis]
git bisect start [--term-(bad|new)=<term-new> --term-(good|old)=<term-old>]
[--no-checkout] [--first-parent] [<bad> [<good>...]] [--] [<pathspec>...]
[--no-checkout] [--first-parent] [--reset-when-found[=<where>]] [<bad> [<good>...]] [--] [<pathspec>...]
git bisect (bad|new|<term-new>) [<rev>]
git bisect (good|old|<term-old>) [<rev>...]
git bisect terms [--term-(good|old) | --term-(bad|new)]
@@ -20,7 +20,7 @@ git bisect reset [<commit>]
git bisect (visualize|view)
git bisect replay <logfile>
git bisect log
git bisect run <cmd> [<arg>...]
git bisect run [--reset-when-found[=<where>]] <cmd> [<arg>...]
git bisect help
DESCRIPTION
@@ -385,6 +385,16 @@ ignored.
This option is particularly useful in avoiding false positives when a merged
branch contained broken or non-buildable commits, but the merge itself was OK.
`--reset-when-found[=<where>]`::
Once the first bad commit is found, report it and clean up the
bisection state. `<where>` may be `original` to return to the commit
checked out before `git bisect start`, or `found` to leave the first
bad commit checked out. If `<where>` is omitted, it defaults to
`original`.
+
This option may be given to `git bisect start` or to `git bisect run`. It
cannot be used for a bisection started with `--no-checkout`.
EXAMPLES
--------

View File

@@ -488,6 +488,7 @@ static GIT_PATH_FUNC(git_path_bisect_start, "BISECT_START")
static GIT_PATH_FUNC(git_path_bisect_log, "BISECT_LOG")
static GIT_PATH_FUNC(git_path_bisect_terms, "BISECT_TERMS")
static GIT_PATH_FUNC(git_path_bisect_first_parent, "BISECT_FIRST_PARENT")
static GIT_PATH_FUNC(git_path_bisect_reset_when_found, "BISECT_RESET_WHEN_FOUND")
static void read_bisect_paths(struct strvec *array)
{
@@ -1213,6 +1214,7 @@ int bisect_clean_state(void)
unlink_or_warn(git_path_bisect_run());
unlink_or_warn(git_path_bisect_terms());
unlink_or_warn(git_path_bisect_first_parent());
unlink_or_warn(git_path_bisect_reset_when_found());
/*
* Cleanup BISECT_START last to support the --no-checkout option
* introduced in the commit 4796e823a.

View File

@@ -24,11 +24,12 @@ static GIT_PATH_FUNC(git_path_bisect_start, "BISECT_START")
static GIT_PATH_FUNC(git_path_bisect_log, "BISECT_LOG")
static GIT_PATH_FUNC(git_path_bisect_names, "BISECT_NAMES")
static GIT_PATH_FUNC(git_path_bisect_first_parent, "BISECT_FIRST_PARENT")
static GIT_PATH_FUNC(git_path_bisect_reset_when_found, "BISECT_RESET_WHEN_FOUND")
static GIT_PATH_FUNC(git_path_bisect_run, "BISECT_RUN")
#define BUILTIN_GIT_BISECT_START_USAGE \
N_("git bisect start [--term-(bad|new)=<term-new> --term-(good|old)=<term-old>]\n" \
" [--no-checkout] [--first-parent] [<bad> [<good>...]] [--] [<pathspec>...]")
" [--no-checkout] [--first-parent] [--reset-when-found[=<where>]] [<bad> [<good>...]] [--] [<pathspec>...]")
#define BUILTIN_GIT_BISECT_BAD_USAGE \
N_("git bisect (bad|new|<term-new>) [<rev>]")
#define BUILTIN_GIT_BISECT_GOOD_USAGE \
@@ -48,7 +49,7 @@ static GIT_PATH_FUNC(git_path_bisect_run, "BISECT_RUN")
#define BUILTIN_GIT_BISECT_LOG_USAGE \
"git bisect log"
#define BUILTIN_GIT_BISECT_RUN_USAGE \
N_("git bisect run <cmd> [<arg>...]")
N_("git bisect run [--reset-when-found[=<where>]] <cmd> [<arg>...]")
#define BUILTIN_GIT_BISECT_HELP_USAGE \
"git bisect help"
@@ -68,6 +69,12 @@ static const char * const git_bisect_usage[] = {
NULL
};
enum reset_when_found_mode {
RESET_WHEN_FOUND_NONE,
RESET_WHEN_FOUND_TO_ORIGINAL,
RESET_WHEN_FOUND_TO_FOUND,
};
struct add_bisect_ref_data {
struct rev_info *revs;
unsigned int object_flags;
@@ -234,7 +241,7 @@ static int write_terms(const char *bad, const char *good)
return res;
}
static int bisect_reset(const char *commit)
static int bisect_reset(const char *commit, bool quiet)
{
struct strbuf branch = STRBUF_INIT;
@@ -255,8 +262,10 @@ static int bisect_reset(const char *commit)
struct child_process cmd = CHILD_PROCESS_INIT;
cmd.git_cmd = 1;
strvec_pushl(&cmd.args, "checkout", "--ignore-other-worktrees",
branch.buf, "--", NULL);
strvec_pushl(&cmd.args, "checkout", "--ignore-other-worktrees", NULL);
if (quiet)
strvec_push(&cmd.args, "--quiet");
strvec_pushl(&cmd.args, branch.buf, "--", NULL);
if (run_command(&cmd)) {
error(_("could not check out original"
" HEAD '%s'. Try 'git bisect"
@@ -267,7 +276,79 @@ static int bisect_reset(const char *commit)
}
strbuf_release(&branch);
return bisect_clean_state();
return 0;
}
static int parse_reset_when_found(const char *value,
enum reset_when_found_mode *mode)
{
if (!strcmp(value, "original"))
*mode = RESET_WHEN_FOUND_TO_ORIGINAL;
else if (!strcmp(value, "found"))
*mode = RESET_WHEN_FOUND_TO_FOUND;
else
return error(_("invalid value for '--reset-when-found': '%s'"),
value);
return 0;
}
static const char *reset_when_found_mode_name(enum reset_when_found_mode mode)
{
switch (mode) {
case RESET_WHEN_FOUND_TO_ORIGINAL:
return "original";
case RESET_WHEN_FOUND_TO_FOUND:
return "found";
case RESET_WHEN_FOUND_NONE:
BUG("no name for unset reset-when-found mode");
}
BUG("unknown reset-when-found mode %d", mode);
}
static int read_reset_when_found(enum reset_when_found_mode *mode)
{
struct strbuf value = STRBUF_INIT;
int res = 0;
*mode = RESET_WHEN_FOUND_NONE;
if (is_empty_or_missing_file(git_path_bisect_reset_when_found()))
return 0;
if (strbuf_read_file(&value, git_path_bisect_reset_when_found(), 0) < 0) {
res = error_errno(_("could not read '%s'"),
git_path_bisect_reset_when_found());
goto out;
}
strbuf_trim(&value);
if (parse_reset_when_found(value.buf, mode))
res = -1;
out:
strbuf_release(&value);
return res;
}
static int bisect_reset_when_found(enum reset_when_found_mode mode)
{
struct bisect_terms terms = { 0 };
char *commit = NULL;
int res;
if (mode == RESET_WHEN_FOUND_TO_FOUND) {
read_bisect_terms(&terms.term_bad, &terms.term_good);
commit = xstrfmt("refs/bisect/%s", terms.term_bad);
} else if (mode == RESET_WHEN_FOUND_NONE) {
BUG("automatic reset requested without a reset mode");
}
res = bisect_reset(commit, true);
if (!res)
res = bisect_clean_state();
free(commit);
free_terms(&terms);
return res;
}
static void log_commit(FILE *fp,
@@ -687,7 +768,8 @@ static int bisect_successful(struct bisect_terms *terms)
return res;
}
static enum bisect_error bisect_next(struct bisect_terms *terms, const char *prefix)
static enum bisect_error bisect_next(struct bisect_terms *terms,
const char *prefix)
{
enum bisect_error res;
@@ -710,7 +792,8 @@ static enum bisect_error bisect_next(struct bisect_terms *terms, const char *pre
return res;
}
static enum bisect_error bisect_auto_next(struct bisect_terms *terms, const char *prefix)
static enum bisect_error bisect_auto_next(struct bisect_terms *terms,
const char *prefix)
{
if (bisect_next_check(terms, NULL)) {
bisect_print_status(terms);
@@ -734,6 +817,7 @@ static enum bisect_error bisect_start(struct bisect_terms *terms, int argc,
struct strbuf bisect_names = STRBUF_INIT;
struct object_id head_oid;
struct object_id oid;
enum reset_when_found_mode reset_when_found = RESET_WHEN_FOUND_NONE;
const char *head;
if (is_bare_repository(the_repository))
@@ -757,6 +841,13 @@ static enum bisect_error bisect_start(struct bisect_terms *terms, int argc,
no_checkout = 1;
} else if (!strcmp(arg, "--first-parent")) {
first_parent_only = 1;
} else if (!strcmp(arg, "--reset-when-found")) {
reset_when_found = RESET_WHEN_FOUND_TO_ORIGINAL;
} else if (skip_prefix(arg, "--reset-when-found=", &arg)) {
if (parse_reset_when_found(arg, &reset_when_found)) {
res = BISECT_FAILED;
goto finish;
}
} else if (!strcmp(arg, "--term-good") ||
!strcmp(arg, "--term-old")) {
i++;
@@ -794,6 +885,11 @@ static enum bisect_error bisect_start(struct bisect_terms *terms, int argc,
break;
}
}
if (reset_when_found != RESET_WHEN_FOUND_NONE && no_checkout) {
res = error(_("options '%s' and '%s' cannot be used together"),
"--reset-when-found", "--no-checkout");
goto finish;
}
pathspec_pos = i;
/*
@@ -873,6 +969,10 @@ static enum bisect_error bisect_start(struct bisect_terms *terms, int argc,
if (first_parent_only)
write_file(git_path_bisect_first_parent(), "\n");
if (reset_when_found != RESET_WHEN_FOUND_NONE)
write_file(git_path_bisect_reset_when_found(), "%s\n",
reset_when_found_mode_name(reset_when_found));
if (no_checkout) {
if (repo_get_oid(the_repository, start_head.buf, &oid) < 0) {
res = error(_("invalid ref: '%s'"), start_head.buf);
@@ -1104,7 +1204,7 @@ static enum bisect_error bisect_replay(struct bisect_terms *terms, const char *f
if (is_empty_or_missing_file(filename))
return error(_("cannot read file '%s' for replaying"), filename);
if (bisect_reset(NULL))
if (bisect_clean_state())
return BISECT_FAILED;
fp = fopen(filename, "r");
@@ -1252,13 +1352,36 @@ static int bisect_run(struct bisect_terms *terms, int argc, const char **argv)
{
int res = BISECT_OK;
struct strbuf command = STRBUF_INIT;
const char *reset_when_found_arg;
const char *new_state;
int temporary_stdout_fd, saved_stdout;
int is_first_run = 1;
enum reset_when_found_mode reset_when_found = RESET_WHEN_FOUND_NONE;
if (bisect_next_check(terms, NULL))
return BISECT_FAILED;
if (argc && !strcmp(argv[0], "--reset-when-found")) {
reset_when_found = RESET_WHEN_FOUND_TO_ORIGINAL;
} else if (argc && skip_prefix(argv[0], "--reset-when-found=",
&reset_when_found_arg)) {
if (parse_reset_when_found(reset_when_found_arg,
&reset_when_found))
return BISECT_FAILED;
}
if (reset_when_found != RESET_WHEN_FOUND_NONE &&
refs_ref_exists(get_main_ref_store(the_repository), "BISECT_HEAD"))
return error(_("options '%s' and '%s' cannot be used together"),
"--reset-when-found", "--no-checkout");
if (reset_when_found != RESET_WHEN_FOUND_NONE) {
write_file(git_path_bisect_reset_when_found(), "%s\n",
reset_when_found_mode_name(reset_when_found));
argc--;
argv++;
}
if (!argc) {
error(_("bisect run failed: no command provided."));
return BISECT_FAILED;
@@ -1338,7 +1461,6 @@ static int bisect_run(struct bisect_terms *terms, int argc, const char **argv)
res = BISECT_OK;
} else if (res == BISECT_INTERNAL_SUCCESS_1ST_BAD_FOUND) {
printf(_("bisect found first '%s' commit\n"), terms->term_bad);
res = BISECT_OK;
} else if (res) {
error(_("bisect run failed: 'git bisect %s'"
" exited with error code %d"), new_state, res);
@@ -1355,10 +1477,15 @@ static int bisect_run(struct bisect_terms *terms, int argc, const char **argv)
static int cmd_bisect__reset(int argc, const char **argv, const char *prefix UNUSED,
struct repository *repo UNUSED)
{
int res;
if (argc > 1)
return error(_("'%s' requires either no argument or a commit"),
"git bisect reset");
return bisect_reset(argc ? argv[0] : NULL);
res = bisect_reset(argc ? argv[0] : NULL, false);
if (res)
return res;
return bisect_clean_state();
}
static int cmd_bisect__terms(int argc, const char **argv, const char *prefix UNUSED,
@@ -1505,7 +1632,8 @@ int cmd_bisect(int argc,
!one_of(argv[0], terms.term_good, terms.term_bad, NULL))
usage_msg_optf(_("unknown command: '%s'"), git_bisect_usage,
options, argv[0]);
res = bisect_state(&terms, argc, argv);
else
res = bisect_state(&terms, argc, argv);
free_terms(&terms);
} else {
argc--;
@@ -1513,5 +1641,15 @@ int cmd_bisect(int argc,
res = fn(argc, argv, prefix, repo);
}
if (res == BISECT_INTERNAL_SUCCESS_1ST_BAD_FOUND) {
enum reset_when_found_mode mode;
if (read_reset_when_found(&mode))
res = BISECT_FAILED;
else if (mode != RESET_WHEN_FOUND_NONE &&
bisect_reset_when_found(mode))
res = BISECT_FAILED;
}
return is_bisect_success(res) ? 0 : -res;
}

View File

@@ -43,6 +43,42 @@ test_bisect_usage () {
test_cmp expect actual
}
test_bisect_state_file () {
local file &&
file=$(git rev-parse --git-path "$1") &&
test_path_is_file "$file"
}
test_bisect_state_missing () {
local file &&
file=$(git rev-parse --git-path "$1") &&
test_path_is_missing "$file"
}
bisect_start_and_finish () {
git bisect start "$1" $HASH4 $HASH2 &&
git bisect bad
}
bisect_run_reset_when_found () {
write_script test_script.sh <<-\EOF &&
! grep Another hello >/dev/null
EOF
git bisect start $HASH4 $HASH2 &&
git bisect run "$1" ./test_script.sh >my_bisect_log.txt &&
test_grep "$HASH3 is the first .bad. commit" my_bisect_log.txt &&
test_bisect_state_missing BISECT_RUN
}
test_reset_when_found_fails () {
local pattern="$1" &&
local state_file="$2" &&
shift 2 &&
test_must_fail "$@" 2>err &&
test_grep -- "$pattern" err &&
test_bisect_state_missing "$state_file"
}
test_expect_success 'bisect usage' "
test_bisect_usage 1 git bisect reset extra1 extra2 <<-\EOF &&
error: 'git bisect reset' requires either no argument or a commit
@@ -453,6 +489,91 @@ test_expect_success '"git bisect run" simple case' '
git bisect reset
'
test_expect_success '"git bisect start --reset-when-found" defaults to original' '
test_when_finished "git bisect reset && git checkout main" &&
git checkout main &&
bisect_start_and_finish --reset-when-found &&
actual=$(git rev-parse HEAD) &&
test "$HASH4" = "$actual" &&
actual=$(git branch --show-current) &&
test main = "$actual" &&
test_bisect_state_missing BISECT_START &&
bisect_start_and_finish --reset-when-found=original &&
actual=$(git rev-parse HEAD) &&
test "$HASH4" = "$actual" &&
actual=$(git branch --show-current) &&
test main = "$actual" &&
test_bisect_state_missing BISECT_START
'
test_expect_success '"git bisect start --reset-when-found=found" leaves first bad checked out' '
test_when_finished "git bisect reset && git checkout main" &&
bisect_start_and_finish --reset-when-found=found &&
actual=$(git rev-parse HEAD) &&
test "$HASH3" = "$actual" &&
test_bisect_state_missing BISECT_START
'
test_expect_success '"git bisect run --reset-when-found" defaults to original' '
test_when_finished "git bisect reset && git checkout main" &&
bisect_run_reset_when_found --reset-when-found &&
actual=$(git rev-parse HEAD) &&
test "$HASH4" = "$actual" &&
actual=$(git branch --show-current) &&
test main = "$actual" &&
test_bisect_state_missing BISECT_START
'
test_expect_success '"git bisect run --reset-when-found=found" leaves first bad checked out' '
test_when_finished "git bisect reset && git checkout main" &&
bisect_run_reset_when_found --reset-when-found=found &&
actual=$(git rev-parse HEAD) &&
test "$HASH3" = "$actual" &&
test_bisect_state_missing BISECT_START
'
test_expect_success '--reset-when-found rejects an unknown reset target' '
test_when_finished "git bisect reset && git checkout main" &&
test_reset_when_found_fails \
"invalid value for.*--reset-when-found.*unknown" BISECT_START \
git bisect start --reset-when-found=unknown $HASH4 $HASH2 &&
git bisect start $HASH4 $HASH2 &&
test_reset_when_found_fails \
"invalid value for.*--reset-when-found.*unknown" \
BISECT_RESET_WHEN_FOUND \
git bisect run --reset-when-found=unknown true
'
test_expect_success '--reset-when-found cannot be used with --no-checkout' '
test_when_finished "git bisect reset" &&
test_reset_when_found_fails \
"options .*--reset-when-found.* and .*--no-checkout.* cannot be used together" BISECT_START \
git bisect start --reset-when-found=original --no-checkout $HASH4 $HASH2 &&
git bisect start --no-checkout $HASH4 $HASH2 &&
test_reset_when_found_fails \
"options .*--reset-when-found.* and .*--no-checkout.* cannot be used together" BISECT_RESET_WHEN_FOUND \
git bisect run --reset-when-found=found true
'
test_expect_success 'without --reset-when-found the bisection state is kept' '
test_when_finished "git bisect reset" &&
git bisect start $HASH4 $HASH2 &&
git bisect bad &&
test_bisect_state_file BISECT_START
'
test_expect_success '--reset-when-found does not leak into a later bisection' '
test_when_finished "git bisect reset && git checkout main" &&
bisect_start_and_finish --reset-when-found &&
git bisect start $HASH4 $HASH2 &&
git bisect bad &&
test_bisect_state_file BISECT_START
'
# We want to automatically find the commit that
# added "Ciao" into hello.
test_expect_success '"git bisect run" with more complex "git bisect start"' '