From 6aad1a1cd34a65939be6f64a0d8950e0bc23870a Mon Sep 17 00:00:00 2001 From: Wyatt Holden Date: Mon, 19 Oct 2026 11:07:55 -0700 Subject: [PATCH 1/3] checkout: make orphaned commit checks configurable When leaving a detached HEAD, checkout enumerates refs and walks history to warn about commits left behind. This work can dominate checkout time even after the working tree has been updated. In an automated checkout of an internal monorepo, unpack_trees ended about 12 seconds into a roughly 204-second run. Stack dumps captured the main thread in orphaned_commit_warning(), enumerating loose refs and opening ref files. Using --quiet reduced the observed checkout time to about 14 seconds, but also suppressed unrelated output. These observations motivate an opt-out; they do not establish how much of the delay came from enumeration versus history traversal. Add checkout.orphanedCommitWarning, enabled by default, so checkout and switch can skip the check while retaining normal checkout output. Automated callers can opt out when they do not need a warning about leaving detached commits behind. Follow the configuration precedent of 06b324c1d71 (status: add status.aheadbehind setting, 2019-06-18). Unlike status, introduce no dedicated command-line option; callers can use git -c for a one-off override. Add a Trace2 region around the check to make its cost visible in future investigations and to verify that disabling the setting skips the routine rather than merely hiding its warning. Document the setting and test both checkout and switch. Signed-off-by: Wyatt Holden --- Documentation/config/checkout.adoc | 11 +++++++++++ builtin/checkout.c | 18 ++++++++++++++++-- t/t2020-checkout-detach.sh | 29 +++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/Documentation/config/checkout.adoc b/Documentation/config/checkout.adoc index e35d21296978fe..cacd10449a20b0 100644 --- a/Documentation/config/checkout.adoc +++ b/Documentation/config/checkout.adoc @@ -22,6 +22,17 @@ commands or functionality in the future. option in `git checkout` and `git switch`. See linkgit:git-switch[1] and linkgit:git-checkout[1]. +`checkout.orphanedCommitWarning`:: + When leaving a detached `HEAD`, linkgit:git-checkout[1] and + linkgit:git-switch[1] check whether any commits are being left + behind that are not reachable from any ref or the new `HEAD`, + and warn if so. This check can be expensive in large repositories. + Set this variable to `false` to skip the check and its warning + without suppressing other checkout output. Defaults to `true`. ++ +When disabled, commits left behind can still be found in the `HEAD` +reflog until its entries expire. See linkgit:git-reflog[1]. + `checkout.workers`:: The number of parallel workers to use when updating the working tree. The default is one, i.e. sequential execution. If set to a value less diff --git a/builtin/checkout.c b/builtin/checkout.c index c0f0d2c700c447..7cc9e97612c5b7 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -51,6 +51,7 @@ struct checkout_opts { int patch_interhunk_context; int auto_advance; int quiet; + int orphaned_commit_warning; int merge; int force; int force_detach; @@ -99,6 +100,7 @@ struct checkout_opts { .patch_context = -1, \ .patch_interhunk_context = -1, \ .auto_advance = 1, \ + .orphaned_commit_warning = 1, \ } #define MERGE_WORKING_TREE_UNPACK_FAILED (-2) @@ -1132,6 +1134,7 @@ static void orphaned_commit_warning(struct commit *old_commit, struct commit *ne struct rev_info revs; struct object *object = &old_commit->object; + trace2_region_enter("checkout", "orphaned_commit_warning", the_repository); repo_init_revisions(the_repository, &revs, NULL); setup_revisions(0, NULL, &revs, NULL); @@ -1155,6 +1158,7 @@ static void orphaned_commit_warning(struct commit *old_commit, struct commit *ne /* Clean up objects used, as they will be reused. */ repo_clear_commit_marks(the_repository, ALL_REV_FLAGS); release_revisions(&revs); + trace2_region_leave("checkout", "orphaned_commit_warning", the_repository); } static int switch_branches(const struct checkout_opts *opts, @@ -1253,8 +1257,14 @@ static int switch_branches(const struct checkout_opts *opts, } } - if (!opts->quiet && !old_branch_info.path && old_branch_info.commit && new_branch_info->commit != old_branch_info.commit) - orphaned_commit_warning(old_branch_info.commit, new_branch_info->commit); + if (!opts->quiet && !old_branch_info.path && old_branch_info.commit && + new_branch_info->commit != old_branch_info.commit) { + if (opts->orphaned_commit_warning) + orphaned_commit_warning(old_branch_info.commit, new_branch_info->commit); + else + describe_detached_head(_("Previous HEAD position was"), + old_branch_info.commit); + } if (autostash_res == STASH_APPLY_CONFLICT && !opts->quiet) fputc('\n', stderr); @@ -1295,6 +1305,10 @@ static int git_checkout_config(const char *var, const char *value, opts->dwim_new_local_branch = git_config_bool(var, value); return 0; } + if (!strcmp(var, "checkout.orphanedcommitwarning")) { + opts->orphaned_commit_warning = git_config_bool(var, value); + return 0; + } if (starts_with(var, "submodule.")) return git_default_submodule_config(var, value, NULL); diff --git a/t/t2020-checkout-detach.sh b/t/t2020-checkout-detach.sh index 28bbbe6c05547a..d21dbd5b72dbfa 100755 --- a/t/t2020-checkout-detach.sh +++ b/t/t2020-checkout-detach.sh @@ -164,6 +164,35 @@ test_expect_success 'checkout does not warn leaving reachable commit' ' check_no_orphan_warning stderr ' +for command in checkout switch +do + test_expect_success "$command honors checkout.orphanedCommitWarning" ' + git checkout --detach "$orphan2" && + GIT_TRACE2_EVENT="$(pwd)/trace-enabled-$command" \ + git -c checkout.orphanedCommitWarning=true \ + $command main 2>stderr && + check_orphan_warning stderr "2 commits" && + test_grep "\"label\":\"orphaned_commit_warning\"" \ + trace-enabled-$command && + + git checkout --detach "$orphan2" && + GIT_TRACE2_EVENT="$(pwd)/trace-disabled-$command" \ + git -c checkout.orphanedCommitWarning=false \ + $command main 2>stderr && + check_no_orphan_warning stderr && + test_grep "Switched to branch" stderr && + test_grep ! "\"label\":\"orphaned_commit_warning\"" \ + trace-disabled-$command && + check_not_detached + ' + + test_expect_success "$command rejects invalid checkout.orphanedCommitWarning" ' + test_must_fail git -c checkout.orphanedCommitWarning=invalid \ + $command main 2>stderr && + test_grep "bad boolean config value.*checkout.orphanedcommitwarning" stderr + ' +done + cat >expect <<'EOF' Your branch is behind 'main' by 1 commit, and can be fast-forwarded. (use "git pull" to update your local branch) From a9b81eb87684fa9bfbc83e845eb67ded8d7cb6d9 Mon Sep 17 00:00:00 2001 From: Wyatt Holden Date: Mon, 19 Oct 2026 11:11:23 -0700 Subject: [PATCH 2/3] checkout: advise when orphaned commit checks are slow The orphaned-commit check can take much longer than updating the working tree. Users who encounter this cost need a way to discover the checkout.orphanedCommitWarning setting. Measure the complete check and suggest disabling it when it takes more than two seconds. Explain that opting out also removes the warning about commits left behind, and allow the advice itself to be disabled with advice.checkoutOrphanedCommitWarning. Follow the slow-operation advice introduced by: 0a53561a622 (status: warn when a/b calculation takes too long, 2019-06-18) Unlike that status advice, recommend a configuration setting rather than a dedicated command-line option. Keep a test hook to exercise advice and suppression without relying on wall-clock delays. Force the advice condition without replacing the measured duration, and check the formatted duration rather than a fabricated fixed value. Signed-off-by: Wyatt Holden --- Documentation/config/advice.adoc | 6 +++++ advice.c | 1 + advice.h | 1 + builtin/checkout.c | 13 +++++++++++ t/README | 5 ++++ t/t2020-checkout-detach.sh | 40 ++++++++++++++++++++++++++++++++ 6 files changed, 66 insertions(+) diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc index 3d91c90eda72f9..eecf72d8d13080 100644 --- a/Documentation/config/advice.adoc +++ b/Documentation/config/advice.adoc @@ -38,6 +38,12 @@ all advice messages. configuration variable for how to set a given remote to be used by default in some situations where this advice would be printed. + checkoutOrphanedCommitWarning:: + Shown when linkgit:git-checkout[1] or linkgit:git-switch[1] + takes more than 2 seconds to check for commits left behind + when leaving a detached `HEAD`. Suggests setting + `checkout.orphanedCommitWarning` to `false` to skip the check. + Disabling this advice does not disable the check itself. commitBeforeMerge:: Shown when linkgit:git-merge[1] refuses to merge to avoid overwriting local changes. diff --git a/advice.c b/advice.c index 401d0473917cd9..8142d73b5e9c70 100644 --- a/advice.c +++ b/advice.c @@ -50,6 +50,7 @@ static struct { [ADVICE_AMBIGUOUS_FETCH_REFSPEC] = { "ambiguousFetchRefspec" }, [ADVICE_AM_WORK_DIR] = { "amWorkDir" }, [ADVICE_CHECKOUT_AMBIGUOUS_REMOTE_BRANCH_NAME] = { "checkoutAmbiguousRemoteBranchName" }, + [ADVICE_CHECKOUT_ORPHANED_COMMIT_WARNING] = { "checkoutOrphanedCommitWarning" }, [ADVICE_COMMIT_BEFORE_MERGE] = { "commitBeforeMerge" }, [ADVICE_DEFAULT_BRANCH_NAME] = { "defaultBranchName" }, [ADVICE_DETACHED_HEAD] = { "detachedHead" }, diff --git a/advice.h b/advice.h index 3f0b4f0485c7fe..7dd1fe109c247b 100644 --- a/advice.h +++ b/advice.h @@ -17,6 +17,7 @@ enum advice_type { ADVICE_AMBIGUOUS_FETCH_REFSPEC, ADVICE_AM_WORK_DIR, ADVICE_CHECKOUT_AMBIGUOUS_REMOTE_BRANCH_NAME, + ADVICE_CHECKOUT_ORPHANED_COMMIT_WARNING, ADVICE_COMMIT_BEFORE_MERGE, ADVICE_DEFAULT_BRANCH_NAME, /* To be retired sometime after Git 3.0 */ ADVICE_DETACHED_HEAD, diff --git a/builtin/checkout.c b/builtin/checkout.c index 7cc9e97612c5b7..1e94280d35264f 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -35,6 +35,7 @@ #include "strvec.h" #include "submodule.h" #include "symlinks.h" +#include "trace.h" #include "trace2.h" #include "tree.h" #include "tree-walk.h" @@ -1133,6 +1134,8 @@ static void orphaned_commit_warning(struct commit *old_commit, struct commit *ne { struct rev_info revs; struct object *object = &old_commit->object; + uint64_t t_begin = getnanotime(); + uint64_t t_delta_in_ms; trace2_region_enter("checkout", "orphaned_commit_warning", the_repository); repo_init_revisions(the_repository, &revs, NULL); @@ -1159,6 +1162,16 @@ static void orphaned_commit_warning(struct commit *old_commit, struct commit *ne repo_clear_commit_marks(the_repository, ALL_REV_FLAGS); release_revisions(&revs); trace2_region_leave("checkout", "orphaned_commit_warning", the_repository); + + t_delta_in_ms = (getnanotime() - t_begin) / 1000000; + if (t_delta_in_ms > 2000 || + git_env_bool("GIT_TEST_CHECKOUT_DELAY_WARNING", 0)) + advise_if_enabled(ADVICE_CHECKOUT_ORPHANED_COMMIT_WARNING, + _("Checking for orphaned commits took %.2f seconds.\n" + "You can use 'git config set checkout.orphanedCommitWarning false'\n" + "to skip this check, but you will no longer be warned when leaving\n" + "commits behind that are not connected to any of your branches."), + t_delta_in_ms / 1000.0); } static int switch_branches(const struct checkout_opts *opts, diff --git a/t/README b/t/README index fd7e8504b511e2..577f02732e97e5 100644 --- a/t/README +++ b/t/README @@ -371,6 +371,11 @@ test suite. Accept any boolean values that are accepted by git-config. GIT_TEST_PROTOCOL_VERSION=, when set, makes 'protocol.version' default to n. +GIT_TEST_CHECKOUT_DELAY_WARNING=, when true, forces the +slow orphaned-commit check advice to be considered regardless of the +elapsed time. It does not change the measured duration or override +advice settings. + GIT_TEST_FULL_IN_PACK_ARRAY= exercises the uncommon pack-objects code path where there are more than 1024 packs even if the actual number of packs in repository is below this limit. Accept diff --git a/t/t2020-checkout-detach.sh b/t/t2020-checkout-detach.sh index d21dbd5b72dbfa..7dfaaab1b8b603 100755 --- a/t/t2020-checkout-detach.sh +++ b/t/t2020-checkout-detach.sh @@ -186,6 +186,46 @@ do check_not_detached ' + test_expect_success "$command advises when the orphan check is slow" ' + git checkout --detach "$orphan2" && + GIT_TEST_CHECKOUT_DELAY_WARNING=true \ + git $command main 2>stderr && + check_orphan_warning stderr "2 commits" && + test_grep -E "^hint: Checking for orphaned commits took [0-9]+[.][0-9]{2} seconds[.]$" stderr && + test_grep "git config set checkout.orphanedCommitWarning false" stderr && + test_grep "git config set advice.checkoutOrphanedCommitWarning false" stderr + ' + + test_expect_success "$command can suppress slow orphan check advice" ' + git checkout --detach "$orphan2" && + GIT_TEST_CHECKOUT_DELAY_WARNING=true \ + git -c advice.checkoutOrphanedCommitWarning=false \ + $command main 2>stderr && + check_orphan_warning stderr "2 commits" && + test_grep ! "^hint:" stderr && + + git checkout --detach "$orphan2" && + GIT_TEST_CHECKOUT_DELAY_WARNING=true GIT_ADVICE=0 \ + git $command main 2>stderr && + check_orphan_warning stderr "2 commits" && + test_grep ! "^hint:" stderr + ' + + test_expect_success "$command does not advise when the orphan check is skipped" ' + git checkout --detach "$orphan2" && + GIT_TEST_CHECKOUT_DELAY_WARNING=true \ + git -c checkout.orphanedCommitWarning=false \ + $command main 2>stderr && + check_no_orphan_warning stderr && + test_grep ! "^hint:" stderr && + + git checkout --detach "$orphan2" && + GIT_TEST_CHECKOUT_DELAY_WARNING=true \ + git $command --quiet main 2>stderr && + test_must_be_empty stderr && + check_not_detached + ' + test_expect_success "$command rejects invalid checkout.orphanedCommitWarning" ' test_must_fail git -c checkout.orphanedCommitWarning=invalid \ $command main 2>stderr && From f24d4019e889b12cd6237217c3cf31f47aa318d5 Mon Sep 17 00:00:00 2001 From: Wyatt Holden Date: Mon, 19 Oct 2026 11:11:29 -0700 Subject: [PATCH 3/3] scalar: disable orphaned commit checks by default Checking for commits left behind when leaving a detached HEAD can be expensive in large repositories. Add checkout.orphanedCommitWarning=false to Scalar's recommended settings to avoid this foreground work while preserving normal checkout output. Use the existing set-if-missing behavior so explicit user choices are preserved during clone, register, and reconfigure. Document the loss of the immediate orphan warning and the availability of reflog recovery. Existing Scalar tests cover configuration preservation, so no additional inventory of recommended settings is needed. Signed-off-by: Wyatt Holden --- Documentation/scalar.adoc | 7 +++++++ scalar.c | 1 + 2 files changed, 8 insertions(+) diff --git a/Documentation/scalar.adoc b/Documentation/scalar.adoc index 5252fb134a47ab..d2b8c9a6ca66ea 100644 --- a/Documentation/scalar.adoc +++ b/Documentation/scalar.adoc @@ -214,6 +214,13 @@ am.keepCR=true:: and non-Windows platforms and keeping carriage return (`\r`) characters in certain workflows. +checkout.orphanedCommitWarning=false:: + This disables the reachability check that warns about commits left + behind when `git checkout` or `git switch` leaves a detached `HEAD`. + The check can be expensive in large repositories. Other checkout + output is unchanged, and commits left behind can still be found + in the `HEAD` reflog until its entries expire. + commitGraph.changedPaths=true:: This setting helps the background maintenance steps that compute the serialized commit-graph to also store changed-path Bloom filters. This diff --git a/scalar.c b/scalar.c index a80d8ee3ff54a7..7f851f56109fc3 100644 --- a/scalar.c +++ b/scalar.c @@ -138,6 +138,7 @@ static int set_recommended_config(int reconfigure) */ struct scalar_config config[] = { { "am.keepCR", "true" }, + { "checkout.orphanedCommitWarning", "false" }, { "commitGraph.changedPaths", "true" }, { "commitGraph.generationVersion", "1" }, { "core.autoCRLF", "false" },