From ebb4d2ffa34e8c37796cc1be14216af5b91869aa Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Mon, 29 Jun 2026 09:08:42 -0700 Subject: [PATCH 01/29] history: streamline message preparation and plug file stream leak An early part of fill_commit_message() function uses write_file_buf() to write out what was prepared in a strbuf, which is primarily meant for use by callers that have their own message prepared fully and called as the last thing to flush it to the destination file. However, the function then opens a file stream in append mode to further write into it. It may have been understandable if this was a later addition, but it seems it came from a single commit, d205234c (builtin/history: implement "reword" subcommand, 2026-01-13), which is somewhat puzzling, but anyway... Just open the file stream upfront for writing, write the message the function has in the strbuf, and then keep writing whatever it wants to write to the same open file stream. And do not forget to close the stream. We are about to pass the resulting file to an external editor, and on some systems, notably Windows, you are not supposed to keep a file open while expecting another program to access it. Diagnosed-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- builtin/history.c | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/builtin/history.c b/builtin/history.c index 8dcb9a604665f8..365e81379b246e 100644 --- a/builtin/history.c +++ b/builtin/history.c @@ -41,11 +41,6 @@ static int fill_commit_message(struct repository *repo, " empty message aborts the commit.\n"); struct wt_status s; - strbuf_addstr(out, default_message); - strbuf_addch(out, '\n'); - strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str); - write_file_buf(path, out->buf, out->len); - wt_status_prepare(repo, &s); FREE_AND_NULL(s.branch); s.ahead_behind_flags = AHEAD_BEHIND_QUICK; @@ -57,14 +52,22 @@ static int fill_commit_message(struct repository *repo, s.whence = FROM_COMMIT; s.committable = 1; - s.fp = fopen(git_path_commit_editmsg(), "a"); + s.fp = fopen(path, "w"); if (!s.fp) - return error_errno(_("could not open '%s'"), git_path_commit_editmsg()); + return error_errno(_("could not open '%s'"), path); + + strbuf_addstr(out, default_message); + strbuf_addch(out, '\n'); + strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str); + if (fwrite(out->buf, 1, out->len, s.fp) != out->len) + die_errno(_("could not write to '%s'"), path); wt_status_collect_changes_trees(&s, old_tree, new_tree); wt_status_print(&s); wt_status_collect_free_buffers(&s); string_list_clear_func(&s.change, change_data_free); + if (fclose(s.fp)) + die_errno(_("could not write to '%s'"), path); strbuf_reset(out); if (launch_editor(path, out, NULL)) { From 7270956809b8380cce9c0e511795fb192918dd02 Mon Sep 17 00:00:00 2001 From: Jeff King Date: Wed, 1 Jul 2026 02:39:42 -0400 Subject: [PATCH 02/29] bloom: make bloom-filter slab initialization idempotent Before using any of the commit-graph bloom-filter code, somebody needs to call init_bloom_filters(). This initializes the commit-slab we use for storing filter information. But we don't want to call it twice (without a matching deinit call in the middle), since it overwrites the existing slab pointers, leaking the old values. Usually this init call is done lazily by parse_commit_graph() when we read a graph file that contains bloom data. But this can lead to some oddities: 1. We may call parse_commit_graph() multiple times when we have a split commit graph. I think this doesn't produce any user-visible bug, because we parse all of the files back-to-back. So even though we call init_bloom_filters() multiple times, we never look up any commits in between, so the slab is always empty and initializing it again happens to do nothing. This is a little sketchy to rely on, though. 2. We call init_bloom_filters() directly in the "test-tool bloom" helper so we can call get_or_compute_bloom_filter(). Normally this is OK, as there is no bloom data in the on-disk graph file. But if you build with SANITIZE=leak and run: GIT_TEST_COMMIT_GRAPH=1 \ GIT_TEST_COMMIT_GRAPH_CHANGED_PATHS=1 \ ./t0095-bloom.sh there's a leak that happens like this: a. Our direct init_bloom_filters() sets up the slab. b. In get_or_compute_bloom_filter() we look in the slab for a cached entry. We won't find anything yet, but since we don't use the read-only "peek" accessor (since we'll fill in the entry if not present), this actually populates the slab with an allocated chunk. c. Now we look for an entry in the graph files. So we have to load them and end up in parse_commit_graph(), which calls init_bloom_filters() again. That trashes our existing slab allocation, which is now leaked. 3. There's a similar case in write_commit_graph(), which calls init_bloom_filters() before get_or_compute_bloom_filter(). I think this code path is lucky to avoid the leak because it reads the graph files first, then calls its init_bloom_filters(), and then starts filling in entries. So even though it has the same overwrite problem, we'd never actually allocate any slab entries between overwrites. The easiest solution here is just to make initialization of the slab idempotent using an extra flag. We could actually get away without using the extra flag, for example by checking whether bloom_filters.stride has been set. But it's probably better to avoid being too intimate with the commit-slab details. Likewise we don't actually need to re-initialize after a deinit call; the slab-clearing function leaves things in a usable state. But it seemed less surprising to pair the init/deinit calls explicitly. I suspect this could all be cleaned up a bit more, but it's tricky. The only function which uses the slab is get_or_compute_bloom_filter(), so it would be much simpler if it just lazy-initialized the slab itself. But I think there is a subtle dependency here: we usually only initialize the slab when we find a graph file that has bloom entries. So if we were to lose that signal, then even repos without on-disk bloom data would start trying to populate the slab, wasting memory that will never get entries filled in from the disk. So we'd need some other way of signaling "it is worth considering bloom entries at all". This patch takes a smaller and more direct route to just dealing with the potential leak issue. Signed-off-by: Jeff King Signed-off-by: Junio C Hamano --- bloom.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bloom.c b/bloom.c index a805ac0c296b37..c98d1672adb71a 100644 --- a/bloom.c +++ b/bloom.c @@ -16,6 +16,7 @@ define_commit_slab(bloom_filter_slab, struct bloom_filter); static struct bloom_filter_slab bloom_filters; +static int bloom_filter_slab_initialized; struct pathmap_hash_entry { struct hashmap_entry entry; @@ -263,7 +264,10 @@ void add_key_to_filter(const struct bloom_key *key, void init_bloom_filters(void) { + if (bloom_filter_slab_initialized) + return; init_bloom_filter_slab(&bloom_filters); + bloom_filter_slab_initialized = 1; } static void free_one_bloom_filter(struct bloom_filter *filter) @@ -276,6 +280,7 @@ static void free_one_bloom_filter(struct bloom_filter *filter) void deinit_bloom_filters(void) { deep_clear_bloom_filter_slab(&bloom_filters, free_one_bloom_filter); + bloom_filter_slab_initialized = 0; } struct bloom_keyvec *bloom_keyvec_new(const char *path, size_t len, From c34573e638262aeb2749493d3d79200f4fa419e1 Mon Sep 17 00:00:00 2001 From: Jeff King Date: Wed, 1 Jul 2026 02:40:52 -0400 Subject: [PATCH 03/29] revision: avoid leaking bloom keyvecs with multiple traversals In prepare_revision_walk(), we convert the pruning pathspecs into bloom-filter "keyvecs" via prepare_to_use_bloom_filter(). This allocates memory which is then freed eventually by release_revisions(), via release_revisions_bloom_keyvecs(). But there's one case where we leak. If a caller uses the same rev_info for multiple walks, calling prepare_revision_walk() multiple times, then subsequent calls will overwrite the earlier keyvecs, leaking them. This can happen with "git show foo bar", which does a separate no-walk traversal for "foo" and "bar". Building with SANITIZE=leak and running the test suite like: GIT_TEST_COMMIT_GRAPH=1 \ GIT_TEST_COMMIT_GRAPH_CHANGED_PATHS=1 \ ./t4013-diff-various.sh will trigger a complaint from LSan. It does not happen without those extra flags because we don't store on-disk bloom filters by default, and thus we optimize out the keyvec computation. We can fix the leak by discarding the old entries before generating new ones. There's an alternative fix, which is that prepare_to_use_bloom_filter() could notice that we already have keyvec entries and just reuse them. But this is less safe; the keyvec depends on the pruning pathspec, and we don't know if that has changed. I think it would _probably_ work in practice, since any caller using a rev_info for multiple traversals is probably doing so with the same pathspec. But it would also create a very subtle bug if that assumption is violated. So we'll do the safer thing here, and generate fresh keyvec entries for each traversal. The efficiency difference is probably not noticeable, and this is what was happening already (we just weren't bothering to free the old ones!). Signed-off-by: Jeff King Signed-off-by: Junio C Hamano --- revision.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/revision.c b/revision.c index 599b3a66c369ca..c2f3276e486f0c 100644 --- a/revision.c +++ b/revision.c @@ -708,6 +708,8 @@ static int convert_pathspec_to_bloom_keyvec(struct bloom_keyvec **out, static void prepare_to_use_bloom_filter(struct rev_info *revs) { + release_revisions_bloom_keyvecs(revs); + if (!revs->commits) return; From 459088ec2e3f9c4fea4040d39502d0e011e0ac42 Mon Sep 17 00:00:00 2001 From: Jeff King Date: Wed, 1 Jul 2026 02:42:03 -0400 Subject: [PATCH 04/29] line-log: drop extra copy of range with bloom filters When line_log_process_ranges_arbitrary_commit() finds out from a Bloom filter that a commit didn't touch the path in question, it can quickly pass its range on to the parent commit. It does so by making a copy of the range, and passing that copy to add_line_range(). But add_line_range() already makes its own copy (either directly, or by merging with an existing range for that parent). So the copy we make is leaked. We can plug the leak by just passing our range directly, without the extra copy. The bug goes back to f32dde8c12 (line-log: integrate with changed-path Bloom filters, 2020-05-11). We didn't notice because the test suite never explicitly combines these features! You can observe it by building with SANITIZE=leak and running t4211 with some extra flags: GIT_TEST_COMMIT_GRAPH=1 \ GIT_TEST_COMMIT_GRAPH_CHANGED_PATHS=1 \ ./t4211-line-log.sh It would probably be useful to have some more targeted test coverage of these features together. But I don't think there's much point in just blindly copying the existing tests and adding bloom-filter support. We already do that via the linux-TEST-vars CI job. We just don't run the leak-checking build with those flags (so if there were a correctness problem, we'd have noticed, just not a leak). So I think we'd benefit from somebody clueful thinking about the interaction of these features and testing the corner cases. But for the purposes of this leak fix, I think we can just rely on the recipe above (and consider running an extra leak-test job with more TEST-vars set). Signed-off-by: Jeff King Signed-off-by: Junio C Hamano --- line-log.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/line-log.c b/line-log.c index 858a899cd2a61d..0a03f20118feb0 100644 --- a/line-log.c +++ b/line-log.c @@ -1153,8 +1153,7 @@ int line_log_process_ranges_arbitrary_commit(struct rev_info *rev, struct commit if (range) { if (commit->parents && !bloom_filter_check(rev, commit, range)) { - struct line_log_data *prange = line_log_data_copy(range); - add_line_range(rev, commit->parents->item, prange); + add_line_range(rev, commit->parents->item, range); clear_commit_line_range(rev, commit); } else if (commit->parents && commit->parents->next) changed = process_ranges_merge_commit(rev, commit, range); From ca39c3511d89dd9c84a90b0a4dc394a950e08772 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Wed, 1 Jul 2026 13:35:28 +0200 Subject: [PATCH 05/29] read-cache: split out function to drop unmerged entries to stage 0 In `repo_read_index_unmerged()` we read the index and then drop any unmerged entries to stage 0. In a subsequent commit we'll want to perform this operation on arbitrary indexes, not only the one of the given repository. Prepare for this by splitting out the functionality into a new function that can act on an arbitrary index. While at it, fix a signedness mismatch when iterating through the index cache entries. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- read-cache-ll.h | 1 + read-cache.c | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/read-cache-ll.h b/read-cache-ll.h index 2c8b4b21b1c7e9..71b87615ebc6d3 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -309,6 +309,7 @@ int write_locked_index(struct index_state *, struct lock_file *lock, unsigned fl void discard_index(struct index_state *); void move_index_extensions(struct index_state *dst, struct index_state *src); int unmerged_index(const struct index_state *); +int index_state_unmerged_to_stage0(struct index_state *istate); /** * Returns 1 if istate differs from tree, 0 otherwise. If tree is NULL, diff --git a/read-cache.c b/read-cache.c index 21829102ae275e..799a5bc7199fe0 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3403,13 +3403,15 @@ int write_locked_index(struct index_state *istate, struct lock_file *lock, */ int repo_read_index_unmerged(struct repository *repo) { - struct index_state *istate; - int i; + repo_read_index(repo); + return index_state_unmerged_to_stage0(repo->index); +} + +int index_state_unmerged_to_stage0(struct index_state *istate) +{ int unmerged = 0; - repo_read_index(repo); - istate = repo->index; - for (i = 0; i < istate->cache_nr; i++) { + for (unsigned int i = 0; i < istate->cache_nr; i++) { struct cache_entry *ce = istate->cache[i]; struct cache_entry *new_ce; int len; From 5ce540bed335005046aad5969e1ae3bb1fc6cde5 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Wed, 1 Jul 2026 13:35:29 +0200 Subject: [PATCH 06/29] reset: drop `USE_THE_REPOSITORY_VARIABLE` In "reset.c" we still have references to `the_repository`, even though the only entry point into the file already receives a repository as parameter. Update all uses of `the_repository` to instead use the passed-in repo and drop `USE_THE_REPOSITORY_VARIABLE`. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- reset.c | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/reset.c b/reset.c index 46e30e639458c5..3b3cb74dabf164 100644 --- a/reset.c +++ b/reset.c @@ -1,5 +1,3 @@ -#define USE_THE_REPOSITORY_VARIABLE - #include "git-compat-util.h" #include "cache-tree.h" #include "gettext.h" @@ -13,7 +11,8 @@ #include "unpack-trees.h" #include "hook.h" -static int update_refs(const struct reset_head_opts *opts, +static int update_refs(struct repository *repo, + const struct reset_head_opts *opts, const struct object_id *oid, const struct object_id *head) { @@ -42,19 +41,19 @@ static int update_refs(const struct reset_head_opts *opts, prefix_len = msg.len; if (update_orig_head) { - if (!repo_get_oid(the_repository, "ORIG_HEAD", &oid_old_orig)) + if (!repo_get_oid(repo, "ORIG_HEAD", &oid_old_orig)) old_orig = &oid_old_orig; if (head) { if (!reflog_orig_head) { strbuf_addstr(&msg, "updating ORIG_HEAD"); reflog_orig_head = msg.buf; } - refs_update_ref(get_main_ref_store(the_repository), + refs_update_ref(get_main_ref_store(repo), reflog_orig_head, "ORIG_HEAD", orig_head ? orig_head : head, old_orig, 0, UPDATE_REFS_MSG_ON_ERR); } else if (old_orig) - refs_delete_ref(get_main_ref_store(the_repository), + refs_delete_ref(get_main_ref_store(repo), NULL, "ORIG_HEAD", old_orig, 0); } @@ -64,23 +63,23 @@ static int update_refs(const struct reset_head_opts *opts, reflog_head = msg.buf; } if (!switch_to_branch) - ret = refs_update_ref(get_main_ref_store(the_repository), + ret = refs_update_ref(get_main_ref_store(repo), reflog_head, "HEAD", oid, head, detach_head ? REF_NO_DEREF : 0, UPDATE_REFS_MSG_ON_ERR); else { - ret = refs_update_ref(get_main_ref_store(the_repository), + ret = refs_update_ref(get_main_ref_store(repo), reflog_branch ? reflog_branch : reflog_head, switch_to_branch, oid, NULL, 0, UPDATE_REFS_MSG_ON_ERR); if (!ret) - ret = refs_update_symref(get_main_ref_store(the_repository), + ret = refs_update_symref(get_main_ref_store(repo), "HEAD", switch_to_branch, reflog_head); } if (!ret && run_hook) - run_hooks_l(the_repository, "post-checkout", - oid_to_hex(head ? head : null_oid(the_hash_algo)), + run_hooks_l(repo, "post-checkout", + oid_to_hex(head ? head : null_oid(repo->hash_algo)), oid_to_hex(oid), "1", NULL); strbuf_release(&msg); return ret; @@ -126,7 +125,7 @@ int reset_head(struct repository *r, const struct reset_head_opts *opts) oid = &head_oid; if (refs_only) - return update_refs(opts, oid, head); + return update_refs(r, opts, oid, head); action = reset_hard ? "reset" : "checkout"; setup_unpack_trees_porcelain(&unpack_tree_opts, action); @@ -163,7 +162,7 @@ int reset_head(struct repository *r, const struct reset_head_opts *opts) goto leave_reset_head; } - tree = repo_parse_tree_indirect(the_repository, oid); + tree = repo_parse_tree_indirect(r, oid); if (!tree) { ret = error(_("unable to read tree (%s)"), oid_to_hex(oid)); goto leave_reset_head; @@ -177,7 +176,7 @@ int reset_head(struct repository *r, const struct reset_head_opts *opts) } if (oid != &head_oid || update_orig_head || switch_to_branch) - ret = update_refs(opts, oid, head); + ret = update_refs(r, opts, oid, head); leave_reset_head: rollback_lock_file(&lock); From 9762e2faf7c7f6781df3b07ed1a56f1b4b99b53c Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Wed, 1 Jul 2026 13:35:30 +0200 Subject: [PATCH 07/29] reset: rename `reset_head()` In a subsequent commit we're about to adapt `reset_head()` so that the reference update to HEAD is optional, only. At this point the function starts to feel misnamed, as it doesn't necessarily have anything to do with the HEAD reference anymore. The gist of the function then is that we reset the working tree to a specific new commit, updating both the index and the checked-out files. Rename it to `reset_working_tree()` to better reflect that. Note that we don't adjust the flags yet. This will happen in a subsequent commit. Suggested-by: Phillip Wood Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- builtin/rebase.c | 20 ++++++++++---------- reset.c | 5 +++-- reset.h | 4 ++-- sequencer.c | 8 ++++---- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/builtin/rebase.c b/builtin/rebase.c index fa4f5d9306b856..22fbba3c62fa54 100644 --- a/builtin/rebase.c +++ b/builtin/rebase.c @@ -592,7 +592,7 @@ static int finish_rebase(struct rebase_options *opts) static int move_to_original_branch(struct rebase_options *opts) { struct strbuf branch_reflog = STRBUF_INIT, head_reflog = STRBUF_INIT; - struct reset_head_opts ropts = { 0 }; + struct reset_working_tree_options ropts = { 0 }; int ret; if (!opts->head_name) @@ -610,7 +610,7 @@ static int move_to_original_branch(struct rebase_options *opts) ropts.flags = RESET_HEAD_REFS_ONLY; ropts.branch_msg = branch_reflog.buf; ropts.head_msg = head_reflog.buf; - ret = reset_head(the_repository, &ropts); + ret = reset_working_tree(the_repository, &ropts); strbuf_release(&branch_reflog); strbuf_release(&head_reflog); @@ -685,7 +685,7 @@ static int run_am(struct rebase_options *opts) status = run_command(&format_patch); if (status) { - struct reset_head_opts ropts = { 0 }; + struct reset_working_tree_options ropts = { 0 }; unlink(rebased_patches); free(rebased_patches); child_process_clear(&am); @@ -693,7 +693,7 @@ static int run_am(struct rebase_options *opts) ropts.oid = &opts->orig_head->object.oid; ropts.branch = opts->head_name; ropts.default_reflog_action = opts->reflog_action; - reset_head(the_repository, &ropts); + reset_working_tree(the_repository, &ropts); error(_("\ngit encountered an error while preparing the " "patches to replay\n" "these revisions:\n" @@ -855,7 +855,7 @@ static int rebase_config(const char *var, const char *value, static int checkout_up_to_date(struct rebase_options *options) { struct strbuf buf = STRBUF_INIT; - struct reset_head_opts ropts = { 0 }; + struct reset_working_tree_options ropts = { 0 }; int ret = 0; strbuf_addf(&buf, "%s: checkout %s", @@ -866,7 +866,7 @@ static int checkout_up_to_date(struct rebase_options *options) if (!ropts.branch) ropts.flags |= RESET_HEAD_DETACH; ropts.head_msg = buf.buf; - if (reset_head(the_repository, &ropts) < 0) + if (reset_working_tree(the_repository, &ropts) < 0) ret = error(_("could not switch to %s"), options->switch_to); strbuf_release(&buf); @@ -1116,7 +1116,7 @@ int cmd_rebase(int argc, int reschedule_failed_exec = -1; int allow_preemptive_ff = 1; int preserve_merges_selected = 0; - struct reset_head_opts ropts = { 0 }; + struct reset_working_tree_options ropts = { 0 }; struct option builtin_rebase_options[] = { OPT_STRING(0, "onto", &options.onto_name, N_("revision"), @@ -1385,7 +1385,7 @@ int cmd_rebase(int argc, rerere_clear(the_repository, &merge_rr); string_list_clear(&merge_rr, 1); ropts.flags = RESET_HEAD_HARD; - if (reset_head(the_repository, &ropts) < 0) + if (reset_working_tree(the_repository, &ropts) < 0) die(_("could not discard worktree changes")); remove_branch_state(the_repository, 0); if (read_basic_state(&options)) @@ -1410,7 +1410,7 @@ int cmd_rebase(int argc, ropts.head_msg = head_msg.buf; ropts.branch = options.head_name; ropts.flags = RESET_HEAD_HARD; - if (reset_head(the_repository, &ropts) < 0) + if (reset_working_tree(the_repository, &ropts) < 0) die(_("could not move back to %s"), oid_to_hex(&options.orig_head->object.oid)); strbuf_release(&head_msg); @@ -1880,7 +1880,7 @@ int cmd_rebase(int argc, RESET_HEAD_RUN_POST_CHECKOUT_HOOK; ropts.head_msg = msg.buf; ropts.default_reflog_action = options.reflog_action; - if (reset_head(the_repository, &ropts)) { + if (reset_working_tree(the_repository, &ropts)) { ret = error(_("Could not detach HEAD")); goto cleanup_autostash; } diff --git a/reset.c b/reset.c index 3b3cb74dabf164..799596398b01f0 100644 --- a/reset.c +++ b/reset.c @@ -12,7 +12,7 @@ #include "hook.h" static int update_refs(struct repository *repo, - const struct reset_head_opts *opts, + const struct reset_working_tree_options *opts, const struct object_id *oid, const struct object_id *head) { @@ -85,7 +85,8 @@ static int update_refs(struct repository *repo, return ret; } -int reset_head(struct repository *r, const struct reset_head_opts *opts) +int reset_working_tree(struct repository *r, + const struct reset_working_tree_options *opts) { const struct object_id *oid = opts->oid; const char *switch_to_branch = opts->branch; diff --git a/reset.h b/reset.h index a28f81829d859d..f1301520149612 100644 --- a/reset.h +++ b/reset.h @@ -17,7 +17,7 @@ /* Update ORIG_HEAD as well as HEAD */ #define RESET_ORIG_HEAD (1<<4) -struct reset_head_opts { +struct reset_working_tree_options { /* * The commit to checkout/reset to. Defaults to HEAD. */ @@ -55,6 +55,6 @@ struct reset_head_opts { const char *default_reflog_action; }; -int reset_head(struct repository *r, const struct reset_head_opts *opts); +int reset_working_tree(struct repository *r, const struct reset_working_tree_options *opts); #endif diff --git a/sequencer.c b/sequencer.c index 1ee4b2875b25a0..d73ecf0384f092 100644 --- a/sequencer.c +++ b/sequencer.c @@ -4677,7 +4677,7 @@ static void create_autostash_internal(struct repository *r, if (has_unstaged_changes(r, 1) || has_uncommitted_changes(r, 1)) { struct child_process stash = CHILD_PROCESS_INIT; - struct reset_head_opts ropts = { .flags = RESET_HEAD_HARD }; + struct reset_working_tree_options ropts = { .flags = RESET_HEAD_HARD }; struct object_id oid; strvec_pushl(&stash.args, @@ -4707,7 +4707,7 @@ static void create_autostash_internal(struct repository *r, if (!silent) printf(_("Created autostash: %s\n"), buf.buf); - if (reset_head(r, &ropts) < 0) + if (reset_working_tree(r, &ropts) < 0) die(_("could not reset --hard")); discard_index(r->index); if (repo_read_index(r) < 0) @@ -4867,7 +4867,7 @@ static int checkout_onto(struct repository *r, struct replay_opts *opts, const char *onto_name, const struct object_id *onto, const struct object_id *orig_head) { - struct reset_head_opts ropts = { + struct reset_working_tree_options ropts = { .oid = onto, .orig_head = orig_head, .flags = RESET_HEAD_DETACH | RESET_ORIG_HEAD | @@ -4876,7 +4876,7 @@ static int checkout_onto(struct repository *r, struct replay_opts *opts, onto_name), .default_reflog_action = sequencer_reflog_action(opts) }; - if (reset_head(r, &ropts)) { + if (reset_working_tree(r, &ropts)) { apply_autostash(rebase_path_autostash()); sequencer_remove_state(opts); return error(_("could not detach HEAD")); From 2535b951e07ac0da0d1c6c06bd76effe879c18a4 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Wed, 1 Jul 2026 13:35:31 +0200 Subject: [PATCH 08/29] reset: modernize flags passed to `reset_working_tree()` The flags passed to `reset_working_tree()` are declared as defines. This has fallen a bit out of practice nowadays, where we instead prefer to use enums. Furthermore, the prefix of those flags does not match the function name anymore after the rename in the preceding commit. Adapt the code to follow modern best practices and adapt the flag names. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- builtin/rebase.c | 15 ++++++++------- reset.c | 12 ++++++------ reset.h | 31 +++++++++++++++++++------------ sequencer.c | 9 ++++++--- 4 files changed, 39 insertions(+), 28 deletions(-) diff --git a/builtin/rebase.c b/builtin/rebase.c index 22fbba3c62fa54..06dcbaf5e80d38 100644 --- a/builtin/rebase.c +++ b/builtin/rebase.c @@ -607,7 +607,7 @@ static int move_to_original_branch(struct rebase_options *opts) strbuf_addf(&head_reflog, "%s (finish): returning to %s", opts->reflog_action, opts->head_name); ropts.branch = opts->head_name; - ropts.flags = RESET_HEAD_REFS_ONLY; + ropts.flags = RESET_WORKING_TREE_REFS_ONLY; ropts.branch_msg = branch_reflog.buf; ropts.head_msg = head_reflog.buf; ret = reset_working_tree(the_repository, &ropts); @@ -862,9 +862,9 @@ static int checkout_up_to_date(struct rebase_options *options) options->reflog_action, options->switch_to); ropts.oid = &options->orig_head->object.oid; ropts.branch = options->head_name; - ropts.flags = RESET_HEAD_RUN_POST_CHECKOUT_HOOK; + ropts.flags = RESET_WORKING_TREE_RUN_POST_CHECKOUT_HOOK; if (!ropts.branch) - ropts.flags |= RESET_HEAD_DETACH; + ropts.flags |= RESET_WORKING_TREE_DETACH; ropts.head_msg = buf.buf; if (reset_working_tree(the_repository, &ropts) < 0) ret = error(_("could not switch to %s"), options->switch_to); @@ -1384,7 +1384,7 @@ int cmd_rebase(int argc, rerere_clear(the_repository, &merge_rr); string_list_clear(&merge_rr, 1); - ropts.flags = RESET_HEAD_HARD; + ropts.flags = RESET_WORKING_TREE_HARD; if (reset_working_tree(the_repository, &ropts) < 0) die(_("could not discard worktree changes")); remove_branch_state(the_repository, 0); @@ -1409,7 +1409,7 @@ int cmd_rebase(int argc, ropts.oid = &options.orig_head->object.oid; ropts.head_msg = head_msg.buf; ropts.branch = options.head_name; - ropts.flags = RESET_HEAD_HARD; + ropts.flags = RESET_WORKING_TREE_HARD; if (reset_working_tree(the_repository, &ropts) < 0) die(_("could not move back to %s"), oid_to_hex(&options.orig_head->object.oid)); @@ -1876,8 +1876,9 @@ int cmd_rebase(int argc, options.reflog_action, options.onto_name); ropts.oid = &options.onto->object.oid; ropts.orig_head = &options.orig_head->object.oid; - ropts.flags = RESET_HEAD_DETACH | RESET_ORIG_HEAD | - RESET_HEAD_RUN_POST_CHECKOUT_HOOK; + ropts.flags = RESET_WORKING_TREE_DETACH | + RESET_WORKING_TREE_UPDATE_ORIG_HEAD | + RESET_WORKING_TREE_RUN_POST_CHECKOUT_HOOK; ropts.head_msg = msg.buf; ropts.default_reflog_action = options.reflog_action; if (reset_working_tree(the_repository, &ropts)) { diff --git a/reset.c b/reset.c index 799596398b01f0..4ca7f23a25af8d 100644 --- a/reset.c +++ b/reset.c @@ -16,9 +16,9 @@ static int update_refs(struct repository *repo, const struct object_id *oid, const struct object_id *head) { - unsigned detach_head = opts->flags & RESET_HEAD_DETACH; - unsigned run_hook = opts->flags & RESET_HEAD_RUN_POST_CHECKOUT_HOOK; - unsigned update_orig_head = opts->flags & RESET_ORIG_HEAD; + unsigned detach_head = opts->flags & RESET_WORKING_TREE_DETACH; + unsigned run_hook = opts->flags & RESET_WORKING_TREE_RUN_POST_CHECKOUT_HOOK; + unsigned update_orig_head = opts->flags & RESET_WORKING_TREE_UPDATE_ORIG_HEAD; const struct object_id *orig_head = opts->orig_head; const char *switch_to_branch = opts->branch; const char *reflog_branch = opts->branch_msg; @@ -90,9 +90,9 @@ int reset_working_tree(struct repository *r, { const struct object_id *oid = opts->oid; const char *switch_to_branch = opts->branch; - unsigned reset_hard = opts->flags & RESET_HEAD_HARD; - unsigned refs_only = opts->flags & RESET_HEAD_REFS_ONLY; - unsigned update_orig_head = opts->flags & RESET_ORIG_HEAD; + unsigned reset_hard = opts->flags & RESET_WORKING_TREE_HARD; + unsigned refs_only = opts->flags & RESET_WORKING_TREE_REFS_ONLY; + unsigned update_orig_head = opts->flags & RESET_WORKING_TREE_UPDATE_ORIG_HEAD; struct object_id *head = NULL, head_oid; struct tree_desc desc[2] = { { NULL }, { NULL } }; struct lock_file lock = LOCK_INIT; diff --git a/reset.h b/reset.h index f1301520149612..2e5826de99d61d 100644 --- a/reset.h +++ b/reset.h @@ -6,16 +6,22 @@ #define GIT_REFLOG_ACTION_ENVIRONMENT "GIT_REFLOG_ACTION" -/* Request a detached checkout */ -#define RESET_HEAD_DETACH (1<<0) -/* Request a reset rather than a checkout */ -#define RESET_HEAD_HARD (1<<1) -/* Run the post-checkout hook */ -#define RESET_HEAD_RUN_POST_CHECKOUT_HOOK (1<<2) -/* Only update refs, do not touch the worktree */ -#define RESET_HEAD_REFS_ONLY (1<<3) -/* Update ORIG_HEAD as well as HEAD */ -#define RESET_ORIG_HEAD (1<<4) +enum reset_working_tree_flags { + /* Request a detached checkout */ + RESET_WORKING_TREE_DETACH = (1 << 0), + + /* Request a reset rather than a checkout */ + RESET_WORKING_TREE_HARD = (1 << 1), + + /* Run the post-checkout hook */ + RESET_WORKING_TREE_RUN_POST_CHECKOUT_HOOK = (1 << 2), + + /* Only update refs, do not touch the worktree */ + RESET_WORKING_TREE_REFS_ONLY = (1 << 3), + + /* Update ORIG_HEAD as well as HEAD */ + RESET_WORKING_TREE_UPDATE_ORIG_HEAD = (1 << 4), +}; struct reset_working_tree_options { /* @@ -33,7 +39,7 @@ struct reset_working_tree_options { /* * Flags defined above. */ - unsigned flags; + enum reset_working_tree_flags flags; /* * Optional reflog message for branch, defaults to head_msg. */ @@ -45,7 +51,8 @@ struct reset_working_tree_options { const char *head_msg; /* * Optional reflog message for ORIG_HEAD, if this omitted and flags - * contains RESET_ORIG_HEAD then default_reflog_action must be given. + * contains RESET_WORKING_TREE_UPDATE_ORIG_HEAD then + * default_reflog_action must be given. */ const char *orig_head_msg; /* diff --git a/sequencer.c b/sequencer.c index d73ecf0384f092..4efe831178e464 100644 --- a/sequencer.c +++ b/sequencer.c @@ -4677,7 +4677,9 @@ static void create_autostash_internal(struct repository *r, if (has_unstaged_changes(r, 1) || has_uncommitted_changes(r, 1)) { struct child_process stash = CHILD_PROCESS_INIT; - struct reset_working_tree_options ropts = { .flags = RESET_HEAD_HARD }; + struct reset_working_tree_options ropts = { + .flags = RESET_WORKING_TREE_HARD, + }; struct object_id oid; strvec_pushl(&stash.args, @@ -4870,8 +4872,9 @@ static int checkout_onto(struct repository *r, struct replay_opts *opts, struct reset_working_tree_options ropts = { .oid = onto, .orig_head = orig_head, - .flags = RESET_HEAD_DETACH | RESET_ORIG_HEAD | - RESET_HEAD_RUN_POST_CHECKOUT_HOOK, + .flags = RESET_WORKING_TREE_DETACH | + RESET_WORKING_TREE_UPDATE_ORIG_HEAD | + RESET_WORKING_TREE_RUN_POST_CHECKOUT_HOOK, .head_msg = reflog_message(opts, "start", "checkout %s", onto_name), .default_reflog_action = sequencer_reflog_action(opts) From 8e435d99b56e8c7be32d7f46101d18e88b9fc4ac Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Wed, 1 Jul 2026 13:35:32 +0200 Subject: [PATCH 09/29] reset: introduce dry-run mode In a subsequent commit we'll add another caller to `reset_working_tree()` that wants to perform a dry-run check of whether it would be possible to update the index and working tree when moving to a new commit. Introduce a new flag that lets the caller perform this operation. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- reset.c | 44 +++++++++++++++++++++++++++++++++----------- reset.h | 6 ++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/reset.c b/reset.c index 4ca7f23a25af8d..99f2c1b0123525 100644 --- a/reset.c +++ b/reset.c @@ -93,11 +93,14 @@ int reset_working_tree(struct repository *r, unsigned reset_hard = opts->flags & RESET_WORKING_TREE_HARD; unsigned refs_only = opts->flags & RESET_WORKING_TREE_REFS_ONLY; unsigned update_orig_head = opts->flags & RESET_WORKING_TREE_UPDATE_ORIG_HEAD; + unsigned dry_run = opts->flags & RESET_WORKING_TREE_DRY_RUN; struct object_id *head = NULL, head_oid; struct tree_desc desc[2] = { { NULL }, { NULL } }; struct lock_file lock = LOCK_INIT; struct unpack_trees_options unpack_tree_opts = { 0 }; struct tree *tree; + struct index_state scratch_index = INDEX_STATE_INIT(r); + struct index_state *istate; const char *action; int ret = 0, nr = 0; @@ -110,7 +113,7 @@ int reset_working_tree(struct repository *r, if (opts->branch_msg && !opts->branch) BUG("branch reflog message given without a branch"); - if (!refs_only && repo_hold_locked_index(r, &lock, LOCK_REPORT_ON_ERROR) < 0) { + if (!refs_only && !dry_run && repo_hold_locked_index(r, &lock, LOCK_REPORT_ON_ERROR) < 0) { ret = -1; goto leave_reset_head; } @@ -125,16 +128,36 @@ int reset_working_tree(struct repository *r, if (!oid) oid = &head_oid; - if (refs_only) - return update_refs(r, opts, oid, head); + if (refs_only) { + if (!dry_run) + return update_refs(r, opts, oid, head); + return 0; + } + + if (dry_run) { + if (read_index_from(&scratch_index, r->index_file, r->gitdir) < 0 || + index_state_unmerged_to_stage0(&scratch_index) < 0) { + ret = error(_("could not read index")); + goto leave_reset_head; + } + + istate = &scratch_index; + } else { + if (repo_read_index_unmerged(r) < 0) { + ret = error(_("could not read index")); + goto leave_reset_head; + } + istate = r->index; + } action = reset_hard ? "reset" : "checkout"; setup_unpack_trees_porcelain(&unpack_tree_opts, action); unpack_tree_opts.head_idx = 1; - unpack_tree_opts.src_index = r->index; - unpack_tree_opts.dst_index = r->index; + unpack_tree_opts.src_index = istate; + unpack_tree_opts.dst_index = istate; unpack_tree_opts.fn = reset_hard ? oneway_merge : twoway_merge; - unpack_tree_opts.update = 1; + unpack_tree_opts.update = !dry_run; + unpack_tree_opts.dry_run = dry_run; unpack_tree_opts.merge = 1; unpack_tree_opts.preserve_ignored = 0; /* FIXME: !overwrite_ignore */ unpack_tree_opts.skip_cache_tree_update = 1; @@ -142,11 +165,6 @@ int reset_working_tree(struct repository *r, if (reset_hard) unpack_tree_opts.reset = UNPACK_RESET_PROTECT_UNTRACKED; - if (repo_read_index_unmerged(r) < 0) { - ret = error(_("could not read index")); - goto leave_reset_head; - } - if (!reset_hard && !fill_tree_descriptor(r, &desc[nr++], &head_oid)) { ret = error(_("failed to find tree of %s"), oid_to_hex(&head_oid)); @@ -163,6 +181,9 @@ int reset_working_tree(struct repository *r, goto leave_reset_head; } + if (dry_run) + goto leave_reset_head; + tree = repo_parse_tree_indirect(r, oid); if (!tree) { ret = error(_("unable to read tree (%s)"), oid_to_hex(oid)); @@ -182,6 +203,7 @@ int reset_working_tree(struct repository *r, leave_reset_head: rollback_lock_file(&lock); clear_unpack_trees_porcelain(&unpack_tree_opts); + release_index(&scratch_index); while (nr) free((void *)desc[--nr].buffer); return ret; diff --git a/reset.h b/reset.h index 2e5826de99d61d..898e4a1e9504f5 100644 --- a/reset.h +++ b/reset.h @@ -21,6 +21,12 @@ enum reset_working_tree_flags { /* Update ORIG_HEAD as well as HEAD */ RESET_WORKING_TREE_UPDATE_ORIG_HEAD = (1 << 4), + + /* + * Perform a dry-run by performing the operation without updating + * any user-visible state. + */ + RESET_WORKING_TREE_DRY_RUN = (1 << 5), }; struct reset_working_tree_options { From 11c689b8730e0927ef9fc73eea59270318b3177d Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Thu, 2 Jul 2026 14:01:59 +0200 Subject: [PATCH 10/29] packfile: thread odb_source_packed through packed_object_info() Add an optional `struct odb_source_packed *source` parameter to `packed_object_info()` and `packed_object_info_with_index_pos()`. This parameter is unused at this point in time, but it will be used in a follow-up commit so that we can record the source of a specific object. Note that callers in "odb/source-packed.c" pass the already-available source, but all other callers pass `NULL` instead. This is fine though, as we only care about populating this info when called via the packed store. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- builtin/cat-file.c | 2 +- builtin/pack-objects.c | 4 ++-- commit-graph.c | 2 +- odb/source-packed.c | 4 ++-- pack-bitmap.c | 2 +- packfile.c | 8 +++++--- packfile.h | 6 ++++-- t/helper/test-bitmap.c | 2 +- 8 files changed, 17 insertions(+), 13 deletions(-) diff --git a/builtin/cat-file.c b/builtin/cat-file.c index 0f3dbd9850d2fa..8726485f1fef5a 100644 --- a/builtin/cat-file.c +++ b/builtin/cat-file.c @@ -497,7 +497,7 @@ static void batch_object_write(const char *obj_name, data->info.sizep = &data->size; if (pack) - ret = packed_object_info(pack, offset, &data->info); + ret = packed_object_info(NULL, pack, offset, &data->info); else ret = odb_read_object_info_extended(the_repository->objects, &data->oid, &data->info, diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index bc5f9ef3218a2f..620d9ce08508cc 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -2463,7 +2463,7 @@ static void drop_reused_delta(struct object_entry *entry) oi.sizep = &size; oi.typep = &type; - if (packed_object_info(IN_PACK(entry), entry->in_pack_offset, &oi) < 0) { + if (packed_object_info(NULL, IN_PACK(entry), entry->in_pack_offset, &oi) < 0) { /* * We failed to get the info from this pack for some reason; * fall back to odb_read_object_info, which may find another copy. @@ -3804,7 +3804,7 @@ static int add_object_entry_from_pack(const struct object_id *oid, ofs = nth_packed_object_offset(p, pos); oi.typep = &type; - if (packed_object_info(p, ofs, &oi) < 0) { + if (packed_object_info(NULL, p, ofs, &oi) < 0) { die(_("could not get type of object %s in pack %s"), oid_to_hex(oid), p->pack_name); } else if (type == OBJ_COMMIT) { diff --git a/commit-graph.c b/commit-graph.c index c6d9c5c740e94d..9dc8bd5eee785f 100644 --- a/commit-graph.c +++ b/commit-graph.c @@ -1538,7 +1538,7 @@ static int add_packed_commits(const struct object_id *oid, struct object_info oi = OBJECT_INFO_INIT; oi.typep = &type; - if (packed_object_info(pack, offset, &oi) < 0) + if (packed_object_info(NULL, pack, offset, &oi) < 0) die(_("unable to get type of object %s"), oid_to_hex(oid)); return add_packed_commits_oi(oid, &oi, data); diff --git a/odb/source-packed.c b/odb/source-packed.c index 42c28fba0e34b2..43fb53b72ddf2e 100644 --- a/odb/source-packed.c +++ b/odb/source-packed.c @@ -59,7 +59,7 @@ static int odb_source_packed_read_object_info(struct odb_source *source, if (!oi) return 0; - ret = packed_object_info(e.p, e.offset, oi); + ret = packed_object_info(packed, e.p, e.offset, oi); if (ret < 0) { mark_bad_packed_object(e.p, oid); return -1; @@ -99,7 +99,7 @@ static int odb_source_packed_for_each_object_wrapper(const struct object_id *oid off_t offset = nth_packed_object_offset(pack, index_pos); struct object_info oi = *data->request; - if (packed_object_info_with_index_pos(pack, offset, + if (packed_object_info_with_index_pos(data->store, pack, offset, &index_pos, &oi) < 0) { mark_bad_packed_object(pack, oid); return -1; diff --git a/pack-bitmap.c b/pack-bitmap.c index 83eb47a28ba9de..35774b6f0c0da7 100644 --- a/pack-bitmap.c +++ b/pack-bitmap.c @@ -1877,7 +1877,7 @@ static unsigned long get_size_by_pos(struct bitmap_index *bitmap_git, ofs = pack_pos_to_offset(pack, pos); } - if (packed_object_info(pack, ofs, &oi) < 0) { + if (packed_object_info(NULL, pack, ofs, &oi) < 0) { struct object_id oid; nth_bitmap_object_oid(bitmap_git, &oid, pack_pos_to_index(pack, pos)); diff --git a/packfile.c b/packfile.c index 1d1b23b6cc782f..2b741d7a7665e9 100644 --- a/packfile.c +++ b/packfile.c @@ -1324,7 +1324,8 @@ static void add_delta_base_cache(struct packed_git *p, off_t base_offset, hashmap_add(&delta_base_cache, &ent->ent); } -int packed_object_info_with_index_pos(struct packed_git *p, off_t obj_offset, +int packed_object_info_with_index_pos(struct odb_source_packed *source UNUSED, + struct packed_git *p, off_t obj_offset, uint32_t *maybe_index_pos, struct object_info *oi) { struct pack_window *w_curs = NULL; @@ -1446,10 +1447,11 @@ int packed_object_info_with_index_pos(struct packed_git *p, off_t obj_offset, return ret; } -int packed_object_info(struct packed_git *p, off_t obj_offset, +int packed_object_info(struct odb_source_packed *source, + struct packed_git *p, off_t obj_offset, struct object_info *oi) { - return packed_object_info_with_index_pos(p, obj_offset, NULL, oi); + return packed_object_info_with_index_pos(source, p, obj_offset, NULL, oi); } static void *unpack_compressed_entry(struct packed_git *p, diff --git a/packfile.h b/packfile.h index 2329a697014a66..e1f77152b5c4bf 100644 --- a/packfile.h +++ b/packfile.h @@ -320,9 +320,11 @@ extern int do_check_packed_object_crc; * Look up the object info for a specific offset in the packfile. * Returns zero on success, a negative error code otherwise. */ -int packed_object_info(struct packed_git *pack, +int packed_object_info(struct odb_source_packed *source, + struct packed_git *pack, off_t offset, struct object_info *); -int packed_object_info_with_index_pos(struct packed_git *p, off_t obj_offset, +int packed_object_info_with_index_pos(struct odb_source_packed *source, + struct packed_git *p, off_t obj_offset, uint32_t *maybe_index_pos, struct object_info *oi); void mark_bad_packed_object(struct packed_git *, const struct object_id *); diff --git a/t/helper/test-bitmap.c b/t/helper/test-bitmap.c index b130832b81ecce..8547ef67e243eb 100644 --- a/t/helper/test-bitmap.c +++ b/t/helper/test-bitmap.c @@ -52,7 +52,7 @@ static int add_packed_object(const struct object_id *oid, entry = packlist_alloc(packed, oid); entry->idx.offset = nth_packed_object_offset(pack, pos); - if (packed_object_info(pack, entry->idx.offset, &oi) < 0) + if (packed_object_info(NULL, pack, entry->idx.offset, &oi) < 0) die("could not get type of object %s", oid_to_hex(oid)); oe_set_type(entry, type); From 126076b62f5164f9da6bbe454fc71c164ea5cb42 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Thu, 2 Jul 2026 14:02:00 +0200 Subject: [PATCH 11/29] odb: make backend-specific fields optional The `struct object_info` carries two pieces of information about how an object was looked up: - The `whence` enum identifying the backend. - The backend-tagged union `u` exposing backend-specific details (currently only the packed-source case, which records the owning pack, offset and packed object type). The union is populated unconditionally, even though most callers don't care about provenance at all. Split the backend-specific union out into a new public type, `struct object_info_source`, and make the object info structure carry it via just another opt-in request pointer. As with all the other requestable information, callers that need source info allocate a `struct object_info_source` on the stack and point `sourcep` at it; callers that don't care about it simply leave the field as a `NULL` pointer. Adapt callers accordingly. Note that the `whence` enum is strictly-speaking also backend-specific information, so it would be another good candidate to be moved into the `struct object_info_source`. For now though it is left alone, as it will be replaced by a `struct odb_source` pointer in a subsequent commit. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- builtin/cat-file.c | 8 ++++-- builtin/index-pack.c | 8 ++++-- builtin/pack-objects.c | 15 ++++++++--- odb.c | 3 ++- odb.h | 60 ++++++++++++++++++++++++++++-------------- packfile.c | 33 ++++++++++++----------- reachable.c | 5 +++- 7 files changed, 87 insertions(+), 45 deletions(-) diff --git a/builtin/cat-file.c b/builtin/cat-file.c index 8726485f1fef5a..adc626ce30ee33 100644 --- a/builtin/cat-file.c +++ b/builtin/cat-file.c @@ -835,7 +835,8 @@ static int batch_one_object_oi(const struct object_id *oid, { struct for_each_object_payload *payload = _payload; if (oi && oi->whence == OI_PACKED) - return payload->callback(oid, oi->u.packed.pack, oi->u.packed.offset, + return payload->callback(oid, oi->sourcep->u.packed.pack, + oi->sourcep->u.packed.offset, payload->payload); return payload->callback(oid, NULL, 0, payload->payload); } @@ -906,7 +907,10 @@ static void batch_each_object(struct batch_options *opt, &payload, flags); } } else { - struct object_info oi = { 0 }; + struct object_info_source oi_source; + struct object_info oi = { + .sourcep = &oi_source, + }; for (source = the_repository->objects->sources; source; source = source->next) { struct odb_source_files *files = odb_source_files_downcast(source); diff --git a/builtin/index-pack.c b/builtin/index-pack.c index f3966584683990..77af26db8fc0f3 100644 --- a/builtin/index-pack.c +++ b/builtin/index-pack.c @@ -1825,11 +1825,15 @@ static void repack_local_links(void) oidset_iter_init(&outgoing_links, &iter); while ((oid = oidset_iter_next(&iter))) { - struct object_info info = OBJECT_INFO_INIT; + struct object_info_source info_source; + struct object_info info = { + .sourcep = &info_source, + }; + if (odb_read_object_info_extended(the_repository->objects, oid, &info, 0)) /* Missing; assume it is a promisor object */ continue; - if (info.whence == OI_PACKED && info.u.packed.pack->pack_promisor) + if (info.whence == OI_PACKED && info_source.u.packed.pack->pack_promisor) continue; if (!cmd.args.nr) { diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index 620d9ce08508cc..9deb37e9e87efe 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -4491,8 +4491,9 @@ static int add_object_in_unpacked_pack(const struct object_id *oid, void *data UNUSED) { if (cruft) { - add_cruft_object_entry(oid, OBJ_NONE, oi->u.packed.pack, - oi->u.packed.offset, NULL, *oi->mtimep); + add_cruft_object_entry(oid, OBJ_NONE, oi->sourcep->u.packed.pack, + oi->sourcep->u.packed.offset, NULL, + *oi->mtimep); } else { add_object_entry(oid, OBJ_NONE, "", 0); } @@ -4509,8 +4510,10 @@ static void add_objects_in_unpacked_packs(void) ODB_FOR_EACH_OBJECT_SKIP_IN_CORE_KEPT_PACKS | ODB_FOR_EACH_OBJECT_SKIP_ON_DISK_KEPT_PACKS, }; + struct object_info_source oi_source; struct object_info oi = { .mtimep = &mtime, + .sourcep = &oi_source, }; odb_prepare_alternates(to_pack.repo->objects); @@ -5000,10 +5003,14 @@ static int option_parse_cruft_expiration(const struct option *opt UNUSED, static int is_not_in_promisor_pack_obj(struct object *obj, void *data UNUSED) { - struct object_info info = OBJECT_INFO_INIT; + struct object_info_source info_source; + struct object_info info = { + .sourcep = &info_source, + }; + if (odb_read_object_info_extended(the_repository->objects, &obj->oid, &info, 0)) BUG("should_include_obj should only be called on existing objects"); - return info.whence != OI_PACKED || !info.u.packed.pack->pack_promisor; + return info.whence != OI_PACKED || !info_source.u.packed.pack->pack_promisor; } static int is_not_in_promisor_pack(struct commit *commit, void *data) { diff --git a/odb.c b/odb.c index 7d555be09feaea..99f4e7551c4274 100644 --- a/odb.c +++ b/odb.c @@ -692,7 +692,8 @@ static int oid_object_info_convert(struct repository *r, } } input_oi->whence = new_oi.whence; - input_oi->u = new_oi.u; + if (input_oi->sourcep) + *input_oi->sourcep = *new_oi.sourcep; return ret; } diff --git a/odb.h b/odb.h index 3834a0dcbf033b..770900289ab217 100644 --- a/odb.h +++ b/odb.h @@ -248,6 +248,38 @@ int odb_pretend_object(struct object_database *odb, void *buf, size_t len, enum object_type type, struct object_id *oid); +/* + * Object information that can be used to uniquely identify an object and learn + * more about how exactly it is stored. + */ +struct object_info_source { + /* + * Backend-specific information about the specific object. This can be + * used for example to uniquely identify a given object in case it + * exists multiple times. + */ + union { + /* + * struct { + * ... Nothing to expose in this case + * } cached; + * struct { + * ... Nothing to expose in this case + * } loose; + */ + struct { + struct packed_git *pack; + off_t offset; + enum packed_object_type { + PACKED_OBJECT_TYPE_UNKNOWN, + PACKED_OBJECT_TYPE_FULL, + PACKED_OBJECT_TYPE_OFS_DELTA, + PACKED_OBJECT_TYPE_REF_DELTA, + } type; + } packed; + } u; +}; + struct object_info { /* Request */ enum object_type *typep; @@ -269,32 +301,20 @@ struct object_info { */ time_t *mtimep; + /* + * Backend-specific information that tells the caller where exactly an + * object was looked up from. This information should help disambiguate + * object lookups in case the same object exists in multiple sources, + * or multiple times in the same source. + */ + struct object_info_source *sourcep; + /* Response */ enum { OI_CACHED, OI_LOOSE, OI_PACKED, } whence; - union { - /* - * struct { - * ... Nothing to expose in this case - * } cached; - * struct { - * ... Nothing to expose in this case - * } loose; - */ - struct { - struct packed_git *pack; - off_t offset; - enum packed_object_type { - PACKED_OBJECT_TYPE_UNKNOWN, - PACKED_OBJECT_TYPE_FULL, - PACKED_OBJECT_TYPE_OFS_DELTA, - PACKED_OBJECT_TYPE_REF_DELTA, - } type; - } packed; - } u; }; /* diff --git a/packfile.c b/packfile.c index 2b741d7a7665e9..688c410b35fc25 100644 --- a/packfile.c +++ b/packfile.c @@ -1422,22 +1422,25 @@ int packed_object_info_with_index_pos(struct odb_source_packed *source UNUSED, } oi->whence = OI_PACKED; - oi->u.packed.offset = obj_offset; - oi->u.packed.pack = p; - switch (type) { - case OBJ_NONE: - oi->u.packed.type = PACKED_OBJECT_TYPE_UNKNOWN; - break; - case OBJ_REF_DELTA: - oi->u.packed.type = PACKED_OBJECT_TYPE_REF_DELTA; - break; - case OBJ_OFS_DELTA: - oi->u.packed.type = PACKED_OBJECT_TYPE_OFS_DELTA; - break; - default: - oi->u.packed.type = PACKED_OBJECT_TYPE_FULL; - break; + if (oi->sourcep) { + oi->sourcep->u.packed.offset = obj_offset; + oi->sourcep->u.packed.pack = p; + + switch (type) { + case OBJ_NONE: + oi->sourcep->u.packed.type = PACKED_OBJECT_TYPE_UNKNOWN; + break; + case OBJ_REF_DELTA: + oi->sourcep->u.packed.type = PACKED_OBJECT_TYPE_REF_DELTA; + break; + case OBJ_OFS_DELTA: + oi->sourcep->u.packed.type = PACKED_OBJECT_TYPE_OFS_DELTA; + break; + default: + oi->sourcep->u.packed.type = PACKED_OBJECT_TYPE_FULL; + break; + } } ret = 0; diff --git a/reachable.c b/reachable.c index 101cfc272715fb..2fc5b82d62658b 100644 --- a/reachable.c +++ b/reachable.c @@ -235,7 +235,8 @@ static int add_recent_object(const struct object_id *oid, add_pending_object(data->revs, obj, ""); if (data->cb) { if (oi->whence == OI_PACKED) - data->cb(obj, oi->u.packed.pack, oi->u.packed.offset, *oi->mtimep); + data->cb(obj, oi->sourcep->u.packed.pack, + oi->sourcep->u.packed.offset, *oi->mtimep); else data->cb(obj, NULL, 0, *oi->mtimep); } @@ -252,9 +253,11 @@ int add_unseen_recent_objects_to_traversal(struct rev_info *revs, unsigned flags; enum object_type type; time_t mtime; + struct object_info_source oi_source; struct object_info oi = { .mtimep = &mtime, .typep = &type, + .sourcep = &oi_source, }; int r; From 32a95be604e710d38e05d0013fbb2a64257c4014 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Thu, 2 Jul 2026 14:02:01 +0200 Subject: [PATCH 12/29] odb: add `source` field to struct object_info_source The previous commit introduced `struct object_info_source` as an opt-in container for backend-specific information, but for now we only moved preexisting data into this structure. Most importantly, the caller has no way yet to learn about which source an object was actually looked up from. Instead, callers have to rely on the `whence` enum to distinguish the object type, but cannot use that enum to tell the object source. Add a `struct odb_source *source` field to the structure and populate it from each backend's lookup path. The `whence` enum is still set and used by callers; it will be removed in a subsequent commit now that `sourcep->source` can identify the backend on its own. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- builtin/cat-file.c | 8 ++++---- builtin/index-pack.c | 6 +++--- builtin/pack-objects.c | 14 +++++++------- odb.c | 4 ++-- odb.h | 11 +++++++---- odb/source-inmemory.c | 3 +++ odb/source-loose.c | 2 ++ packfile.c | 20 ++++++++++++-------- reachable.c | 8 ++++---- 9 files changed, 44 insertions(+), 32 deletions(-) diff --git a/builtin/cat-file.c b/builtin/cat-file.c index adc626ce30ee33..0aca6acb753312 100644 --- a/builtin/cat-file.c +++ b/builtin/cat-file.c @@ -835,8 +835,8 @@ static int batch_one_object_oi(const struct object_id *oid, { struct for_each_object_payload *payload = _payload; if (oi && oi->whence == OI_PACKED) - return payload->callback(oid, oi->sourcep->u.packed.pack, - oi->sourcep->u.packed.offset, + return payload->callback(oid, oi->source_infop->u.packed.pack, + oi->source_infop->u.packed.offset, payload->payload); return payload->callback(oid, NULL, 0, payload->payload); } @@ -907,9 +907,9 @@ static void batch_each_object(struct batch_options *opt, &payload, flags); } } else { - struct object_info_source oi_source; + struct odb_source_info source_info; struct object_info oi = { - .sourcep = &oi_source, + .source_infop = &source_info, }; for (source = the_repository->objects->sources; source; source = source->next) { diff --git a/builtin/index-pack.c b/builtin/index-pack.c index 77af26db8fc0f3..fe6e70522d648d 100644 --- a/builtin/index-pack.c +++ b/builtin/index-pack.c @@ -1825,15 +1825,15 @@ static void repack_local_links(void) oidset_iter_init(&outgoing_links, &iter); while ((oid = oidset_iter_next(&iter))) { - struct object_info_source info_source; + struct odb_source_info source_info; struct object_info info = { - .sourcep = &info_source, + .source_infop = &source_info, }; if (odb_read_object_info_extended(the_repository->objects, oid, &info, 0)) /* Missing; assume it is a promisor object */ continue; - if (info.whence == OI_PACKED && info_source.u.packed.pack->pack_promisor) + if (info.whence == OI_PACKED && source_info.u.packed.pack->pack_promisor) continue; if (!cmd.args.nr) { diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index 9deb37e9e87efe..b7ef90f67c68f4 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -4491,8 +4491,8 @@ static int add_object_in_unpacked_pack(const struct object_id *oid, void *data UNUSED) { if (cruft) { - add_cruft_object_entry(oid, OBJ_NONE, oi->sourcep->u.packed.pack, - oi->sourcep->u.packed.offset, NULL, + add_cruft_object_entry(oid, OBJ_NONE, oi->source_infop->u.packed.pack, + oi->source_infop->u.packed.offset, NULL, *oi->mtimep); } else { add_object_entry(oid, OBJ_NONE, "", 0); @@ -4510,10 +4510,10 @@ static void add_objects_in_unpacked_packs(void) ODB_FOR_EACH_OBJECT_SKIP_IN_CORE_KEPT_PACKS | ODB_FOR_EACH_OBJECT_SKIP_ON_DISK_KEPT_PACKS, }; - struct object_info_source oi_source; + struct odb_source_info source_info; struct object_info oi = { .mtimep = &mtime, - .sourcep = &oi_source, + .source_infop = &source_info, }; odb_prepare_alternates(to_pack.repo->objects); @@ -5003,14 +5003,14 @@ static int option_parse_cruft_expiration(const struct option *opt UNUSED, static int is_not_in_promisor_pack_obj(struct object *obj, void *data UNUSED) { - struct object_info_source info_source; + struct odb_source_info source_info; struct object_info info = { - .sourcep = &info_source, + .source_infop = &source_info, }; if (odb_read_object_info_extended(the_repository->objects, &obj->oid, &info, 0)) BUG("should_include_obj should only be called on existing objects"); - return info.whence != OI_PACKED || !info_source.u.packed.pack->pack_promisor; + return info.whence != OI_PACKED || !source_info.u.packed.pack->pack_promisor; } static int is_not_in_promisor_pack(struct commit *commit, void *data) { diff --git a/odb.c b/odb.c index 99f4e7551c4274..34c35c47a54d58 100644 --- a/odb.c +++ b/odb.c @@ -692,8 +692,8 @@ static int oid_object_info_convert(struct repository *r, } } input_oi->whence = new_oi.whence; - if (input_oi->sourcep) - *input_oi->sourcep = *new_oi.sourcep; + if (input_oi->source_infop) + *input_oi->source_infop = *new_oi.source_infop; return ret; } diff --git a/odb.h b/odb.h index 770900289ab217..659bf8afe14591 100644 --- a/odb.h +++ b/odb.h @@ -249,10 +249,13 @@ int odb_pretend_object(struct object_database *odb, struct object_id *oid); /* - * Object information that can be used to uniquely identify an object and learn - * more about how exactly it is stored. + * Object database source information that can be used to uniquely identify an + * object and learn more about how exactly it is stored. */ -struct object_info_source { +struct odb_source_info { + /* The source that this object has been looked up from. */ + struct odb_source *source; + /* * Backend-specific information about the specific object. This can be * used for example to uniquely identify a given object in case it @@ -307,7 +310,7 @@ struct object_info { * object lookups in case the same object exists in multiple sources, * or multiple times in the same source. */ - struct object_info_source *sourcep; + struct odb_source_info *source_infop; /* Response */ enum { diff --git a/odb/source-inmemory.c b/odb/source-inmemory.c index e004566d768b01..1d173bfa46d12f 100644 --- a/odb/source-inmemory.c +++ b/odb/source-inmemory.c @@ -52,6 +52,9 @@ static void populate_object_info(struct odb_source_inmemory *source, *oi->contentp = xmemdupz(object->buf, object->size); if (oi->mtimep) *oi->mtimep = 0; + if (oi->source_infop) + oi->source_infop->source = &source->base; + oi->whence = OI_CACHED; } diff --git a/odb/source-loose.c b/odb/source-loose.c index 66e6bb8d3f8004..c254957602f4d1 100644 --- a/odb/source-loose.c +++ b/odb/source-loose.c @@ -196,6 +196,8 @@ static int read_object_info_from_path(struct odb_source_loose *loose, oi->typep = NULL; if (oi->delta_base_oid) oidclr(oi->delta_base_oid, loose->base.odb->repo->hash_algo); + if (oi->source_infop && !ret) + oi->source_infop->source = &loose->base; if (!ret) oi->whence = OI_LOOSE; } diff --git a/packfile.c b/packfile.c index 688c410b35fc25..ce51d1e5a371cc 100644 --- a/packfile.c +++ b/packfile.c @@ -1324,7 +1324,7 @@ static void add_delta_base_cache(struct packed_git *p, off_t base_offset, hashmap_add(&delta_base_cache, &ent->ent); } -int packed_object_info_with_index_pos(struct odb_source_packed *source UNUSED, +int packed_object_info_with_index_pos(struct odb_source_packed *source, struct packed_git *p, off_t obj_offset, uint32_t *maybe_index_pos, struct object_info *oi) { @@ -1423,22 +1423,26 @@ int packed_object_info_with_index_pos(struct odb_source_packed *source UNUSED, oi->whence = OI_PACKED; - if (oi->sourcep) { - oi->sourcep->u.packed.offset = obj_offset; - oi->sourcep->u.packed.pack = p; + if (oi->source_infop) { + if (!source) + BUG("cannot request source without an owning source"); + oi->source_infop->source = &source->base; + + oi->source_infop->u.packed.offset = obj_offset; + oi->source_infop->u.packed.pack = p; switch (type) { case OBJ_NONE: - oi->sourcep->u.packed.type = PACKED_OBJECT_TYPE_UNKNOWN; + oi->source_infop->u.packed.type = PACKED_OBJECT_TYPE_UNKNOWN; break; case OBJ_REF_DELTA: - oi->sourcep->u.packed.type = PACKED_OBJECT_TYPE_REF_DELTA; + oi->source_infop->u.packed.type = PACKED_OBJECT_TYPE_REF_DELTA; break; case OBJ_OFS_DELTA: - oi->sourcep->u.packed.type = PACKED_OBJECT_TYPE_OFS_DELTA; + oi->source_infop->u.packed.type = PACKED_OBJECT_TYPE_OFS_DELTA; break; default: - oi->sourcep->u.packed.type = PACKED_OBJECT_TYPE_FULL; + oi->source_infop->u.packed.type = PACKED_OBJECT_TYPE_FULL; break; } } diff --git a/reachable.c b/reachable.c index 2fc5b82d62658b..bf76b48fc5fff0 100644 --- a/reachable.c +++ b/reachable.c @@ -235,8 +235,8 @@ static int add_recent_object(const struct object_id *oid, add_pending_object(data->revs, obj, ""); if (data->cb) { if (oi->whence == OI_PACKED) - data->cb(obj, oi->sourcep->u.packed.pack, - oi->sourcep->u.packed.offset, *oi->mtimep); + data->cb(obj, oi->source_infop->u.packed.pack, + oi->source_infop->u.packed.offset, *oi->mtimep); else data->cb(obj, NULL, 0, *oi->mtimep); } @@ -253,11 +253,11 @@ int add_unseen_recent_objects_to_traversal(struct rev_info *revs, unsigned flags; enum object_type type; time_t mtime; - struct object_info_source oi_source; + struct odb_source_info source_info; struct object_info oi = { .mtimep = &mtime, .typep = &type, - .sourcep = &oi_source, + .source_infop = &source_info, }; int r; From 2242f7ee36e9ec1a70314b685b1680e641e56f88 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Thu, 2 Jul 2026 14:02:02 +0200 Subject: [PATCH 13/29] treewide: convert users of `whence` to the new source field The `whence` field has become redundant now that callers can learn about the exact source an object has been looked up from via the `struct object_info_source::source` field. Adapt callers to use the new field. Note that all callsites already set up the `info.sourcep` request pointer, so the conversion is rather straight-forward. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- builtin/cat-file.c | 2 +- builtin/index-pack.c | 3 ++- builtin/pack-objects.c | 2 +- reachable.c | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/builtin/cat-file.c b/builtin/cat-file.c index 0aca6acb753312..758f8fc736321c 100644 --- a/builtin/cat-file.c +++ b/builtin/cat-file.c @@ -834,7 +834,7 @@ static int batch_one_object_oi(const struct object_id *oid, void *_payload) { struct for_each_object_payload *payload = _payload; - if (oi && oi->whence == OI_PACKED) + if (oi && oi->source_infop->source->type == ODB_SOURCE_PACKED) return payload->callback(oid, oi->source_infop->u.packed.pack, oi->source_infop->u.packed.offset, payload->payload); diff --git a/builtin/index-pack.c b/builtin/index-pack.c index fe6e70522d648d..7af1aea6f9f13f 100644 --- a/builtin/index-pack.c +++ b/builtin/index-pack.c @@ -1833,7 +1833,8 @@ static void repack_local_links(void) if (odb_read_object_info_extended(the_repository->objects, oid, &info, 0)) /* Missing; assume it is a promisor object */ continue; - if (info.whence == OI_PACKED && source_info.u.packed.pack->pack_promisor) + if (source_info.source->type == ODB_SOURCE_PACKED && + source_info.u.packed.pack->pack_promisor) continue; if (!cmd.args.nr) { diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index b7ef90f67c68f4..4fdb6dbf6fe800 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -5010,7 +5010,7 @@ static int is_not_in_promisor_pack_obj(struct object *obj, void *data UNUSED) if (odb_read_object_info_extended(the_repository->objects, &obj->oid, &info, 0)) BUG("should_include_obj should only be called on existing objects"); - return info.whence != OI_PACKED || !source_info.u.packed.pack->pack_promisor; + return source_info.source->type != ODB_SOURCE_PACKED || !source_info.u.packed.pack->pack_promisor; } static int is_not_in_promisor_pack(struct commit *commit, void *data) { diff --git a/reachable.c b/reachable.c index bf76b48fc5fff0..caadacc02ad2ad 100644 --- a/reachable.c +++ b/reachable.c @@ -234,7 +234,7 @@ static int add_recent_object(const struct object_id *oid, add_pending_object(data->revs, obj, ""); if (data->cb) { - if (oi->whence == OI_PACKED) + if (oi->source_infop->source->type == ODB_SOURCE_PACKED) data->cb(obj, oi->source_infop->u.packed.pack, oi->source_infop->u.packed.offset, *oi->mtimep); else From aea037e53444fd2ffebbd22b49726d9730ced389 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Thu, 2 Jul 2026 14:02:03 +0200 Subject: [PATCH 14/29] odb: drop `whence` field from object info In the preceding commits we have migrated all callers to derive their information of how a specific object is stored to use the new object info source instead, and hence the field is now unused. Drop it. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- odb.c | 1 - odb.h | 7 ------- odb/source-inmemory.c | 2 -- odb/source-loose.c | 2 -- packfile.c | 2 -- 5 files changed, 14 deletions(-) diff --git a/odb.c b/odb.c index 34c35c47a54d58..175a1ee42cb9e2 100644 --- a/odb.c +++ b/odb.c @@ -691,7 +691,6 @@ static int oid_object_info_convert(struct repository *r, return -1; } } - input_oi->whence = new_oi.whence; if (input_oi->source_infop) *input_oi->source_infop = *new_oi.source_infop; return ret; diff --git a/odb.h b/odb.h index 659bf8afe14591..c251788d5016ae 100644 --- a/odb.h +++ b/odb.h @@ -311,13 +311,6 @@ struct object_info { * or multiple times in the same source. */ struct odb_source_info *source_infop; - - /* Response */ - enum { - OI_CACHED, - OI_LOOSE, - OI_PACKED, - } whence; }; /* diff --git a/odb/source-inmemory.c b/odb/source-inmemory.c index 1d173bfa46d12f..460aec821c386b 100644 --- a/odb/source-inmemory.c +++ b/odb/source-inmemory.c @@ -54,8 +54,6 @@ static void populate_object_info(struct odb_source_inmemory *source, *oi->mtimep = 0; if (oi->source_infop) oi->source_infop->source = &source->base; - - oi->whence = OI_CACHED; } static int odb_source_inmemory_read_object_info(struct odb_source *source, diff --git a/odb/source-loose.c b/odb/source-loose.c index c254957602f4d1..54df2e57d33b78 100644 --- a/odb/source-loose.c +++ b/odb/source-loose.c @@ -198,8 +198,6 @@ static int read_object_info_from_path(struct odb_source_loose *loose, oidclr(oi->delta_base_oid, loose->base.odb->repo->hash_algo); if (oi->source_infop && !ret) oi->source_infop->source = &loose->base; - if (!ret) - oi->whence = OI_LOOSE; } return ret; diff --git a/packfile.c b/packfile.c index ce51d1e5a371cc..8fa6309a099397 100644 --- a/packfile.c +++ b/packfile.c @@ -1421,8 +1421,6 @@ int packed_object_info_with_index_pos(struct odb_source_packed *source, oidclr(oi->delta_base_oid, p->repo->hash_algo); } - oi->whence = OI_PACKED; - if (oi->source_infop) { if (!source) BUG("cannot request source without an owning source"); From 8a7ad23e11de9a1de0d16a3aaddb840be768ab30 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Thu, 2 Jul 2026 14:02:04 +0200 Subject: [PATCH 15/29] odb: document object info fields Some of the fields in `struct object_info` are undocumented. Add these missing comments. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- odb.h | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/odb.h b/odb.h index c251788d5016ae..a1e222f605d5f8 100644 --- a/odb.h +++ b/odb.h @@ -283,12 +283,28 @@ struct odb_source_info { } u; }; +/* + * The object info contains the query and response that is to be used for + * functions that end up reading object information. Callers are expected to + * populate pointers whose information they want to request. + */ struct object_info { - /* Request */ + /* The object type. */ enum object_type *typep; + + /* The inflated object size in bytes. */ size_t *sizep; + + /* The object size as stored on disk. */ off_t *disk_sizep; + + /* + * The base the object is deltified against, in case it is stored as a + * delta. + */ struct object_id *delta_base_oid; + + /* The object contents. Ownership of memory goes over to the caller. */ void **contentp; /* From 10ab1af0f4446dcbc2b34773da9a9d0a9eb2b69f Mon Sep 17 00:00:00 2001 From: Mike Gilbert Date: Wed, 1 Jul 2026 15:39:28 -0400 Subject: [PATCH 16/29] meson: restore hook-list.h to builtin_sources This fixes a racy build failure. ``` builtin/bugreport.c:12:10: fatal error: hook-list.h: No such file or directory 12 | #include "hook-list.h" | ^~~~~~~~~~~~~ ``` hook-list.h must be generated before builtin/bugreport.c is compiled. Bug: https://bugs.gentoo.org/978326 Fixes: 2eb541e8f2a9 (hook: move is_known_hook() to hook.c for wider use, 2026-04-10) Signed-off-by: Mike Gilbert Acked-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- meson.build | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/meson.build b/meson.build index 3247697f74aae1..bdc83843e8e0ba 100644 --- a/meson.build +++ b/meson.build @@ -278,7 +278,20 @@ compat_sources = [ 'compat/terminal.c', ] +hook_list = custom_target( + input: 'Documentation/githooks.adoc', + output: 'hook-list.h', + command: [ + shell, + meson.current_source_dir() + '/tools/generate-hooklist.sh', + meson.current_source_dir(), + '@OUTPUT@', + ], + env: script_environment, +) + libgit_sources = [ + hook_list, 'abspath.c', 'add-interactive.c', 'add-patch.c', @@ -566,19 +579,8 @@ libgit_sources += custom_target( env: script_environment, ) -libgit_sources += custom_target( - input: 'Documentation/githooks.adoc', - output: 'hook-list.h', - command: [ - shell, - meson.current_source_dir() + '/tools/generate-hooklist.sh', - meson.current_source_dir(), - '@OUTPUT@', - ], - env: script_environment, -) - builtin_sources = [ + hook_list, 'builtin/add.c', 'builtin/am.c', 'builtin/annotate.c', From b2ebb7803bafb581649de1b51eb3184ab7fe3463 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Wed, 1 Jul 2026 13:35:33 +0200 Subject: [PATCH 17/29] reset: introduce ability to skip updating HEAD In a subsequent commit we'll introduce a new caller to `reset_working_tree()` that really only wants to update the index and working tree, without updating any references. Introduce a new flag that makes the caller opt in to updating HEAD and adapt all callers to set that flag. Note that in a previous iteration we instead introduced a flag that made callers opt out of updating any references. This was somewhat awkward though because we already have the `UPDATE_ORIG_HEAD` flag, so the result was somewhat inconsistent. Suggested-by: Phillip Wood Signed-off-by: Patrick Steinhardt [jc: fixed-up a typo pointed out by Christian] Signed-off-by: Junio C Hamano --- builtin/rebase.c | 14 ++++++++++---- reset.c | 9 +++++++-- reset.h | 9 ++++++--- sequencer.c | 4 +++- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/builtin/rebase.c b/builtin/rebase.c index 06dcbaf5e80d38..10a306310cd439 100644 --- a/builtin/rebase.c +++ b/builtin/rebase.c @@ -607,7 +607,8 @@ static int move_to_original_branch(struct rebase_options *opts) strbuf_addf(&head_reflog, "%s (finish): returning to %s", opts->reflog_action, opts->head_name); ropts.branch = opts->head_name; - ropts.flags = RESET_WORKING_TREE_REFS_ONLY; + ropts.flags = RESET_WORKING_TREE_REFS_ONLY | + RESET_WORKING_TREE_UPDATE_HEAD; ropts.branch_msg = branch_reflog.buf; ropts.head_msg = head_reflog.buf; ret = reset_working_tree(the_repository, &ropts); @@ -693,6 +694,7 @@ static int run_am(struct rebase_options *opts) ropts.oid = &opts->orig_head->object.oid; ropts.branch = opts->head_name; ropts.default_reflog_action = opts->reflog_action; + ropts.flags = RESET_WORKING_TREE_UPDATE_HEAD; reset_working_tree(the_repository, &ropts); error(_("\ngit encountered an error while preparing the " "patches to replay\n" @@ -862,7 +864,8 @@ static int checkout_up_to_date(struct rebase_options *options) options->reflog_action, options->switch_to); ropts.oid = &options->orig_head->object.oid; ropts.branch = options->head_name; - ropts.flags = RESET_WORKING_TREE_RUN_POST_CHECKOUT_HOOK; + ropts.flags = RESET_WORKING_TREE_RUN_POST_CHECKOUT_HOOK | + RESET_WORKING_TREE_UPDATE_HEAD; if (!ropts.branch) ropts.flags |= RESET_WORKING_TREE_DETACH; ropts.head_msg = buf.buf; @@ -1384,7 +1387,8 @@ int cmd_rebase(int argc, rerere_clear(the_repository, &merge_rr); string_list_clear(&merge_rr, 1); - ropts.flags = RESET_WORKING_TREE_HARD; + ropts.flags = RESET_WORKING_TREE_HARD | + RESET_WORKING_TREE_UPDATE_HEAD; if (reset_working_tree(the_repository, &ropts) < 0) die(_("could not discard worktree changes")); remove_branch_state(the_repository, 0); @@ -1409,7 +1413,8 @@ int cmd_rebase(int argc, ropts.oid = &options.orig_head->object.oid; ropts.head_msg = head_msg.buf; ropts.branch = options.head_name; - ropts.flags = RESET_WORKING_TREE_HARD; + ropts.flags = RESET_WORKING_TREE_HARD | + RESET_WORKING_TREE_UPDATE_HEAD; if (reset_working_tree(the_repository, &ropts) < 0) die(_("could not move back to %s"), oid_to_hex(&options.orig_head->object.oid)); @@ -1877,6 +1882,7 @@ int cmd_rebase(int argc, ropts.oid = &options.onto->object.oid; ropts.orig_head = &options.orig_head->object.oid; ropts.flags = RESET_WORKING_TREE_DETACH | + RESET_WORKING_TREE_UPDATE_HEAD | RESET_WORKING_TREE_UPDATE_ORIG_HEAD | RESET_WORKING_TREE_RUN_POST_CHECKOUT_HOOK; ropts.head_msg = msg.buf; diff --git a/reset.c b/reset.c index 99f2c1b0123525..490194380e6030 100644 --- a/reset.c +++ b/reset.c @@ -92,6 +92,7 @@ int reset_working_tree(struct repository *r, const char *switch_to_branch = opts->branch; unsigned reset_hard = opts->flags & RESET_WORKING_TREE_HARD; unsigned refs_only = opts->flags & RESET_WORKING_TREE_REFS_ONLY; + unsigned update_head = opts->flags & RESET_WORKING_TREE_UPDATE_HEAD; unsigned update_orig_head = opts->flags & RESET_WORKING_TREE_UPDATE_ORIG_HEAD; unsigned dry_run = opts->flags & RESET_WORKING_TREE_DRY_RUN; struct object_id *head = NULL, head_oid; @@ -113,6 +114,9 @@ int reset_working_tree(struct repository *r, if (opts->branch_msg && !opts->branch) BUG("branch reflog message given without a branch"); + if (update_orig_head && !update_head) + BUG("cannot update ORIG_HEAD without updating HEAD"); + if (!refs_only && !dry_run && repo_hold_locked_index(r, &lock, LOCK_REPORT_ON_ERROR) < 0) { ret = -1; goto leave_reset_head; @@ -129,7 +133,7 @@ int reset_working_tree(struct repository *r, oid = &head_oid; if (refs_only) { - if (!dry_run) + if (!dry_run && update_head) return update_refs(r, opts, oid, head); return 0; } @@ -197,7 +201,8 @@ int reset_working_tree(struct repository *r, goto leave_reset_head; } - if (oid != &head_oid || update_orig_head || switch_to_branch) + if (update_head && + (oid != &head_oid || update_orig_head || switch_to_branch)) ret = update_refs(r, opts, oid, head); leave_reset_head: diff --git a/reset.h b/reset.h index 898e4a1e9504f5..38b2891b53785f 100644 --- a/reset.h +++ b/reset.h @@ -19,14 +19,17 @@ enum reset_working_tree_flags { /* Only update refs, do not touch the worktree */ RESET_WORKING_TREE_REFS_ONLY = (1 << 3), - /* Update ORIG_HEAD as well as HEAD */ - RESET_WORKING_TREE_UPDATE_ORIG_HEAD = (1 << 4), + /* Update HEAD */ + RESET_WORKING_TREE_UPDATE_HEAD = (1 << 4), + + /* Update ORIG_HEAD */ + RESET_WORKING_TREE_UPDATE_ORIG_HEAD = (1 << 5), /* * Perform a dry-run by performing the operation without updating * any user-visible state. */ - RESET_WORKING_TREE_DRY_RUN = (1 << 5), + RESET_WORKING_TREE_DRY_RUN = (1 << 6), }; struct reset_working_tree_options { diff --git a/sequencer.c b/sequencer.c index 4efe831178e464..e905b1b2d97b54 100644 --- a/sequencer.c +++ b/sequencer.c @@ -4678,7 +4678,8 @@ static void create_autostash_internal(struct repository *r, has_uncommitted_changes(r, 1)) { struct child_process stash = CHILD_PROCESS_INIT; struct reset_working_tree_options ropts = { - .flags = RESET_WORKING_TREE_HARD, + .flags = RESET_WORKING_TREE_HARD | + RESET_WORKING_TREE_UPDATE_HEAD, }; struct object_id oid; @@ -4873,6 +4874,7 @@ static int checkout_onto(struct repository *r, struct replay_opts *opts, .oid = onto, .orig_head = orig_head, .flags = RESET_WORKING_TREE_DETACH | + RESET_WORKING_TREE_UPDATE_HEAD | RESET_WORKING_TREE_UPDATE_ORIG_HEAD | RESET_WORKING_TREE_RUN_POST_CHECKOUT_HOOK, .head_msg = reflog_message(opts, "start", "checkout %s", From 1280e92d1622484c97facb840f2788166171d460 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Wed, 1 Jul 2026 13:35:34 +0200 Subject: [PATCH 18/29] reset: allow the caller to specify the current HEAD object When calling `reset_working_tree()` we automatically derive the commit that the callers wants to move from by reading the HEAD commit. Some callers may already have resolved it, or they may want to move from a different commit that doesn't match HEAD. Introduce a new `oid_from` option that lets the caller specify the commit. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- reset.c | 5 ++++- reset.h | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/reset.c b/reset.c index 490194380e6030..c1538cac1678f7 100644 --- a/reset.c +++ b/reset.c @@ -122,7 +122,10 @@ int reset_working_tree(struct repository *r, goto leave_reset_head; } - if (!repo_get_oid(r, "HEAD", &head_oid)) { + if (opts->oid_from) { + oidcpy(&head_oid, opts->oid_from); + head = &head_oid; + } else if (!repo_get_oid(r, "HEAD", &head_oid)) { head = &head_oid; } else if (!oid || !reset_hard) { ret = error(_("could not determine HEAD revision")); diff --git a/reset.h b/reset.h index 38b2891b53785f..4c992ba671c7f1 100644 --- a/reset.h +++ b/reset.h @@ -37,6 +37,11 @@ struct reset_working_tree_options { * The commit to checkout/reset to. Defaults to HEAD. */ const struct object_id *oid; + /* + * The commit to checkout/reset from when doing a two-way merge. This + * is used as one of the sides to merge. + */ + const struct object_id *oid_from; /* * Optional value to set ORIG_HEAD. Defaults to HEAD. */ From e420d7b0ac2d5179c156ca61bc204d8b2661c6ba Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Wed, 1 Jul 2026 13:35:35 +0200 Subject: [PATCH 19/29] reset: stop assuming that the caller passes in a clean index In 652bd0211d (rebase: use 'skip_cache_tree_update' option, 2022-11-10), we updated `reset_working_tree()` to stop updating the index tree cache. This was done as a performance optimization: the function is only called by "sequencer.c" and "rebase.c", both of which assume a clean index before they perform their operation, so we know that the end result will be a clean index, too. Consequently, we can skip recomputing the cache as we can instead use `prime_cache_tree()` directly. In a subsequent commit we're about to add a new caller though where the assumption doesn't hold anymore: the index may be dirty before calling `reset_working_tree()`, and consequently we cannot prime the cache with a given tree anymore as the index and tree will mismatch. Adapt the logic so that we only skip the cache tree update in case we're doing a hard reset. While we could introduce logic that only skips the update in case the incoming index was dirty already, that doesn't really feel worth it: after all, the mentioned commit says itself that the performance improvement was negligible anyway. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- reset.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/reset.c b/reset.c index c1538cac1678f7..71254bde93fc51 100644 --- a/reset.c +++ b/reset.c @@ -167,10 +167,11 @@ int reset_working_tree(struct repository *r, unpack_tree_opts.dry_run = dry_run; unpack_tree_opts.merge = 1; unpack_tree_opts.preserve_ignored = 0; /* FIXME: !overwrite_ignore */ - unpack_tree_opts.skip_cache_tree_update = 1; init_checkout_metadata(&unpack_tree_opts.meta, switch_to_branch, oid, NULL); - if (reset_hard) + if (reset_hard) { + unpack_tree_opts.skip_cache_tree_update = 1; unpack_tree_opts.reset = UNPACK_RESET_PROTECT_UNTRACKED; + } if (!reset_hard && !fill_tree_descriptor(r, &desc[nr++], &head_oid)) { ret = error(_("failed to find tree of %s"), @@ -197,7 +198,8 @@ int reset_working_tree(struct repository *r, goto leave_reset_head; } - prime_cache_tree(r, r->index, tree); + if (reset_hard) + prime_cache_tree(r, r->index, tree); if (write_locked_index(r->index, &lock, COMMIT_LOCK) < 0) { ret = error(_("could not write index")); From 27717e2069fb524dab27ff2335aa4e69f35786a4 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Wed, 1 Jul 2026 13:35:36 +0200 Subject: [PATCH 20/29] replay: expose `replay_result_queue_update()` Expose `replay_result_queue_update()`, which is used to append another reference update to the replay result. This function will be used in a subsequent commit. Suggested-by: Christian Couder Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- replay.c | 8 ++++---- replay.h | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/replay.c b/replay.c index 4ef8abb607770a..7c8433107bbef2 100644 --- a/replay.c +++ b/replay.c @@ -351,10 +351,10 @@ void replay_result_release(struct replay_result *result) free(result->updates); } -static void replay_result_queue_update(struct replay_result *result, - const char *refname, - const struct object_id *old_oid, - const struct object_id *new_oid) +void replay_result_queue_update(struct replay_result *result, + const char *refname, + const struct object_id *old_oid, + const struct object_id *new_oid) { ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc); result->updates[result->updates_nr].refname = xstrdup(refname); diff --git a/replay.h b/replay.h index 1851a07705ab03..da83b65345efe2 100644 --- a/replay.h +++ b/replay.h @@ -80,6 +80,11 @@ struct replay_result { void replay_result_release(struct replay_result *result); +void replay_result_queue_update(struct replay_result *result, + const char *refname, + const struct object_id *old_oid, + const struct object_id *new_oid); + /* * Replay a set of commits onto a new location. Leaves both the working tree, * index and references untouched. Reference updates caused by the replay will From 46affdb8c74759697e9afef2a55854d3641953e1 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Wed, 1 Jul 2026 13:35:37 +0200 Subject: [PATCH 21/29] builtin/history: split handling of ref updates into two phases The function `handle_reference_updates()` is used by git-history(1) to update all references that refer to commits that have been rewritten. As such, it performs two steps: - It gathers the references that need to be updated in the first place. - It prepares and commits the reference transaction. In a subsequent commit we'll want to handle those two steps separately. Prepare for this by splitting up the function into two. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- builtin/history.c | 100 ++++++++++++++++++++++++++++------------------ 1 file changed, 62 insertions(+), 38 deletions(-) diff --git a/builtin/history.c b/builtin/history.c index 0fc06fb2045814..22b9fcb4a4b054 100644 --- a/builtin/history.c +++ b/builtin/history.c @@ -333,21 +333,17 @@ static int handle_ref_update(struct ref_transaction *transaction, NULL, NULL, 0, reflog_msg, err); } -static int handle_reference_updates(struct rev_info *revs, - enum ref_action action, - struct commit *original, - struct commit *rewritten, - const char *reflog_msg, - int dry_run, - enum replay_empty_commit_action empty) +static int compute_pending_ref_updates(struct rev_info *revs, + enum ref_action action, + struct commit *original, + struct commit *rewritten, + enum replay_empty_commit_action empty, + struct replay_result *result) { const struct name_decoration *decoration; struct replay_revisions_options opts = { .empty = empty, }; - struct replay_result result = { 0 }; - struct ref_transaction *transaction = NULL; - struct strbuf err = STRBUF_INIT; char hex[GIT_MAX_HEXSZ + 1]; bool detached_head; int head_flags = 0; @@ -359,34 +355,13 @@ static int handle_reference_updates(struct rev_info *revs, opts.onto = oid_to_hex_r(hex, &rewritten->object.oid); - ret = replay_revisions(revs, &opts, &result); + ret = replay_revisions(revs, &opts, result); if (ret) - goto out; + return ret; if (action != REF_ACTION_BRANCHES && action != REF_ACTION_HEAD) BUG("unsupported ref action %d", action); - if (!dry_run) { - transaction = ref_store_transaction_begin(get_main_ref_store(revs->repo), 0, &err); - if (!transaction) { - ret = error(_("failed to begin ref transaction: %s"), err.buf); - goto out; - } - } - - for (size_t i = 0; i < result.updates_nr; i++) { - ret = handle_ref_update(transaction, - result.updates[i].refname, - &result.updates[i].new_oid, - &result.updates[i].old_oid, - reflog_msg, &err); - if (ret) { - ret = error(_("failed to update ref '%s': %s"), - result.updates[i].refname, err.buf); - goto out; - } - } - /* * `replay_revisions()` only updates references that are * ancestors of `rewritten`, so we need to manually @@ -414,14 +389,41 @@ static int handle_reference_updates(struct rev_info *revs, !detached_head) continue; + replay_result_queue_update(result, decoration->name, + &original->object.oid, + &rewritten->object.oid); + } + + return 0; +} + +static int apply_pending_ref_updates(struct repository *repo, + const struct replay_result *result, + const char *reflog_msg, + int dry_run) +{ + struct ref_transaction *transaction = NULL; + struct strbuf err = STRBUF_INIT; + int ret; + + if (!dry_run) { + transaction = ref_store_transaction_begin(get_main_ref_store(repo), + 0, &err); + if (!transaction) { + ret = error(_("failed to begin ref transaction: %s"), err.buf); + goto out; + } + } + + for (size_t i = 0; i < result->updates_nr; i++) { ret = handle_ref_update(transaction, - decoration->name, - &rewritten->object.oid, - &original->object.oid, + result->updates[i].refname, + &result->updates[i].new_oid, + &result->updates[i].old_oid, reflog_msg, &err); if (ret) { ret = error(_("failed to update ref '%s': %s"), - decoration->name, err.buf); + result->updates[i].refname, err.buf); goto out; } } @@ -435,11 +437,33 @@ static int handle_reference_updates(struct rev_info *revs, out: ref_transaction_free(transaction); - replay_result_release(&result); strbuf_release(&err); return ret; } +static int handle_reference_updates(struct rev_info *revs, + enum ref_action action, + struct commit *original, + struct commit *rewritten, + const char *reflog_msg, + int dry_run, + enum replay_empty_commit_action empty) +{ + struct replay_result result = { 0 }; + int ret; + + ret = compute_pending_ref_updates(revs, action, original, rewritten, + empty, &result); + if (ret) + goto out; + + ret = apply_pending_ref_updates(revs->repo, &result, reflog_msg, dry_run); + +out: + replay_result_release(&result); + return ret; +} + static int commit_became_empty(struct repository *repo, struct commit *original, struct tree *result) From d11b348f7840c7ae4aba5d273ff56c813475a88e Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Wed, 1 Jul 2026 13:35:38 +0200 Subject: [PATCH 22/29] builtin/history: implement "drop" subcommand A common operation when editing the commit history is to drop a specific commit from the history entirely, but this operation is not currently covered by git-history(1). A couple of noteworthy bits: - This is the first git-history(1) command that will ultimately result in changes to both the index and the working tree. We thus have to add logic to merge resulting changes into those. - It is still not possible to replay merge commits, so this limitation is inherited for the new "drop" command. - For now we refuse to drop root commits. While we _can_ indeed drop root commits in the general case, there are edge cases where the resulting history would become completely empty. This is thus left to a subsequent patch series. Other than that, most of the logic is rather straight-forward as we can continue to build on the preexisting logic in git-history(1) for most of the part. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- Documentation/git-history.adoc | 38 ++- builtin/history.c | 184 +++++++++++ t/meson.build | 1 + t/t3454-history-drop.sh | 561 +++++++++++++++++++++++++++++++++ 4 files changed, 783 insertions(+), 1 deletion(-) create mode 100755 t/t3454-history-drop.sh diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc index 2ba812179533b8..28b477cd378150 100644 --- a/Documentation/git-history.adoc +++ b/Documentation/git-history.adoc @@ -8,6 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history SYNOPSIS -------- [synopsis] +git history drop [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)] git history fixup [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)] git history reword [--dry-run] [--update-refs=(branches|head)] git history split [--dry-run] [--update-refs=(branches|head)] [--] [...] @@ -51,13 +52,28 @@ be stateful operations. The limitation can be lifted once (if) Git learns about first-class conflicts. When using `fixup` with `--empty=drop`, dropping the root commit is not yet -supported. +supported. Likewise, `drop` cannot remove the root commit or a merge commit. COMMANDS -------- The following commands are available to rewrite history in different ways: +`drop `:: + Remove the specified commit from the history. All descendants of the + commit are replayed directly onto its parent. ++ +The root commit cannot be dropped as that may lead to edge cases where refs +end up with no commits anymore. Merge commits cannot be dropped either; see +LIMITATIONS. ++ +If `HEAD` points at a commit that is to be rewritten, the index and working +tree are updated to match the new `HEAD`. The command aborts before any +references are updated in case local modifications would be overwritten. ++ +If replaying any descendant would result in a conflict, the command aborts +with an error. + `fixup `:: Apply the currently staged changes to the specified commit. This is similar in nature to `git commit --fixup=` followed by `git @@ -170,6 +186,26 @@ The staged addition of `unrelated.txt` has been incorporated into the `first` commit. All descendant commits have been replayed on top of the rewritten history. +Drop a commit +~~~~~~~~~~~~~ + +---------- +$ git log --oneline +abc1234 (HEAD -> main) third +def5678 second +ghi9012 first + +$ git history drop 'main^{/second}' + +$ git log --oneline +jkl3456 (HEAD -> main) third +ghi9012 first +---------- + +The `second` commit has been removed from the history, and `third` has been +replayed directly on top of `first`. All branches that pointed at the dropped +commit have been moved to its parent. + Split a commit ~~~~~~~~~~~~~~ diff --git a/builtin/history.c b/builtin/history.c index 22b9fcb4a4b054..dd11ce2fa81256 100644 --- a/builtin/history.c +++ b/builtin/history.c @@ -17,13 +17,17 @@ #include "read-cache.h" #include "refs.h" #include "replay.h" +#include "reset.h" #include "revision.h" #include "sequencer.h" #include "strvec.h" #include "tree.h" +#include "tree-walk.h" #include "unpack-trees.h" #include "wt-status.h" +#define GIT_HISTORY_DROP_USAGE \ + N_("git history drop [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)]") #define GIT_HISTORY_FIXUP_USAGE \ N_("git history fixup [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]") #define GIT_HISTORY_REWORD_USAGE \ @@ -999,12 +1003,191 @@ static int cmd_history_split(int argc, return ret; } +static int update_worktree(struct repository *repo, + const struct commit *old_head, + const struct commit *new_head, + bool dry_run) +{ + struct reset_working_tree_options opts = { + .oid_from = &old_head->object.oid, + .oid = &new_head->object.oid, + }; + if (dry_run) + opts.flags |= RESET_WORKING_TREE_DRY_RUN; + return reset_working_tree(repo, &opts); +} + +static int find_head_tree_change(struct repository *repo, + const struct replay_result *result, + struct commit **old_head, + struct commit **new_head, + bool *changed) +{ + const struct replay_ref_update *head_update = NULL; + struct commit *old_head_commit, *new_head_commit; + struct tree *old_head_tree, *new_head_tree; + const char *head_target; + int head_flags; + + *changed = false; + + head_target = refs_resolve_ref_unsafe(get_main_ref_store(repo), "HEAD", + RESOLVE_REF_NO_RECURSE | RESOLVE_REF_READING, + NULL, &head_flags); + if (!head_target) + return error(_("cannot look up HEAD")); + + for (size_t i = 0; i < result->updates_nr; i++) { + if (!strcmp(result->updates[i].refname, head_target)) { + head_update = &result->updates[i]; + break; + } + } + + if (!head_update) + return 0; + + old_head_commit = lookup_commit_reference(repo, &head_update->old_oid); + new_head_commit = lookup_commit_reference(repo, &head_update->new_oid); + if (!old_head_commit || !new_head_commit) + return error(_("cannot resolve HEAD commit")); + + old_head_tree = repo_get_commit_tree(repo, old_head_commit); + new_head_tree = repo_get_commit_tree(repo, new_head_commit); + if (!old_head_tree || !new_head_tree) + return error(_("cannot resolve tree for HEAD")); + + if (oideq(&old_head_tree->object.oid, &new_head_tree->object.oid)) + return 0; + + *old_head = old_head_commit; + *new_head = new_head_commit; + *changed = true; + + return 0; +} + +static int cmd_history_drop(int argc, + const char **argv, + const char *prefix, + struct repository *repo) +{ + const char * const usage[] = { + GIT_HISTORY_DROP_USAGE, + NULL, + }; + enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP; + enum ref_action action = REF_ACTION_DEFAULT; + int dry_run = 0; + 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_CALLBACK_F(0, "empty", &empty, "(drop|keep|abort)", + N_("how to handle descendants that become empty"), + PARSE_OPT_NONEG, parse_opt_empty), + OPT_END(), + }; + struct strbuf reflog_msg = STRBUF_INIT; + struct commit *original, *rewritten; + struct rev_info revs = { 0 }; + struct replay_result result = { 0 }; + struct commit *old_head, *new_head; + bool head_moves = false; + int ret; + + argc = parse_options(argc, argv, prefix, options, usage, 0); + if (argc != 1) { + ret = error(_("command expects a single revision")); + goto out; + } + repo_config(repo, git_default_config, NULL); + + if (action == REF_ACTION_DEFAULT) + action = REF_ACTION_BRANCHES; + + original = lookup_commit_reference_by_name(argv[0]); + if (!original) { + ret = error(_("commit cannot be found: %s"), argv[0]); + goto out; + } + + if (!original->parents) { + ret = error(_("cannot drop root commit %s: " + "it has no parent to replay onto"), + argv[0]); + goto out; + } else if (original->parents->next) { + ret = error(_("cannot drop merge commit: %s"), argv[0]); + goto out; + } + + ret = setup_revwalk(repo, action, original, &revs); + if (ret) + goto out; + + rewritten = original->parents->item; + + ret = compute_pending_ref_updates(&revs, action, original, rewritten, + empty, &result); + if (ret) { + ret = error(_("failed replaying descendants")); + goto out; + } + + /* + * If HEAD will move as a result of the rewrite then we'll have to + * merge in the changes into the worktree and index. This merge can of + * course conflict, which will cause the whole operation to abort. + * + * If we had already updated the refs at that point then we'd have an + * inconsistent repository state. So we first perform a dry-run merge + * here before updating refs. + */ + if (!is_bare_repository()) { + ret = find_head_tree_change(repo, &result, &old_head, + &new_head, &head_moves); + if (ret < 0) + goto out; + + if (head_moves && update_worktree(repo, old_head, new_head, true) < 0) { + ret = error(_("dropping this commit would " + "overwrite local changes; aborting")); + goto out; + } + } + + strbuf_addf(&reflog_msg, "drop: dropping %s", argv[0]); + ret = apply_pending_ref_updates(repo, &result, reflog_msg.buf, dry_run); + if (ret < 0) { + ret = error(_("failed to update references")); + goto out; + } + + if (!dry_run && head_moves && update_worktree(repo, old_head, new_head, false) < 0) { + ret = error(_("could not update working tree to new commit %s"), + oid_to_hex(&new_head->object.oid)); + goto out; + } + + ret = 0; + +out: + replay_result_release(&result); + strbuf_release(&reflog_msg); + release_revisions(&revs); + return ret; +} + int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo) { const char * const usage[] = { + GIT_HISTORY_DROP_USAGE, GIT_HISTORY_FIXUP_USAGE, GIT_HISTORY_REWORD_USAGE, GIT_HISTORY_SPLIT_USAGE, @@ -1012,6 +1195,7 @@ int cmd_history(int argc, }; parse_opt_subcommand_fn *fn = NULL; struct option options[] = { + OPT_SUBCOMMAND("drop", &fn, cmd_history_drop), OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup), OPT_SUBCOMMAND("reword", &fn, cmd_history_reword), OPT_SUBCOMMAND("split", &fn, cmd_history_split), diff --git a/t/meson.build b/t/meson.build index 2af8d0127991db..d5e71056b203cc 100644 --- a/t/meson.build +++ b/t/meson.build @@ -399,6 +399,7 @@ integration_tests = [ 't3451-history-reword.sh', 't3452-history-split.sh', 't3453-history-fixup.sh', + 't3454-history-drop.sh', 't3500-cherry.sh', 't3501-revert-cherry-pick.sh', 't3502-cherry-pick-merge.sh', diff --git a/t/t3454-history-drop.sh b/t/t3454-history-drop.sh new file mode 100755 index 00000000000000..68a86d1e370a94 --- /dev/null +++ b/t/t3454-history-drop.sh @@ -0,0 +1,561 @@ +#!/bin/sh + +test_description='tests for git-history drop subcommand' + +. ./test-lib.sh +. "$TEST_DIRECTORY/lib-log-graph.sh" + +expect_graph () { + cat >expect && + lib_test_cmp_graph --format=%s "$@" +} + +expect_log () { + git log --format="%s" "$@" >actual && + cat >expect && + test_cmp expect actual +} + +test_expect_success 'errors on missing commit argument' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + test_must_fail git history drop 2>err && + test_grep "command expects a single revision" err + ) +' + +test_expect_success 'errors on too many arguments' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + test_must_fail git history drop HEAD HEAD 2>err && + test_grep "command expects a single revision" err + ) +' + +test_expect_success 'errors on unknown revision' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + test_must_fail git history drop does-not-exist 2>err && + test_grep "commit cannot be found: does-not-exist" err + ) +' + +test_expect_success 'errors with invalid --empty= value' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit initial && + test_commit second && + test_must_fail git history drop --empty=bogus HEAD 2>err && + test_grep "unrecognized.*--empty.*bogus" err + ) +' + +test_expect_success 'drops a commit in the middle and replays descendants' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + test_commit second && + test_commit third && + + git symbolic-ref HEAD >expect && + git history drop HEAD~ && + git symbolic-ref HEAD >actual && + test_cmp expect actual && + + expect_log <<-\EOF && + third + first + EOF + + test_must_fail git show HEAD:second.t && + test_path_is_missing second.t && + + git reflog >reflog && + test_grep "drop: dropping HEAD~" reflog + ) +' + +test_expect_success 'drops the HEAD commit' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + test_commit second && + + git history drop HEAD && + + expect_log <<-\EOF + first + EOF + ) +' + +test_expect_success 'drops a commit on detached HEAD' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + test_commit second && + test_commit third && + git checkout --detach HEAD && + + git history drop HEAD~ && + + expect_log <<-\EOF + third + first + EOF + ) +' + +# Note: in this case it would actually be fine to drop the root commit, as we +# do have a descendant commit, and no reference points to the root commit +# directly. So this is something that we may relax eventually. +test_expect_success 'refuses to drop the root commit' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + test_commit second && + + test_must_fail git history drop HEAD~ 2>err && + test_grep "cannot drop root commit" err + ) +' + +# In contrast to the above case, we actually don't want to drop the root commit +# here as that would cause us to end up with an empty commit graph. +test_expect_success 'refuses to drop the root commit when branch becomes empty' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + + test_must_fail git history drop HEAD 2>err && + test_grep "cannot drop root commit" err + ) +' + +test_expect_success 'refuses to drop a merge commit' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit base && + git branch branch && + test_commit ours && + git switch branch && + test_commit theirs && + git switch - && + git merge theirs && + + test_must_fail git history drop HEAD 2>err && + test_grep "cannot drop merge commit" err + ) +' + +test_expect_success 'refuses when descendants contain a merge commit' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit base && + test_commit middle && + git branch branch && + test_commit ours && + git switch branch && + test_commit theirs && + git switch - && + git merge theirs && + + test_must_fail git history drop middle 2>err && + test_grep "replaying merge commits is not supported yet" err + ) +' + +test_expect_success 'works in a bare repository' ' + test_when_finished "rm -rf repo repo.git" && + + git init repo && + test_commit -C repo first && + test_commit -C repo second && + test_commit -C repo third && + + git clone --bare repo repo.git && + ( + cd repo.git && + + git history drop HEAD~ && + expect_log <<-\EOF + third + first + EOF + ) +' + +test_expect_success 'updates branches on other lines of descent' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit base && + test_commit target && + git branch theirs && + test_commit ours && + git switch theirs && + test_commit theirs && + + expect_graph --branches <<-\EOF && + * theirs + | * ours + |/ + * target + * base + EOF + + git history drop target && + + expect_graph --branches <<-\EOF + * ours + | * theirs + |/ + * base + EOF + ) +' + +test_expect_success 'moves branch pointing at dropped commit to its parent' ' + test_when_finished "rm -rf repo" && + git init repo --initial-branch=main && + ( + cd repo && + test_commit first && + test_commit second && + git branch points-at-second && + test_commit third && + + git rev-parse first >expect && + git history drop second && + git rev-parse points-at-second >actual && + test_cmp expect actual && + + expect_log --format="%s %D" --branches <<-\EOF + third HEAD -> main + first tag: first, points-at-second + EOF + ) +' + +test_expect_success '--dry-run prints ref updates without modifying repo' ' + test_when_finished "rm -rf repo" && + git init repo --initial-branch=main && + ( + cd repo && + test_commit base && + git branch branch && + test_commit middle && + test_commit ours && + git switch branch && + test_commit theirs && + + git refs list >refs-expect && + git history drop --dry-run main~ >updates && + git refs list >refs-actual && + test_cmp refs-expect refs-actual && + test_grep "update refs/heads/main" updates && + + git update-ref --stdin modify-me && + + git refs list >refs-expect && + git diff >diff-expect && + test_must_fail git history drop --dry-run HEAD 2>err && + test_grep "dropping this commit would overwrite local changes" err && + git diff >diff-actual && + git refs list >refs-actual && + + test_cmp diff-expect diff-actual && + test_cmp refs-expect refs-actual + ) +' + +test_expect_success '--update-refs=head updates only HEAD' ' + test_when_finished "rm -rf repo" && + git init repo --initial-branch=main && + ( + cd repo && + test_commit base && + test_commit target && + git branch theirs && + test_commit ours && + git switch theirs && + test_commit theirs && + + # When told to update HEAD only, the command refuses to + # rewrite commits that are not an ancestor of HEAD. + test_must_fail git history drop --update-refs=head main 2>err && + test_grep "rewritten commit must be an ancestor of HEAD" err && + + expect_graph --branches <<-\EOF && + * theirs + | * ours + |/ + * target + * base + EOF + + git switch main && + git history drop --update-refs=head target && + + expect_graph --branches <<-\EOF + * ours + | * theirs + | * target + |/ + * base + EOF + ) +' + +test_expect_success '--update-refs=head can rewrite detached HEAD' ' + test_when_finished "rm -rf repo" && + git init repo --initial-branch=main && + ( + cd repo && + test_commit first && + test_commit second && + test_commit third && + git switch --detach HEAD && + + git history drop --update-refs=head second && + + expect_log HEAD <<-\EOF && + third + first + EOF + expect_log main <<-\EOF + third + second + first + EOF + ) +' + +test_expect_success 'conflict with replayed commit aborts cleanly' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit base && + test_commit conflict-a file && + test_commit conflict-b file && + + git refs list >refs-expect && + test_must_fail git history drop HEAD~ 2>err && + test_grep "failed replaying descendants" err && + git refs list >refs-actual && + test_cmp refs-expect refs-actual + ) +' + +# Build a history where a descendant of the drop target reverts the change +# introduced by the drop target. After dropping, the descendant's diff applies +# against a tree that already lacks the change, so it becomes empty. +setup_empty_descendant_repo () { + git init "$1" && + ( + cd "$1" && + echo C1 >file && + git add file && + git commit -m "base" && + git tag base && + echo C2 >file && + git add file && + git commit -m "drop-me" && + git tag drop-me && + test_commit middle && + echo C1 >file && + git add file && + git commit -m "revert-drop-me" && + git tag revert-drop-me + ) +} + +test_expect_success '--empty=drop drops descendants that become empty' ' + test_when_finished "rm -rf repo" && + setup_empty_descendant_repo repo && + ( + cd repo && + + git history drop --empty=drop drop-me && + + expect_log <<-\EOF + middle + base + EOF + ) +' + +test_expect_success '--empty=keep keeps descendants that become empty' ' + test_when_finished "rm -rf repo" && + setup_empty_descendant_repo repo && + ( + cd repo && + + git history drop --empty=keep drop-me && + + expect_log <<-\EOF && + revert-drop-me + middle + base + EOF + git diff HEAD~ HEAD >diff && + test_must_be_empty diff + ) +' + +test_expect_success '--empty=abort errors out when a descendant becomes empty' ' + test_when_finished "rm -rf repo" && + setup_empty_descendant_repo repo && + ( + cd repo && + + test_must_fail git history drop --empty=abort drop-me 2>err && + test_grep "became empty after replay" err + ) +' + +test_expect_success 'updates index and worktree when HEAD moves' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + test_commit second && + test_commit third && + + git history drop second && + + # Worktree should no longer contain second.t. + test_path_is_missing second.t && + test_path_is_file first.t && + test_path_is_file third.t && + + # Index and worktree should both match the new HEAD. + git status --porcelain --untracked-files=no >status && + test_must_be_empty status + ) +' + +test_expect_success 'updates worktree when dropping HEAD itself' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + test_commit second && + + git history drop HEAD && + + test_path_is_missing second.t && + test_path_is_file first.t && + + git status --porcelain --untracked-files=no >status && + test_must_be_empty status + ) +' + +test_expect_success 'preserves unrelated unstaged modifications' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + echo first-content >unrelated.txt && + git add unrelated.txt && + git commit -m "add unrelated" && + test_commit second && + test_commit third && + + echo locally-modified >unrelated.txt && + + git diff >diff-expect && + git history drop second && + git diff >diff-actual && + test_cmp diff-expect diff-actual && + test_path_is_missing second.t + ) +' + +test_expect_success 'preserves unrelated staged changes' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit first && + echo first-content >unrelated.txt && + git add unrelated.txt && + git commit -m "add unrelated" && + test_commit second && + test_commit third && + + echo staged-change >unrelated.txt && + git add unrelated.txt && + + git diff --cached >diff-expect && + git history drop second && + git diff --cached >diff-actual && + test_cmp diff-expect diff-actual && + test_path_is_missing second.t + ) +' + +test_expect_success 'aborts when local modifications would be overwritten' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit base && + test_commit conflict && + + echo local-edit >conflict.t && + git diff >diff-expect && + test_must_fail git history drop HEAD 2>err && + test_grep "would overwrite local changes" err && + git diff >diff-actual && + test_cmp diff-expect diff-actual + ) +' + +test_done From c3df27f347dcefc14629c7afb178420762d45eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Scharfe?= Date: Mon, 6 Jul 2026 10:38:25 +0200 Subject: [PATCH 23/29] blame: reserve mark column only if necessary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git blame prepends commit hashes of boundary commits with "^", ignored commits with "?" and unblamable commits with "*" and reserves one column for them by extending the hash abbreviation, to avoid showing ambiguous hashes. This reserved column wastes precious screen space, which can be especially irritating when using the option -b to blank out boundary commit hashes and not ignoring any commits. Reserve it only as needed, i.e. if any of those cases are actually shown. Pointed-out-by: Laszlo Ersek Signed-off-by: René Scharfe Signed-off-by: Junio C Hamano --- Documentation/git-blame.adoc | 11 +++--- builtin/blame.c | 68 ++++++++++++++++++++++++------------ t/t8002-blame.sh | 7 ++-- 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/Documentation/git-blame.adoc b/Documentation/git-blame.adoc index 8808009e87eb1d..2b74e455997c8c 100644 --- a/Documentation/git-blame.adoc +++ b/Documentation/git-blame.adoc @@ -88,11 +88,12 @@ include::blame-options.adoc[] include::diff-algorithm-option.adoc[] `--abbrev=`:: - Instead of using the default _7+1_ hexadecimal digits as the - abbreviated object name, use _+1_ digits, where __ is at - least __ but ensures the commit object names are unique. - Note that 1 column - is used for a caret to mark the boundary commit. + Instead of using the default _7_ hexadecimal digits as the + abbreviated object name, use at least __ digits, but ensure + the commit object names are unique. + If commits marked with caret (boundary), question mark (ignored) + or asterisk (unblamable) are shown, extend unmarked object names + to align them. THE DEFAULT FORMAT diff --git a/builtin/blame.c b/builtin/blame.c index ffbd3ce5c5a2e3..5ae39d0458a813 100644 --- a/builtin/blame.c +++ b/builtin/blame.c @@ -453,6 +453,36 @@ static void determine_line_heat(struct commit_info *ci, const char **dest_color) *dest_color = colorfield[i].col; } +static inline int maybe_putc(int c, FILE *out) +{ + return out ? putc(c, out) : 0; +} + +static size_t print_marks(FILE *out, const struct blame_entry *ent, int opt) +{ + size_t len = 0; + + if ((ent->suspect->commit->object.flags & UNINTERESTING) && + !blank_boundary && !(opt & OUTPUT_ANNOTATE_COMPAT)) { + maybe_putc('^', out); + len++; + } + if (mark_unblamable_lines && ent->unblamable) { + maybe_putc('*', out); + len++; + } + if (mark_ignored_lines && ent->ignored) { + maybe_putc('?', out); + len++; + } + return len; +} + +static size_t count_marks(const struct blame_entry *ent, int opt) +{ + return print_marks(NULL, ent, opt); +} + static void emit_other(struct blame_scoreboard *sb, struct blame_entry *ent, int opt, struct blame_entry *prev_ent) { @@ -499,23 +529,10 @@ static void emit_other(struct blame_scoreboard *sb, struct blame_entry *ent, if (color) fputs(color, stdout); - if (suspect->commit->object.flags & UNINTERESTING) { - if (blank_boundary) { - memset(hex, ' ', strlen(hex)); - } else if (!(opt & OUTPUT_ANNOTATE_COMPAT)) { - length--; - putchar('^'); - } - } - - if (mark_unblamable_lines && ent->unblamable) { - length--; - putchar('*'); - } - if (mark_ignored_lines && ent->ignored) { - length--; - putchar('?'); - } + if ((suspect->commit->object.flags & UNINTERESTING) && + blank_boundary) + memset(hex, ' ', strlen(hex)); + length -= print_marks(stdout, ent, opt); printf("%.*s", (int)(length < GIT_MAX_HEXSZ ? length : GIT_MAX_HEXSZ), hex); if (opt & OUTPUT_ANNOTATE_COMPAT) { @@ -647,11 +664,15 @@ static void find_alignment(struct blame_scoreboard *sb, int *option) struct blame_entry *e; int compute_auto_abbrev = (abbrev < 0); int auto_abbrev = DEFAULT_ABBREV; + size_t max_marks_count = 0; for (e = sb->ent; e; e = e->next) { struct blame_origin *suspect = e->suspect; int num; + size_t marks_count = count_marks(e, *option); + if (max_marks_count < marks_count) + max_marks_count = marks_count; if (compute_auto_abbrev) auto_abbrev = update_auto_abbrev(auto_abbrev, suspect); if (strcmp(suspect->path, sb->path)) @@ -685,8 +706,12 @@ static void find_alignment(struct blame_scoreboard *sb, int *option) max_score_digits = decimal_width(largest_score); if (compute_auto_abbrev) - /* one more abbrev length is needed for the boundary commit */ - abbrev = auto_abbrev + 1; + abbrev = auto_abbrev; + if (abbrev < (int)the_hash_algo->hexsz) { + abbrev += max_marks_count; + if (abbrev > (int)the_hash_algo->hexsz) + abbrev = the_hash_algo->hexsz; + } } static void sanity_check_on_fail(struct blame_scoreboard *sb, int baa) @@ -1047,10 +1072,7 @@ int cmd_blame(int argc, } else if (show_progress < 0) show_progress = isatty(2); - if (0 < abbrev && abbrev < (int)the_hash_algo->hexsz) - /* one more abbrev length is needed for the boundary commit */ - abbrev++; - else if (!abbrev) + if (!abbrev) abbrev = the_hash_algo->hexsz; if (revs_file && read_ancestry(revs_file)) diff --git a/t/t8002-blame.sh b/t/t8002-blame.sh index 7822947f028ee6..bf04b8273efd56 100755 --- a/t/t8002-blame.sh +++ b/t/t8002-blame.sh @@ -113,8 +113,7 @@ test_expect_success 'set up abbrev tests' ' ' test_expect_success 'blame --abbrev= works' ' - # non-boundary commits get +1 for alignment - check_abbrev 31 --abbrev=30 HEAD && + check_abbrev 30 --abbrev=30 HEAD && check_abbrev 30 --abbrev=30 ^HEAD ' @@ -141,10 +140,8 @@ test_expect_success 'blame --abbrev gets truncated with boundary commit' ' ' test_expect_success 'blame --abbrev -b truncates the blank boundary' ' - # Note that `--abbrev=` always gets incremented by 1, which is why we - # expect 11 leading spaces and not 10. cat >expect <<-EOF && - $(printf "%11s" "") ( 2005-04-07 15:45:13 -0700 1) abbrev + $(printf "%10s" "") ( 2005-04-07 15:45:13 -0700 1) abbrev EOF git blame -b --abbrev=10 ^HEAD -- abbrev.t >actual && test_cmp expect actual From 5a183de7114b0139a8a91a34c794e8be9cf9f851 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Mon, 6 Jul 2026 15:27:04 +0200 Subject: [PATCH 24/29] builtin/refs: drop `the_repository` We still have a couple of uses of `the_repository` in "builtin/refs.c". All of those are trivial to convert though as the command always requires a repository to exist. Convert them to use the passed-in repository and drop `USE_THE_REPOSITORY_VARIABLE`. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- builtin/refs.c | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/builtin/refs.c b/builtin/refs.c index e3125bc61b20e0..f0faabf45a9048 100644 --- a/builtin/refs.c +++ b/builtin/refs.c @@ -1,4 +1,3 @@ -#define USE_THE_REPOSITORY_VARIABLE #include "builtin.h" #include "config.h" #include "fsck.h" @@ -23,7 +22,7 @@ N_("git refs optimize " PACK_REFS_OPTS) static int cmd_refs_migrate(int argc, const char **argv, const char *prefix, - struct repository *repo UNUSED) + struct repository *repo) { const char * const migrate_usage[] = { REFS_MIGRATE_USAGE, @@ -59,13 +58,13 @@ static int cmd_refs_migrate(int argc, const char **argv, const char *prefix, goto out; } - if (the_repository->ref_storage_format == format) { + if (repo->ref_storage_format == format) { err = error(_("repository already uses '%s' format"), ref_storage_format_to_name(format)); goto out; } - if (repo_migrate_ref_storage_format(the_repository, format, flags, &errbuf) < 0) { + if (repo_migrate_ref_storage_format(repo, format, flags, &errbuf) < 0) { err = error("%s", errbuf.buf); goto out; } @@ -99,8 +98,8 @@ static int cmd_refs_verify(int argc, const char **argv, const char *prefix, if (argc) usage(_("'git refs verify' takes no arguments")); - repo_config(the_repository, git_fsck_config, &fsck_refs_options); - prepare_repo_settings(the_repository); + repo_config(repo, git_fsck_config, &fsck_refs_options); + prepare_repo_settings(repo); worktrees = get_worktrees_without_reading_head(); for (size_t i = 0; worktrees[i]; i++) @@ -124,7 +123,7 @@ static int cmd_refs_list(int argc, const char **argv, const char *prefix, } static int cmd_refs_exists(int argc, const char **argv, const char *prefix, - struct repository *repo UNUSED) + struct repository *repo) { struct strbuf unused_referent = STRBUF_INIT; struct object_id unused_oid; @@ -145,7 +144,7 @@ static int cmd_refs_exists(int argc, const char **argv, const char *prefix, die(_("'git refs exists' requires a reference")); ref = *argv++; - if (refs_read_raw_ref(get_main_ref_store(the_repository), ref, + if (refs_read_raw_ref(get_main_ref_store(repo), ref, &unused_oid, &unused_referent, &unused_type, &failure_errno)) { if (failure_errno == ENOENT || failure_errno == EISDIR) { From b3355995cf5adfa4b0b0c6dfddf4449f457e3912 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Mon, 6 Jul 2026 15:27:05 +0200 Subject: [PATCH 25/29] builtin/refs: add "delete" subcommand Reference-related functionality in Git is currently spread across many different commands: git-update-ref(1), git-for-each-ref(1), git-show-ref(1), git-pack-refs(1) and git-symbolic-ref(1). This makes it hard for users to discover what functionality we have available to work with references. We have thus started to consolidate this functionality into git-refs(1), which is a toolbox of everything related to references. Until now, the command doesn't handle functionality of git-update-ref(1). Fix this gap by introducing a new "delete" subcommand, which is the equivalent of `git update-ref -d`. Note that we're intentionally not using a generic "write" subcommand with a "-d" flag. This is rather harder to discover, and subcommands that are implmented as flags tend to be hard to reason about in the code as we'd have to handle mutually-exclusive flags that stem from the other subcommand-like modes. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- Documentation/git-refs.adoc | 17 ++++ builtin/refs.c | 51 ++++++++++++ t/meson.build | 1 + t/t1464-refs-delete.sh | 152 ++++++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100755 t/t1464-refs-delete.sh diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc index fa33680cc781fe..26339344632f9e 100644 --- a/Documentation/git-refs.adoc +++ b/Documentation/git-refs.adoc @@ -20,6 +20,7 @@ git refs list [--count=] [--shell|--perl|--python|--tcl] [ --stdin | (...)] git refs exists git refs optimize [--all] [--no-prune] [--auto] [--include ] [--exclude ] +git refs delete [--message=] [--no-deref] [] DESCRIPTION ----------- @@ -51,6 +52,12 @@ optimize:: usage. This subcommand is an alias for linkgit:git-pack-refs[1] and offers identical functionality. +delete:: + Delete the given reference. This subcommand mirrors `git update-ref -d` + (see linkgit:git-update-ref[1]). When `` is given, the + reference is only deleted after verifying that it currently contains + ``. + OPTIONS ------- @@ -90,6 +97,16 @@ The following options are specific to 'git refs optimize': include::pack-refs-options.adoc[] +The following options are specific to commands which write references: + +`--message=`:: + Use the given string for the reflog entry associated with the + update. An empty message is rejected. + +`--no-deref`:: + Operate on itself rather than the reference it points to via a + symbolic ref. + KNOWN LIMITATIONS ----------------- diff --git a/builtin/refs.c b/builtin/refs.c index f0faabf45a9048..edb7d616632309 100644 --- a/builtin/refs.c +++ b/builtin/refs.c @@ -21,6 +21,9 @@ #define REFS_OPTIMIZE_USAGE \ N_("git refs optimize " PACK_REFS_OPTS) +#define REFS_DELETE_USAGE \ + N_("git refs delete [--message=] [--no-deref] []") + static int cmd_refs_migrate(int argc, const char **argv, const char *prefix, struct repository *repo) { @@ -175,6 +178,52 @@ static int cmd_refs_optimize(int argc, const char **argv, const char *prefix, return pack_refs_core(argc, argv, prefix, repo, refs_optimize_usage); } +static int cmd_refs_delete(int argc, const char **argv, const char *prefix, + struct repository *repo) +{ + static char const * const refs_delete_usage[] = { + REFS_DELETE_USAGE, + NULL + }; + const char *message = NULL; + unsigned flags = 0; + struct option opts[] = { + OPT_STRING(0, "message", &message, N_("reason"), + N_("reason of the update")), + OPT_BIT(0 ,"no-deref", &flags, + N_("update not the one it points to"), + REF_NO_DEREF), + OPT_END(), + }; + struct object_id oldoid; + const char *refname; + int ret; + + argc = parse_options(argc, argv, prefix, opts, refs_delete_usage, 0); + if (argc < 1 || argc > 2) + usage(_("delete requires reference name and an optional old object ID")); + + if (message && !*message) + die(_("refusing to perform update with empty message")); + + repo_config(repo, git_default_config, NULL); + + refname = argv[0]; + if (argc == 2) { + if (repo_get_oid_with_flags(repo, argv[1], &oldoid, GET_OID_SKIP_AMBIGUITY_CHECK)) + die(_("invalid old object ID: '%s'"), argv[1]); + if (is_null_oid(&oldoid)) + die(_("cannot delete reference with null old object ID")); + } + + ret = refs_delete_ref(get_main_ref_store(repo), message, refname, + argc == 2 ? &oldoid : NULL, flags); + + if (ret < 0) + ret = 1; + return ret; +} + int cmd_refs(int argc, const char **argv, const char *prefix, @@ -186,6 +235,7 @@ int cmd_refs(int argc, "git refs list " COMMON_USAGE_FOR_EACH_REF, REFS_EXISTS_USAGE, REFS_OPTIMIZE_USAGE, + REFS_DELETE_USAGE, NULL, }; parse_opt_subcommand_fn *fn = NULL; @@ -195,6 +245,7 @@ int cmd_refs(int argc, OPT_SUBCOMMAND("list", &fn, cmd_refs_list), OPT_SUBCOMMAND("exists", &fn, cmd_refs_exists), OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize), + OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete), OPT_END(), }; diff --git a/t/meson.build b/t/meson.build index c5832fee053561..1ccf08a3b56e8b 100644 --- a/t/meson.build +++ b/t/meson.build @@ -223,6 +223,7 @@ integration_tests = [ 't1461-refs-list.sh', 't1462-refs-exists.sh', 't1463-refs-optimize.sh', + 't1464-refs-delete.sh', 't1500-rev-parse.sh', 't1501-work-tree.sh', 't1502-rev-parse-parseopt.sh', diff --git a/t/t1464-refs-delete.sh b/t/t1464-refs-delete.sh new file mode 100755 index 00000000000000..c88063e4942e89 --- /dev/null +++ b/t/t1464-refs-delete.sh @@ -0,0 +1,152 @@ +#!/bin/sh + +test_description='git refs delete' + +. ./test-lib.sh + +setup_repo () { + git init "$1" && + test_commit -C "$1" A && + test_commit -C "$1" B +} + +test_expect_success 'delete without oldvalue verification' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git update-ref refs/heads/foo $A && + git refs delete refs/heads/foo && + test_must_fail git refs exists refs/heads/foo + ) +' + +test_expect_success 'delete with matching oldvalue' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git update-ref refs/heads/foo $A && + git refs delete refs/heads/foo $A && + test_must_fail git refs exists refs/heads/foo + ) +' + +test_expect_success 'delete with stale oldvalue fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git update-ref refs/heads/foo $A && + test_must_fail git refs delete refs/heads/foo $B 2>err && + test_grep " but expected " err && + git refs exists refs/heads/foo + ) +' + +test_expect_success 'delete with null oldvalue fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git update-ref refs/heads/foo $A && + test_must_fail git refs delete refs/heads/foo $ZERO_OID 2>err && + test_grep "null old object ID" err && + git refs exists refs/heads/foo + ) +' + +test_expect_success 'delete with invalid oldvalue fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git update-ref refs/heads/foo $A && + test_must_fail git refs delete refs/heads/foo invalid-oid 2>err && + test_grep "invalid old object ID" err && + git refs exists refs/heads/foo + ) +' + +test_expect_success 'delete symref with --no-deref leaves target intact' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git update-ref refs/heads/foo $A && + git symbolic-ref refs/heads/symref refs/heads/foo && + git refs delete --no-deref refs/heads/symref && + test_must_fail git refs exists refs/heads/symref && + git refs exists refs/heads/foo + ) +' + +test_expect_success 'delete symref with --no-deref verifies target OID' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git update-ref refs/heads/foo $A && + git symbolic-ref refs/heads/symref refs/heads/foo && + + test_must_fail git refs delete --no-deref refs/heads/symref $B && + git refs exists refs/heads/symref && + + git refs delete --no-deref refs/heads/symref $A && + test_must_fail git refs exists refs/heads/symref && + git refs exists refs/heads/foo + ) +' + +test_expect_success 'delete with message records reason in reflog' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git update-ref refs/heads/foo $A && + git symbolic-ref HEAD refs/heads/foo && + git refs delete --message=delete-reason refs/heads/foo && + test_must_fail git refs exists refs/heads/foo && + test-tool ref-store main for-each-reflog-ent HEAD >actual && + test_grep "delete-reason$" actual + ) +' + +test_expect_success 'delete with empty message fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git update-ref refs/heads/foo $A && + test_must_fail git refs delete --message= refs/heads/foo 2>err && + test_grep "empty message" err && + git refs exists refs/heads/foo + ) +' + +test_expect_success 'delete without arguments fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + test_must_fail git -C repo refs delete 2>err && + test_grep "requires reference name" err +' + +test_expect_success 'delete with too many arguments fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + test_must_fail git refs delete one two three 2>err && + test_grep "requires reference name" err +' + +test_done From 2540e35bc185635d7428a1cd0f55caacd68b8ff6 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Mon, 6 Jul 2026 15:27:06 +0200 Subject: [PATCH 26/29] builtin/refs: add "update" subcommand Add a new "update" subcommand which mirrors `git update-ref `. This follows the same reasoning as the preceding commit. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- Documentation/git-refs.adoc | 12 ++ builtin/refs.c | 55 ++++++++ t/meson.build | 1 + t/t1465-refs-update.sh | 268 ++++++++++++++++++++++++++++++++++++ 4 files changed, 336 insertions(+) create mode 100755 t/t1465-refs-update.sh diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc index 26339344632f9e..6475bdcc621635 100644 --- a/Documentation/git-refs.adoc +++ b/Documentation/git-refs.adoc @@ -21,6 +21,7 @@ git refs list [--count=] [--shell|--perl|--python|--tcl] git refs exists git refs optimize [--all] [--no-prune] [--auto] [--include ] [--exclude ] git refs delete [--message=] [--no-deref] [] +git refs update [--message=] [--no-deref] [--create-reflog] [] DESCRIPTION ----------- @@ -58,6 +59,13 @@ delete:: reference is only deleted after verifying that it currently contains ``. +update:: + Update the given reference to point at ``. If `` + is given, the reference is only updated after verifying that it + currently contains ``. As a special case, an all-zeroes + `` deletes the branch, whereas an all-zeroes `` + ensures that the branch does not yet exist. + OPTIONS ------- @@ -99,6 +107,10 @@ include::pack-refs-options.adoc[] The following options are specific to commands which write references: +`--create-reflog`:: + Create a reflog for the reference even if one would not ordinarily be + created. + `--message=`:: Use the given string for the reflog entry associated with the update. An empty message is rejected. diff --git a/builtin/refs.c b/builtin/refs.c index edb7d616632309..08453ae1c893ca 100644 --- a/builtin/refs.c +++ b/builtin/refs.c @@ -24,6 +24,9 @@ #define REFS_DELETE_USAGE \ N_("git refs delete [--message=] [--no-deref] []") +#define REFS_UPDATE_USAGE \ + N_("git refs update [--message=] [--no-deref] [--create-reflog] []") + static int cmd_refs_migrate(int argc, const char **argv, const char *prefix, struct repository *repo) { @@ -224,6 +227,56 @@ static int cmd_refs_delete(int argc, const char **argv, const char *prefix, return ret; } +static int cmd_refs_update(int argc, const char **argv, const char *prefix, + struct repository *repo) +{ + static char const * const refs_update_usage[] = { + REFS_UPDATE_USAGE, + NULL + }; + const char *message = NULL; + unsigned flags = 0; + struct option opts[] = { + OPT_STRING(0, "message", &message, N_("reason"), + N_("reason of the update")), + OPT_BIT(0 ,"no-deref", &flags, + N_("update not the one it points to"), + REF_NO_DEREF), + OPT_BIT(0, "create-reflog", &flags, N_("create a reflog"), + REF_FORCE_CREATE_REFLOG), + OPT_END(), + }; + struct object_id newoid, oldoid; + const char *refname; + int ret; + + argc = parse_options(argc, argv, prefix, opts, refs_update_usage, 0); + if (argc < 2 || argc > 3) + usage(_("update requires reference name, new value and an optional old value")); + + if (message && !*message) + die(_("refusing to perform update with empty message")); + + repo_config(repo, git_default_config, NULL); + + refname = argv[0]; + if (repo_get_oid_with_flags(repo, argv[1], &newoid, + GET_OID_SKIP_AMBIGUITY_CHECK)) + die(_("invalid new object ID: '%s'"), argv[1]); + if (argc == 3 && + repo_get_oid_with_flags(repo, argv[2], &oldoid, + GET_OID_SKIP_AMBIGUITY_CHECK)) + die(_("invalid old object ID: '%s'"), argv[2]); + + ret = refs_update_ref(get_main_ref_store(repo), message, refname, + &newoid, argc == 3 ? &oldoid : NULL, flags, + UPDATE_REFS_MSG_ON_ERR); + + if (ret < 0) + ret = 1; + return ret; +} + int cmd_refs(int argc, const char **argv, const char *prefix, @@ -236,6 +289,7 @@ int cmd_refs(int argc, REFS_EXISTS_USAGE, REFS_OPTIMIZE_USAGE, REFS_DELETE_USAGE, + REFS_UPDATE_USAGE, NULL, }; parse_opt_subcommand_fn *fn = NULL; @@ -246,6 +300,7 @@ int cmd_refs(int argc, OPT_SUBCOMMAND("exists", &fn, cmd_refs_exists), OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize), OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete), + OPT_SUBCOMMAND("update", &fn, cmd_refs_update), OPT_END(), }; diff --git a/t/meson.build b/t/meson.build index 1ccf08a3b56e8b..2063962dabae67 100644 --- a/t/meson.build +++ b/t/meson.build @@ -224,6 +224,7 @@ integration_tests = [ 't1462-refs-exists.sh', 't1463-refs-optimize.sh', 't1464-refs-delete.sh', + 't1465-refs-update.sh', 't1500-rev-parse.sh', 't1501-work-tree.sh', 't1502-rev-parse-parseopt.sh', diff --git a/t/t1465-refs-update.sh b/t/t1465-refs-update.sh new file mode 100755 index 00000000000000..a9becdda99c09b --- /dev/null +++ b/t/t1465-refs-update.sh @@ -0,0 +1,268 @@ +#!/bin/sh + +test_description='git refs update' + +. ./test-lib.sh + +setup_repo () { + git init "$1" && + test_commit -C "$1" A && + test_commit -C "$1" B +} + +test_ref_matches () { + git rev-parse "$1" >expect && + echo "$2" >actual && + test_cmp expect actual +} + +test_expect_success 'update creates a new reference' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs update refs/heads/foo $A && + test_ref_matches refs/heads/foo "$A" + ) +' + +test_expect_success 'update an existing reference without oldvalue' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git refs update refs/heads/foo $A && + git refs update refs/heads/foo $B && + test_ref_matches refs/heads/foo $B + ) +' + +test_expect_success 'update with matching oldvalue' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git refs update refs/heads/foo $A && + git refs update refs/heads/foo $B $A && + test_ref_matches refs/heads/foo $B + ) +' + +test_expect_success 'update with stale oldvalue fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git refs update refs/heads/foo $A && + test_must_fail git refs update refs/heads/foo $B $B 2>err && + test_grep " but expected " err && + test_ref_matches refs/heads/foo $A + ) +' + +test_expect_success 'update can create a new branch with oldvalue' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs update refs/heads/foo $A $ZERO_OID 2>err && + test_ref_matches refs/heads/foo $A + ) +' + +test_expect_success 'update can create a new branch without oldvalue' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs update refs/heads/foo $A 2>err && + test_ref_matches refs/heads/foo $A + ) +' + +test_expect_success 'update refuses to create preexisting branch' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git refs update refs/heads/foo $A && + test_must_fail git refs update refs/heads/foo $B $ZERO_OID 2>err && + test_grep "reference already exists" err && + test_ref_matches refs/heads/foo $A + ) +' + +test_expect_success 'update can delete a branch with oldvalue' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs update refs/heads/foo $A 2>err && + git refs update refs/heads/foo $ZERO_OID $A 2>err && + test_must_fail git refs exists refs/heads/foo + ) +' + +test_expect_success 'update can delete a branch without oldvalue' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs update refs/heads/foo $A 2>err && + git refs update refs/heads/foo $ZERO_OID 2>err && + test_must_fail git refs exists refs/heads/foo + ) +' + +test_expect_success 'update refuses to delete a branch with mismatching value' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git refs update refs/heads/foo $A 2>err && + test_must_fail git refs update refs/heads/foo $ZERO_OID $B 2>err && + test_grep " but expected " err && + git refs exists refs/heads/foo + ) +' + +test_expect_success 'update refuses to create preexisting branch' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git refs update refs/heads/foo $A && + test_must_fail git refs update refs/heads/foo $B $ZERO_OID 2>err && + test_grep "reference already exists" err && + test_ref_matches refs/heads/foo $A + ) +' + + +test_expect_success 'update with invalid new value fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + test_must_fail git refs update refs/heads/foo invalid-oid 2>err && + test_grep "invalid new object ID" err && + test_must_fail git refs exists refs/heads/foo + ) +' + +test_expect_success 'update with invalid old value fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git refs update refs/heads/foo $A && + test_must_fail git refs update refs/heads/foo $B invalid-oid 2>err && + test_grep "invalid old object ID" err && + test_ref_matches refs/heads/foo $A + ) +' + +test_expect_success 'update --no-deref rewrites the symref itself' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git refs update refs/heads/foo $A && + git symbolic-ref refs/heads/symref refs/heads/foo && + git refs update --no-deref refs/heads/symref $B && + test_must_fail git symbolic-ref refs/heads/symref && + test_ref_matches refs/heads/symref $B && + test_ref_matches refs/heads/foo $A + ) +' + +test_expect_success 'update does not create a reflog by default' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs update refs/foo $A && + test_must_fail git reflog exists refs/foo + ) +' + +test_expect_success 'update creates a reflog with --create-reflog' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs update --create-reflog refs/foo $A && + git reflog exists refs/foo + ) +' + +test_expect_success 'update with message records reason in reflog' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git refs update refs/heads/foo $A && + git refs update --message=update-reason refs/heads/foo $B && + git reflog show refs/heads/foo >actual && + test_grep "update-reason$" actual + ) +' + +test_expect_success 'update with empty message fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git refs update refs/heads/foo $A && + test_must_fail git refs update --message= refs/heads/foo $B 2>err && + test_grep "empty message" err + ) +' + +test_expect_success 'update with too few arguments fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + test_must_fail git -C repo refs update refs/heads/foo 2>err && + test_grep "requires reference name, new value" err +' + +test_expect_success 'update with too many arguments fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + test_must_fail git refs update refs/heads/foo $A $B extra 2>err && + test_grep "requires reference name, new value" err + ) +' + +test_done From fa4eefe900b679c58ee0013198a9b11d036531ad Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Mon, 6 Jul 2026 15:27:07 +0200 Subject: [PATCH 27/29] builtin/refs: add "create" subcommand The "update" subcommand cannot only update an existing reference, but it can also create new branches and delete existing branches by specifying the all-zeroes object ID as either old or new value. Despite that, we already have the "delete" subcommand as a handy shortcut so that a user can easily delete a branch. This relieves them of needing to understand the more arcane uses of the "update" command, and of counting the number of zeroes they need to pass. But while we have a "delete" subcommand, we don't have an equivalent that would allow the user to create a new branch, which creates a certain asymmetry. Add a new "create" subcommand to plug this gap. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- Documentation/git-refs.adoc | 5 ++ builtin/refs.c | 52 +++++++++++++ t/meson.build | 1 + t/t1466-refs-create.sh | 151 ++++++++++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+) create mode 100755 t/t1466-refs-create.sh diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc index 6475bdcc621635..e6a3528349d269 100644 --- a/Documentation/git-refs.adoc +++ b/Documentation/git-refs.adoc @@ -20,6 +20,7 @@ git refs list [--count=] [--shell|--perl|--python|--tcl] [ --stdin | (...)] git refs exists git refs optimize [--all] [--no-prune] [--auto] [--include ] [--exclude ] +git refs create [--message=] [--no-deref] [--create-reflog] git refs delete [--message=] [--no-deref] [] git refs update [--message=] [--no-deref] [--create-reflog] [] @@ -53,6 +54,10 @@ optimize:: usage. This subcommand is an alias for linkgit:git-pack-refs[1] and offers identical functionality. +create:: + Create the given reference, which must not already exist, pointing at + ``. + delete:: Delete the given reference. This subcommand mirrors `git update-ref -d` (see linkgit:git-update-ref[1]). When `` is given, the diff --git a/builtin/refs.c b/builtin/refs.c index 08453ae1c893ca..1ebaf30149d8a1 100644 --- a/builtin/refs.c +++ b/builtin/refs.c @@ -21,6 +21,9 @@ #define REFS_OPTIMIZE_USAGE \ N_("git refs optimize " PACK_REFS_OPTS) +#define REFS_CREATE_USAGE \ + N_("git refs create [--message=] [--no-deref] [--create-reflog] ") + #define REFS_DELETE_USAGE \ N_("git refs delete [--message=] [--no-deref] []") @@ -181,6 +184,53 @@ static int cmd_refs_optimize(int argc, const char **argv, const char *prefix, return pack_refs_core(argc, argv, prefix, repo, refs_optimize_usage); } +static int cmd_refs_create(int argc, const char **argv, const char *prefix, + struct repository *repo) +{ + static char const * const refs_create_usage[] = { + REFS_CREATE_USAGE, + NULL + }; + const char *message = NULL; + unsigned flags = 0; + struct option opts[] = { + OPT_STRING(0, "message", &message, N_("reason"), + N_("reason of the update")), + OPT_BIT(0 ,"no-deref", &flags, + N_("update not the one it points to"), + REF_NO_DEREF), + OPT_BIT(0, "create-reflog", &flags, N_("create a reflog"), + REF_FORCE_CREATE_REFLOG), + OPT_END(), + }; + struct object_id newoid; + const char *refname; + int ret; + + argc = parse_options(argc, argv, prefix, opts, refs_create_usage, 0); + if (argc != 2) + usage(_("create requires reference name and an object ID")); + + if (message && !*message) + die(_("refusing to perform update with empty message")); + + repo_config(repo, git_default_config, NULL); + + refname = argv[0]; + if (repo_get_oid_with_flags(repo, argv[1], &newoid, GET_OID_SKIP_AMBIGUITY_CHECK)) + die(_("invalid object ID: '%s'"), argv[1]); + if (is_null_oid(&newoid)) + die(_("cannot create reference with null new object ID")); + + ret = refs_update_ref(get_main_ref_store(repo), message, refname, + &newoid, null_oid(repo->hash_algo), flags, + UPDATE_REFS_MSG_ON_ERR); + + if (ret < 0) + ret = 1; + return ret; +} + static int cmd_refs_delete(int argc, const char **argv, const char *prefix, struct repository *repo) { @@ -288,6 +338,7 @@ int cmd_refs(int argc, "git refs list " COMMON_USAGE_FOR_EACH_REF, REFS_EXISTS_USAGE, REFS_OPTIMIZE_USAGE, + REFS_CREATE_USAGE, REFS_DELETE_USAGE, REFS_UPDATE_USAGE, NULL, @@ -299,6 +350,7 @@ int cmd_refs(int argc, OPT_SUBCOMMAND("list", &fn, cmd_refs_list), OPT_SUBCOMMAND("exists", &fn, cmd_refs_exists), OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize), + OPT_SUBCOMMAND("create", &fn, cmd_refs_create), OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete), OPT_SUBCOMMAND("update", &fn, cmd_refs_update), OPT_END(), diff --git a/t/meson.build b/t/meson.build index 2063962dabae67..541e6f919c5561 100644 --- a/t/meson.build +++ b/t/meson.build @@ -225,6 +225,7 @@ integration_tests = [ 't1463-refs-optimize.sh', 't1464-refs-delete.sh', 't1465-refs-update.sh', + 't1466-refs-create.sh', 't1500-rev-parse.sh', 't1501-work-tree.sh', 't1502-rev-parse-parseopt.sh', diff --git a/t/t1466-refs-create.sh b/t/t1466-refs-create.sh new file mode 100755 index 00000000000000..cfb21bf8632235 --- /dev/null +++ b/t/t1466-refs-create.sh @@ -0,0 +1,151 @@ +#!/bin/sh + +test_description='git refs create' + +. ./test-lib.sh + +setup_repo () { + git init "$1" && + test_commit -C "$1" A && + test_commit -C "$1" B +} + +test_ref_matches () { + git rev-parse "$1" >expect && + echo "$2" >actual && + test_cmp expect actual +} + +test_expect_success 'create a new reference' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs create refs/heads/foo $A && + test_ref_matches refs/heads/foo "$A" + ) +' + +test_expect_success 'create fails when the reference already exists' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git refs create refs/heads/foo $A && + test_must_fail git refs create refs/heads/foo $B 2>err && + test_grep "reference already exists" err && + test_ref_matches refs/heads/foo "$A" + ) +' + +test_expect_success 'create with null new value fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + test_must_fail git refs create refs/heads/foo $ZERO_OID 2>err && + test_grep "null new object ID" err && + test_must_fail git refs exists refs/heads/foo + ) +' + +test_expect_success 'create with invalid new value fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + test_must_fail git refs create refs/heads/foo invalid-oid 2>err && + test_grep "invalid object ID" err && + test_must_fail git refs exists refs/heads/foo + ) +' + +test_expect_success 'create does not create a reflog by default' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs create refs/foo $A && + test_must_fail git reflog exists refs/foo + ) +' + +test_expect_success 'create creates a reflog with --create-reflog' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs create --create-reflog refs/foo $A && + git reflog exists refs/foo + ) +' + +test_expect_success 'create with message records reason in reflog' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs create --message="create reason" refs/heads/foo $A && + git reflog show refs/heads/foo >actual && + test_grep "create reason$" actual + ) +' + +test_expect_success 'create with symref target creates target reference' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git symbolic-ref refs/heads/symref refs/heads/target && + git refs create refs/heads/symref $A && + git reflog exists refs/heads/target + ) +' + +test_expect_success 'create with symref target and --no-deref refuses to create reference' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git symbolic-ref refs/heads/symref refs/heads/target && + test_must_fail git refs create --no-deref refs/heads/symref $A 2>err && + test_grep "dangling symref already exists" err && + test_must_fail git reflog exists refs/heads/target + ) +' + +test_expect_success 'create with empty message fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + test_must_fail git refs create --message= refs/heads/foo $A 2>err && + test_grep "empty message" err && + test_must_fail git refs exists refs/heads/foo + ) +' + +test_expect_success 'create without arguments fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + test_must_fail git -C repo refs create 2>err && + test_grep "requires reference name" err +' + +test_expect_success 'create with too many arguments fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + test_must_fail git -C repo refs create refs/heads/foo a b 2>err && + test_grep "requires reference name" err +' + +test_done From 002fe677caddc8162949315c73e53422d4e0f4e8 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Mon, 6 Jul 2026 15:27:08 +0200 Subject: [PATCH 28/29] builtin/refs: add "rename" subcommand Add a "rename" subcommand to git-refs(1) with the syntax: $ git refs rename It renames together with its reflog to ; even when used on a local branch ref, the current value and the reflog of the ref are the only things that are renamed. Document it and redirect casual users to "git branch -m" if that is what they wanted to do. Co-authored-by: Junio C Hamano Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- Documentation/git-refs.adoc | 6 ++ builtin/refs.c | 49 ++++++++++++ t/meson.build | 1 + t/t1467-refs-rename.sh | 144 ++++++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100755 t/t1467-refs-rename.sh diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc index e6a3528349d269..ce278c59bfc1dc 100644 --- a/Documentation/git-refs.adoc +++ b/Documentation/git-refs.adoc @@ -23,6 +23,7 @@ git refs optimize [--all] [--no-prune] [--auto] [--include ] [--exclude git refs create [--message=] [--no-deref] [--create-reflog] git refs delete [--message=] [--no-deref] [] git refs update [--message=] [--no-deref] [--create-reflog] [] +git refs rename [--message=] DESCRIPTION ----------- @@ -71,6 +72,11 @@ update:: `` deletes the branch, whereas an all-zeroes `` ensures that the branch does not yet exist. +rename:: + Rename the reference `` to ``. The old reference must + exist and the new reference must not yet exist, and both must have a + well-formed name (see linkgit:git-check-ref-format[1]). + OPTIONS ------- diff --git a/builtin/refs.c b/builtin/refs.c index 1ebaf30149d8a1..a9ca2058eeb55c 100644 --- a/builtin/refs.c +++ b/builtin/refs.c @@ -30,6 +30,9 @@ #define REFS_UPDATE_USAGE \ N_("git refs update [--message=] [--no-deref] [--create-reflog] []") +#define REFS_RENAME_USAGE \ + N_("git refs rename [--message=] ") + static int cmd_refs_migrate(int argc, const char **argv, const char *prefix, struct repository *repo) { @@ -327,6 +330,50 @@ static int cmd_refs_update(int argc, const char **argv, const char *prefix, return ret; } +static int cmd_refs_rename(int argc, const char **argv, const char *prefix, + struct repository *repo) +{ + static char const * const refs_rename_usage[] = { + REFS_RENAME_USAGE, + NULL + }; + const char *message = NULL; + struct option opts[] = { + OPT_STRING(0, "message", &message, N_("reason"), + N_("reason of the update")), + OPT_END(), + }; + const char *oldref, *newref; + int ret; + + argc = parse_options(argc, argv, prefix, opts, refs_rename_usage, 0); + if (argc != 2) + usage(_("rename requires old and new reference name")); + if (message && !*message) + die(_("refusing to perform update with empty message")); + + repo_config(repo, git_default_config, NULL); + + oldref = argv[0]; + newref = argv[1]; + + if (check_refname_format(oldref, 0)) + die(_("invalid ref format: '%s'"), oldref); + if (check_refname_format(newref, 0)) + die(_("invalid ref format: '%s'"), newref); + + if (!refs_ref_exists(get_main_ref_store(repo), oldref)) + die(_("reference does not exist: '%s'"), oldref); + if (refs_ref_exists(get_main_ref_store(repo), newref)) + die(_("reference already exists: '%s'"), newref); + + ret = refs_rename_ref(get_main_ref_store(repo), oldref, newref, message); + + if (ret < 0) + ret = 1; + return ret; +} + int cmd_refs(int argc, const char **argv, const char *prefix, @@ -341,6 +388,7 @@ int cmd_refs(int argc, REFS_CREATE_USAGE, REFS_DELETE_USAGE, REFS_UPDATE_USAGE, + REFS_RENAME_USAGE, NULL, }; parse_opt_subcommand_fn *fn = NULL; @@ -353,6 +401,7 @@ int cmd_refs(int argc, OPT_SUBCOMMAND("create", &fn, cmd_refs_create), OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete), OPT_SUBCOMMAND("update", &fn, cmd_refs_update), + OPT_SUBCOMMAND("rename", &fn, cmd_refs_rename), OPT_END(), }; diff --git a/t/meson.build b/t/meson.build index 541e6f919c5561..a39fd8c4c445d5 100644 --- a/t/meson.build +++ b/t/meson.build @@ -226,6 +226,7 @@ integration_tests = [ 't1464-refs-delete.sh', 't1465-refs-update.sh', 't1466-refs-create.sh', + 't1467-refs-rename.sh', 't1500-rev-parse.sh', 't1501-work-tree.sh', 't1502-rev-parse-parseopt.sh', diff --git a/t/t1467-refs-rename.sh b/t/t1467-refs-rename.sh new file mode 100755 index 00000000000000..2b28be75c82cb2 --- /dev/null +++ b/t/t1467-refs-rename.sh @@ -0,0 +1,144 @@ +#!/bin/sh + +test_description='git refs rename' + +. ./test-lib.sh + +setup_repo () { + git init "$1" && + test_commit -C "$1" A && + test_commit -C "$1" B +} + +test_ref_matches () { + git rev-parse "$1" >expect && + echo "$2" >actual && + test_cmp expect actual +} + +test_expect_success 'rename an existing reference' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs update refs/heads/foo $A && + git refs rename refs/heads/foo refs/heads/bar && + test_must_fail git refs exists refs/heads/foo && + test_ref_matches refs/heads/bar $A + ) +' + +test_expect_success 'rename moves the reflog along with the reference' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs update --message="rename me" refs/heads/foo $A && + git refs rename refs/heads/foo refs/heads/bar && + git reflog show refs/heads/bar >reflog && + test_grep "rename me" reflog && + test_must_fail git reflog exists refs/heads/foo + ) +' + +test_expect_success 'rename with message records reason in reflog' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs update refs/heads/foo $A && + git refs rename --message="rename reason" refs/heads/foo refs/heads/bar && + git reflog show refs/heads/bar >actual && + test_grep "rename reason" actual + ) +' + +test_expect_success 'rename a nonexistent reference fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + test_must_fail git refs rename refs/heads/foo refs/heads/bar 2>err && + test_grep "reference does not exist" err + ) +' + +test_expect_success 'rename to an existing reference fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + B=$(git rev-parse B) && + git refs update refs/heads/foo $A && + git refs update refs/heads/bar $B && + test_must_fail git refs rename refs/heads/foo refs/heads/bar 2>err && + test_grep "reference already exists" err + ) +' + +test_expect_success 'rename with symbolic ref fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs create refs/heads/target $A && + git symbolic-ref refs/heads/symref refs/heads/target && + ! git refs rename refs/heads/symref refs/heads/renamed 2>err && + test_grep "is a symbolic ref, .* not supported" err + ) +' + +test_expect_success 'rename with empty message fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs update refs/heads/foo $A && + test_must_fail git refs rename --message= refs/heads/foo refs/heads/bar 2>err && + test_grep "empty message" err + ) +' + +test_expect_success 'rename with invalid old reference name fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + test_must_fail git refs rename "refs/heads/foo..bar" refs/heads/bar 2>err && + test_grep "invalid ref format" err + ) +' + +test_expect_success 'rename with invalid new reference name fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + ( + cd repo && + A=$(git rev-parse A) && + git refs update refs/heads/foo $A && + test_must_fail git refs rename refs/heads/foo "refs/heads/bar..baz" 2>err && + test_grep "invalid ref format" err + ) +' + +test_expect_success 'rename with too few arguments fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + test_must_fail git -C repo refs rename refs/heads/foo 2>err && + test_grep "requires old and new reference name" err +' + +test_expect_success 'rename with too many arguments fails' ' + test_when_finished "rm -rf repo" && + setup_repo repo && + test_must_fail git -C repo refs rename refs/heads/foo refs/heads/bar refs/heads/baz 2>err && + test_grep "requires old and new reference name" err +' + +test_done From d35c5399e3e54ac277bb391fc2f6be3e816d312b Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Wed, 15 Jul 2026 13:24:06 -0700 Subject: [PATCH 29/29] The 3rd batch for Git 2.56 Signed-off-by: Junio C Hamano --- Documentation/RelNotes/2.56.0.adoc | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Documentation/RelNotes/2.56.0.adoc b/Documentation/RelNotes/2.56.0.adoc index 2d01f5cdc0ab74..471b84194c4d58 100644 --- a/Documentation/RelNotes/2.56.0.adoc +++ b/Documentation/RelNotes/2.56.0.adoc @@ -31,6 +31,18 @@ UI, Workflows & Features command is now recognized as a potential typo, and advice has been added to offer a typo fix. + * The 'git refs' toolbox has been extended with new 'create', 'delete', + 'update', and 'rename' subcommands to create, delete, update, and + rename references, respectively. + + * The experimental 'git history' command has been taught a new 'drop' + subcommand to remove a commit, with its descendants replayed onto its + parent. + + * The alignment of commit object name abbreviations in 'git blame' + output has been optimized to reserve a column for marks (caret, + question mark, or asterisk) only when such marks are actually shown. + Performance, Internal Implementation, Development Support etc. -------------------------------------------------------------- @@ -88,6 +100,14 @@ Performance, Internal Implementation, Development Support etc. flag, and a new 'odb_prepare()' wrapper has been introduced to allow pre-opening object database sources. + * The 'whence' field in 'struct object_info' has been removed. The + backend-specific object information retrieval has been refactored into + an opt-in 'struct object_info_source' structure. + + * A racy build failure under Meson has been corrected by ensuring that + the generated header file 'hook-list.h' is built before compiling + files in 'builtin_sources' that depend on it. + Fixes since v2.55 ----------------- @@ -140,3 +160,12 @@ Fixes since v2.55 plugged, and the leak reporting of the test suite when running under a TAP harness has been improved. (merge 973a0373ff jk/format-patch-leakfix later to maint). + + * A write file stream resource leak has been fixed as part of a code + cleanup. + (merge ebb4d2ffa3 jc/history-message-prep-fix later to maint). + + * Various memory leaks in the Bloom-filter code paths that are exposed + when running tests with the 'GIT_TEST_COMMIT_GRAPH_CHANGED_PATHS=1' + environment variable have been plugged. + (merge 459088ec2e jk/bloom-leak-fixes later to maint).