bisect: add --reset-when-found to leave when done

When a bisection finishes, "git bisect" reports the first bad commit but leaves the session active until "git bisect reset" is run by hand. Add a "--reset-when-found[=<where>]" option, accepted by both "git bisect start" and "git bisect run", that resets as soon as the first bad commit is found. The "original" value returns to the commit checked out before "git bisect start", while "found" leaves the first bad commit checked out; omitting the value defaults to "original". Persist the selected target in a BISECT_RESET_WHEN_FOUND state file and perform the reset quietly. Let the internal first-bad result propagate to cmd_bisect(), which performs the reset using the existing bad bisect ref after the subcommand has returned. For "git bisect run", this means BISECT_RUN has been printed and closed before cleanup, which also works on systems that cannot unlink an open file. Reject this option together with "--no-checkout", since that mode must not check out either target. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Harald Nordgren committed Aug 2, 2026 at 21:24 UTC f70281521a06894e51b5acfa9abbbb884c05d980
4 files changed +280 -11
Documentation/git-bisect.adoc
+12 -2
@@ -10,7 +10,7 @@ SYNOPSIS
10 --------
11 [synopsis]
12 git bisect start [--term-(bad|new)=<term-new> --term-(good|old)=<term-old>]
13 - [--no-checkout] [--first-parent] [<bad> [<good>...]] [--] [<pathspec>...]
13 + [--no-checkout] [--first-parent] [--reset-when-found[=<where>]] [<bad> [<good>...]] [--] [<pathspec>...]
14 git bisect (bad|new|<term-new>) [<rev>]
15 git bisect (good|old|<term-old>) [<rev>...]
16 git bisect terms [--term-(good|old) | --term-(bad|new)]
@@ -20,7 +20,7 @@ git bisect reset [<commit>]
20 git bisect (visualize|view)
21 git bisect replay <logfile>
22 git bisect log
23 -git bisect run <cmd> [<arg>...]
23 +git bisect run [--reset-when-found[=<where>]] <cmd> [<arg>...]
24 git bisect help
25
26 DESCRIPTION
@@ -385,6 +385,16 @@ ignored.
385 This option is particularly useful in avoiding false positives when a merged
386 branch contained broken or non-buildable commits, but the merge itself was OK.
387
388 +`--reset-when-found[=<where>]`::
389 + Once the first bad commit is found, report it and clean up the
390 + bisection state. `<where>` may be `original` to return to the commit
391 + checked out before `git bisect start`, or `found` to leave the first
392 + bad commit checked out. If `<where>` is omitted, it defaults to
393 + `original`.
394 ++
395 +This option may be given to `git bisect start` or to `git bisect run`. It
396 +cannot be used for a bisection started with `--no-checkout`.
397 +
398 EXAMPLES
399 --------
400
bisect.c
+2
@@ -488,6 +488,7 @@ static GIT_PATH_FUNC(git_path_bisect_start, "BISECT_START")
488 static GIT_PATH_FUNC(git_path_bisect_log, "BISECT_LOG")
489 static GIT_PATH_FUNC(git_path_bisect_terms, "BISECT_TERMS")
490 static GIT_PATH_FUNC(git_path_bisect_first_parent, "BISECT_FIRST_PARENT")
491 +static GIT_PATH_FUNC(git_path_bisect_reset_when_found, "BISECT_RESET_WHEN_FOUND")
492
493 static void read_bisect_paths(struct strvec *array)
494 {
@@ -1211,6 +1212,7 @@ int bisect_clean_state(void)
1212 unlink_or_warn(git_path_bisect_run());
1213 unlink_or_warn(git_path_bisect_terms());
1214 unlink_or_warn(git_path_bisect_first_parent());
1215 + unlink_or_warn(git_path_bisect_reset_when_found());
1216 /*
1217 * Cleanup BISECT_START last to support the --no-checkout option
1218 * introduced in the commit 4796e823a.
builtin/bisect.c
+145 -9
@@ -24,11 +24,12 @@ static GIT_PATH_FUNC(git_path_bisect_start, "BISECT_START")
24 static GIT_PATH_FUNC(git_path_bisect_log, "BISECT_LOG")
25 static GIT_PATH_FUNC(git_path_bisect_names, "BISECT_NAMES")
26 static GIT_PATH_FUNC(git_path_bisect_first_parent, "BISECT_FIRST_PARENT")
27 +static GIT_PATH_FUNC(git_path_bisect_reset_when_found, "BISECT_RESET_WHEN_FOUND")
28 static GIT_PATH_FUNC(git_path_bisect_run, "BISECT_RUN")
29
30 #define BUILTIN_GIT_BISECT_START_USAGE \
31 N_("git bisect start [--term-(bad|new)=<term-new> --term-(good|old)=<term-old>]\n" \
31 - " [--no-checkout] [--first-parent] [<bad> [<good>...]] [--] [<pathspec>...]")
32 + " [--no-checkout] [--first-parent] [--reset-when-found[=<where>]] [<bad> [<good>...]] [--] [<pathspec>...]")
33 #define BUILTIN_GIT_BISECT_BAD_USAGE \
34 N_("git bisect (bad|new|<term-new>) [<rev>]")
35 #define BUILTIN_GIT_BISECT_GOOD_USAGE \
@@ -48,7 +49,7 @@ static GIT_PATH_FUNC(git_path_bisect_run, "BISECT_RUN")
49 #define BUILTIN_GIT_BISECT_LOG_USAGE \
50 "git bisect log"
51 #define BUILTIN_GIT_BISECT_RUN_USAGE \
51 - N_("git bisect run <cmd> [<arg>...]")
52 + N_("git bisect run [--reset-when-found[=<where>]] <cmd> [<arg>...]")
53 #define BUILTIN_GIT_BISECT_HELP_USAGE \
54 "git bisect help"
55
@@ -68,6 +69,12 @@ static const char * const git_bisect_usage[] = {
69 NULL
70 };
71
72 +enum reset_when_found_mode {
73 + RESET_WHEN_FOUND_NONE,
74 + RESET_WHEN_FOUND_TO_ORIGINAL,
75 + RESET_WHEN_FOUND_TO_FOUND,
76 +};
77 +
78 struct add_bisect_ref_data {
79 struct rev_info *revs;
80 unsigned int object_flags;
@@ -269,7 +276,79 @@ static int bisect_reset(const char *commit, bool quiet)
276 }
277
278 strbuf_release(&branch);
272 - return bisect_clean_state();
279 + return 0;
280 +}
281 +
282 +static int parse_reset_when_found(const char *value,
283 + enum reset_when_found_mode *mode)
284 +{
285 + if (!strcmp(value, "original"))
286 + *mode = RESET_WHEN_FOUND_TO_ORIGINAL;
287 + else if (!strcmp(value, "found"))
288 + *mode = RESET_WHEN_FOUND_TO_FOUND;
289 + else
290 + return error(_("invalid value for '--reset-when-found': '%s'"),
291 + value);
292 +
293 + return 0;
294 +}
295 +
296 +static const char *reset_when_found_mode_name(enum reset_when_found_mode mode)
297 +{
298 + switch (mode) {
299 + case RESET_WHEN_FOUND_TO_ORIGINAL:
300 + return "original";
301 + case RESET_WHEN_FOUND_TO_FOUND:
302 + return "found";
303 + case RESET_WHEN_FOUND_NONE:
304 + BUG("no name for unset reset-when-found mode");
305 + }
306 + BUG("unknown reset-when-found mode %d", mode);
307 +}
308 +
309 +static int read_reset_when_found(enum reset_when_found_mode *mode)
310 +{
311 + struct strbuf value = STRBUF_INIT;
312 + int res = 0;
313 +
314 + *mode = RESET_WHEN_FOUND_NONE;
315 + if (is_empty_or_missing_file(git_path_bisect_reset_when_found()))
316 + return 0;
317 +
318 + if (strbuf_read_file(&value, git_path_bisect_reset_when_found(), 0) < 0) {
319 + res = error_errno(_("could not read '%s'"),
320 + git_path_bisect_reset_when_found());
321 + goto out;
322 + }
323 + strbuf_trim(&value);
324 + if (parse_reset_when_found(value.buf, mode))
325 + res = -1;
326 +
327 +out:
328 + strbuf_release(&value);
329 + return res;
330 +}
331 +
332 +static int bisect_reset_when_found(enum reset_when_found_mode mode)
333 +{
334 + struct bisect_terms terms = { 0 };
335 + char *commit = NULL;
336 + int res;
337 +
338 + if (mode == RESET_WHEN_FOUND_TO_FOUND) {
339 + read_bisect_terms(&terms.term_bad, &terms.term_good);
340 + commit = xstrfmt("refs/bisect/%s", terms.term_bad);
341 + } else if (mode == RESET_WHEN_FOUND_NONE) {
342 + BUG("automatic reset requested without a reset mode");
343 + }
344 +
345 + res = bisect_reset(commit, true);
346 + if (!res)
347 + res = bisect_clean_state();
348 +
349 + free(commit);
350 + free_terms(&terms);
351 + return res;
352 }
353
354 static void log_commit(FILE *fp,
@@ -677,7 +756,8 @@ static int bisect_successful(struct bisect_terms *terms)
756 return res;
757 }
758
680 -static enum bisect_error bisect_next(struct bisect_terms *terms, const char *prefix)
759 +static enum bisect_error bisect_next(struct bisect_terms *terms,
760 + const char *prefix)
761 {
762 enum bisect_error res;
763
@@ -700,7 +780,8 @@ static enum bisect_error bisect_next(struct bisect_terms *terms, const char *pre
780 return res;
781 }
782
703 -static enum bisect_error bisect_auto_next(struct bisect_terms *terms, const char *prefix)
783 +static enum bisect_error bisect_auto_next(struct bisect_terms *terms,
784 + const char *prefix)
785 {
786 if (bisect_next_check(terms, NULL)) {
787 bisect_print_status(terms);
@@ -724,6 +805,7 @@ static enum bisect_error bisect_start(struct bisect_terms *terms, int argc,
805 struct strbuf bisect_names = STRBUF_INIT;
806 struct object_id head_oid;
807 struct object_id oid;
808 + enum reset_when_found_mode reset_when_found = RESET_WHEN_FOUND_NONE;
809 const char *head;
810
811 if (is_bare_repository(the_repository))
@@ -747,6 +829,13 @@ static enum bisect_error bisect_start(struct bisect_terms *terms, int argc,
829 no_checkout = 1;
830 } else if (!strcmp(arg, "--first-parent")) {
831 first_parent_only = 1;
832 + } else if (!strcmp(arg, "--reset-when-found")) {
833 + reset_when_found = RESET_WHEN_FOUND_TO_ORIGINAL;
834 + } else if (skip_prefix(arg, "--reset-when-found=", &arg)) {
835 + if (parse_reset_when_found(arg, &reset_when_found)) {
836 + res = BISECT_FAILED;
837 + goto finish;
838 + }
839 } else if (!strcmp(arg, "--term-good") ||
840 !strcmp(arg, "--term-old")) {
841 i++;
@@ -784,6 +873,11 @@ static enum bisect_error bisect_start(struct bisect_terms *terms, int argc,
873 break;
874 }
875 }
876 + if (reset_when_found != RESET_WHEN_FOUND_NONE && no_checkout) {
877 + res = error(_("options '%s' and '%s' cannot be used together"),
878 + "--reset-when-found", "--no-checkout");
879 + goto finish;
880 + }
881 pathspec_pos = i;
882
883 /*
@@ -861,6 +955,10 @@ static enum bisect_error bisect_start(struct bisect_terms *terms, int argc,
955 if (first_parent_only)
956 write_file(git_path_bisect_first_parent(), "\n");
957
958 + if (reset_when_found != RESET_WHEN_FOUND_NONE)
959 + write_file(git_path_bisect_reset_when_found(), "%s\n",
960 + reset_when_found_mode_name(reset_when_found));
961 +
962 if (no_checkout) {
963 if (repo_get_oid(the_repository, start_head.buf, &oid) < 0) {
964 res = error(_("invalid ref: '%s'"), start_head.buf);
@@ -1091,7 +1189,7 @@ static enum bisect_error bisect_replay(struct bisect_terms *terms, const char *f
1189 if (is_empty_or_missing_file(filename))
1190 return error(_("cannot read file '%s' for replaying"), filename);
1191
1094 - if (bisect_reset(NULL, false))
1192 + if (bisect_clean_state())
1193 return BISECT_FAILED;
1194
1195 fp = fopen(filename, "r");
@@ -1239,13 +1337,36 @@ static int bisect_run(struct bisect_terms *terms, int argc, const char **argv)
1337 {
1338 int res = BISECT_OK;
1339 struct strbuf command = STRBUF_INIT;
1340 + const char *reset_when_found_arg;
1341 const char *new_state;
1342 int temporary_stdout_fd, saved_stdout;
1343 int is_first_run = 1;
1344 + enum reset_when_found_mode reset_when_found = RESET_WHEN_FOUND_NONE;
1345
1346 if (bisect_next_check(terms, NULL))
1347 return BISECT_FAILED;
1348
1349 + if (argc && !strcmp(argv[0], "--reset-when-found")) {
1350 + reset_when_found = RESET_WHEN_FOUND_TO_ORIGINAL;
1351 + } else if (argc && skip_prefix(argv[0], "--reset-when-found=",
1352 + &reset_when_found_arg)) {
1353 + if (parse_reset_when_found(reset_when_found_arg,
1354 + &reset_when_found))
1355 + return BISECT_FAILED;
1356 + }
1357 +
1358 + if (reset_when_found != RESET_WHEN_FOUND_NONE &&
1359 + refs_ref_exists(get_main_ref_store(the_repository), "BISECT_HEAD"))
1360 + return error(_("options '%s' and '%s' cannot be used together"),
1361 + "--reset-when-found", "--no-checkout");
1362 +
1363 + if (reset_when_found != RESET_WHEN_FOUND_NONE) {
1364 + write_file(git_path_bisect_reset_when_found(), "%s\n",
1365 + reset_when_found_mode_name(reset_when_found));
1366 + argc--;
1367 + argv++;
1368 + }
1369 +
1370 if (!argc) {
1371 error(_("bisect run failed: no command provided."));
1372 return BISECT_FAILED;
@@ -1320,7 +1441,6 @@ static int bisect_run(struct bisect_terms *terms, int argc, const char **argv)
1441 res = BISECT_OK;
1442 } else if (res == BISECT_INTERNAL_SUCCESS_1ST_BAD_FOUND) {
1443 printf(_("bisect found first '%s' commit\n"), terms->term_bad);
1323 - res = BISECT_OK;
1444 } else if (res) {
1445 error(_("bisect run failed: 'git bisect %s'"
1446 " exited with error code %d"), new_state, res);
@@ -1337,10 +1457,15 @@ static int bisect_run(struct bisect_terms *terms, int argc, const char **argv)
1457 static int cmd_bisect__reset(int argc, const char **argv, const char *prefix UNUSED,
1458 struct repository *repo UNUSED)
1459 {
1460 + int res;
1461 +
1462 if (argc > 1)
1463 return error(_("'%s' requires either no argument or a commit"),
1464 "git bisect reset");
1343 - return bisect_reset(argc ? argv[0] : NULL, false);
1465 + res = bisect_reset(argc ? argv[0] : NULL, false);
1466 + if (res)
1467 + return res;
1468 + return bisect_clean_state();
1469 }
1470
1471 static int cmd_bisect__terms(int argc, const char **argv, const char *prefix UNUSED,
@@ -1482,7 +1607,8 @@ int cmd_bisect(int argc,
1607 !one_of(argv[0], terms.term_good, terms.term_bad, NULL))
1608 usage_msg_optf(_("unknown command: '%s'"), git_bisect_usage,
1609 options, argv[0]);
1485 - res = bisect_state(&terms, argc, argv);
1610 + else
1611 + res = bisect_state(&terms, argc, argv);
1612 free_terms(&terms);
1613 } else {
1614 argc--;
@@ -1490,5 +1616,15 @@ int cmd_bisect(int argc,
1616 res = fn(argc, argv, prefix, repo);
1617 }
1618
1619 + if (res == BISECT_INTERNAL_SUCCESS_1ST_BAD_FOUND) {
1620 + enum reset_when_found_mode mode;
1621 +
1622 + if (read_reset_when_found(&mode))
1623 + res = BISECT_FAILED;
1624 + else if (mode != RESET_WHEN_FOUND_NONE &&
1625 + bisect_reset_when_found(mode))
1626 + res = BISECT_FAILED;
1627 + }
1628 +
1629 return is_bisect_success(res) ? 0 : -res;
1630 }
t/t6030-bisect-porcelain.sh
+121
@@ -43,6 +43,42 @@ test_bisect_usage () {
43 test_cmp expect actual
44 }
45
46 +test_bisect_state_file () {
47 + local file &&
48 + file=$(git rev-parse --git-path "$1") &&
49 + test_path_is_file "$file"
50 +}
51 +
52 +test_bisect_state_missing () {
53 + local file &&
54 + file=$(git rev-parse --git-path "$1") &&
55 + test_path_is_missing "$file"
56 +}
57 +
58 +bisect_start_and_finish () {
59 + git bisect start "$1" $HASH4 $HASH2 &&
60 + git bisect bad
61 +}
62 +
63 +bisect_run_reset_when_found () {
64 + write_script test_script.sh <<-\EOF &&
65 + ! grep Another hello >/dev/null
66 + EOF
67 + git bisect start $HASH4 $HASH2 &&
68 + git bisect run "$1" ./test_script.sh >my_bisect_log.txt &&
69 + test_grep "$HASH3 is the first .bad. commit" my_bisect_log.txt &&
70 + test_bisect_state_missing BISECT_RUN
71 +}
72 +
73 +test_reset_when_found_fails () {
74 + local pattern="$1" &&
75 + local state_file="$2" &&
76 + shift 2 &&
77 + test_must_fail "$@" 2>err &&
78 + test_grep -- "$pattern" err &&
79 + test_bisect_state_missing "$state_file"
80 +}
81 +
82 test_expect_success 'bisect usage' "
83 test_bisect_usage 1 git bisect reset extra1 extra2 <<-\EOF &&
84 error: 'git bisect reset' requires either no argument or a commit
@@ -453,6 +489,91 @@ test_expect_success '"git bisect run" simple case' '
489 git bisect reset
490 '
491
492 +test_expect_success '"git bisect start --reset-when-found" defaults to original' '
493 + test_when_finished "git bisect reset && git checkout main" &&
494 + git checkout main &&
495 + bisect_start_and_finish --reset-when-found &&
496 + actual=$(git rev-parse HEAD) &&
497 + test "$HASH4" = "$actual" &&
498 + actual=$(git branch --show-current) &&
499 + test main = "$actual" &&
500 + test_bisect_state_missing BISECT_START &&
501 +
502 + bisect_start_and_finish --reset-when-found=original &&
503 + actual=$(git rev-parse HEAD) &&
504 + test "$HASH4" = "$actual" &&
505 + actual=$(git branch --show-current) &&
506 + test main = "$actual" &&
507 + test_bisect_state_missing BISECT_START
508 +'
509 +
510 +test_expect_success '"git bisect start --reset-when-found=found" leaves first bad checked out' '
511 + test_when_finished "git bisect reset && git checkout main" &&
512 + bisect_start_and_finish --reset-when-found=found &&
513 + actual=$(git rev-parse HEAD) &&
514 + test "$HASH3" = "$actual" &&
515 + test_bisect_state_missing BISECT_START
516 +'
517 +
518 +test_expect_success '"git bisect run --reset-when-found" defaults to original' '
519 + test_when_finished "git bisect reset && git checkout main" &&
520 + bisect_run_reset_when_found --reset-when-found &&
521 + actual=$(git rev-parse HEAD) &&
522 + test "$HASH4" = "$actual" &&
523 + actual=$(git branch --show-current) &&
524 + test main = "$actual" &&
525 + test_bisect_state_missing BISECT_START
526 +'
527 +
528 +test_expect_success '"git bisect run --reset-when-found=found" leaves first bad checked out' '
529 + test_when_finished "git bisect reset && git checkout main" &&
530 + bisect_run_reset_when_found --reset-when-found=found &&
531 + actual=$(git rev-parse HEAD) &&
532 + test "$HASH3" = "$actual" &&
533 + test_bisect_state_missing BISECT_START
534 +'
535 +
536 +test_expect_success '--reset-when-found rejects an unknown reset target' '
537 + test_when_finished "git bisect reset && git checkout main" &&
538 + test_reset_when_found_fails \
539 + "invalid value for.*--reset-when-found.*unknown" BISECT_START \
540 + git bisect start --reset-when-found=unknown $HASH4 $HASH2 &&
541 +
542 + git bisect start $HASH4 $HASH2 &&
543 + test_reset_when_found_fails \
544 + "invalid value for.*--reset-when-found.*unknown" \
545 + BISECT_RESET_WHEN_FOUND \
546 + git bisect run --reset-when-found=unknown true
547 +'
548 +
549 +test_expect_success '--reset-when-found cannot be used with --no-checkout' '
550 + test_when_finished "git bisect reset" &&
551 + test_reset_when_found_fails \
552 + "options .*--reset-when-found.* and .*--no-checkout.* cannot be used together" BISECT_START \
553 + git bisect start --reset-when-found=original --no-checkout $HASH4 $HASH2 &&
554 +
555 + git bisect start --no-checkout $HASH4 $HASH2 &&
556 + test_reset_when_found_fails \
557 + "options .*--reset-when-found.* and .*--no-checkout.* cannot be used together" BISECT_RESET_WHEN_FOUND \
558 + git bisect run --reset-when-found=found true
559 +'
560 +
561 +test_expect_success 'without --reset-when-found the bisection state is kept' '
562 + test_when_finished "git bisect reset" &&
563 + git bisect start $HASH4 $HASH2 &&
564 + git bisect bad &&
565 + test_bisect_state_file BISECT_START
566 +'
567 +
568 +test_expect_success '--reset-when-found does not leak into a later bisection' '
569 + test_when_finished "git bisect reset && git checkout main" &&
570 + bisect_start_and_finish --reset-when-found &&
571 +
572 + git bisect start $HASH4 $HASH2 &&
573 + git bisect bad &&
574 + test_bisect_state_file BISECT_START
575 +'
576 +
577 # We want to automatically find the commit that
578 # added "Ciao" into hello.
579 test_expect_success '"git bisect run" with more complex "git bisect start"' '